frame New Primitive

This commit is contained in:
lda
2026-04-28 12:41:16 +07:00 Verified
parent db2bc99ff2
commit 9f24c61889
4 changed files with 103 additions and 23 deletions
+6
View File
@@ -320,6 +320,12 @@ At minimum, run state should track:
This gives the engine a clean path toward checkpointing, interrupt, and resume later. This gives the engine a clean path toward checkpointing, interrupt, and resume later.
Run state should also carry execution frames.
- v1 may only have a root workflow frame
- future `foreach` and subgraph execution should attach work to child frames
- interrupts should belong to a frame, not just to the run globally
Two useful execution entry points fall out of this: Two useful execution entry points fall out of this:
- `step_workflow(...)` for one-node advancement - `step_workflow(...)` for one-node advancement
+9 -1
View File
@@ -19,7 +19,14 @@ from .runtime import (
resume_workflow, resume_workflow,
step_workflow, step_workflow,
) )
from .run_state import InterruptRequest, RunState, RunStatus, RuntimeContext, TraceEntry from .run_state import (
ExecutionFrame,
InterruptRequest,
RunState,
RunStatus,
RuntimeContext,
TraceEntry,
)
from .tokens import END, START from .tokens import END, START
from .validate import ( from .validate import (
ValidationIssue, ValidationIssue,
@@ -40,6 +47,7 @@ __all__ = [
"StateField", "StateField",
"StateSchema", "StateSchema",
"NodeHandler", "NodeHandler",
"ExecutionFrame",
"RunState", "RunState",
"RunStatus", "RunStatus",
"RuntimeContext", "RuntimeContext",
+27
View File
@@ -13,9 +13,21 @@ class RunStatus(StrEnum):
INTERRUPTED = "interrupted" INTERRUPTED = "interrupted"
@dataclass(slots=True)
class ExecutionFrame:
id: str
kind: str
node_id: str
parent_frame_id: str | None = None
prior_outcome: str | None = None
activated_incoming_edge: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True) @dataclass(slots=True)
class RuntimeContext: class RuntimeContext:
current_node_id: str current_node_id: str
frame_id: str = "root"
retry_count: int = 0 retry_count: int = 0
prior_outcome: str | None = None prior_outcome: str | None = None
activated_incoming_edge: str | None = None activated_incoming_edge: str | None = None
@@ -23,6 +35,7 @@ class RuntimeContext:
@dataclass(slots=True) @dataclass(slots=True)
class TraceEntry: class TraceEntry:
frame_id: str
node_id: str node_id: str
step_type: str step_type: str
resolved_input: dict[str, Any] resolved_input: dict[str, Any]
@@ -35,6 +48,7 @@ class TraceEntry:
@dataclass(slots=True) @dataclass(slots=True)
class InterruptRequest: class InterruptRequest:
id: str id: str
frame_id: str
node_id: str node_id: str
kind: str kind: str
payload: dict[str, Any] = field(default_factory=dict) payload: dict[str, Any] = field(default_factory=dict)
@@ -49,11 +63,24 @@ class RunState:
state: dict[str, Any] state: dict[str, Any]
output: dict[str, Any] = field(default_factory=dict) output: dict[str, Any] = field(default_factory=dict)
trace: list[TraceEntry] = field(default_factory=list) trace: list[TraceEntry] = field(default_factory=list)
frames: dict[str, ExecutionFrame] = field(default_factory=dict)
current_frame_id: str | None = None
current_node_id: str | None = None current_node_id: str | None = None
prior_outcome: str | None = None prior_outcome: str | None = None
activated_incoming_edge: str | None = None activated_incoming_edge: str | None = None
error: str | None = None error: str | None = None
interrupt: InterruptRequest | None = None interrupt: InterruptRequest | None = None
def current_frame(self) -> ExecutionFrame:
if self.current_frame_id is None:
raise ValueError("run has no current frame")
return self.frames[self.current_frame_id]
def sync_from_current_frame(self) -> None:
frame = self.current_frame()
self.current_node_id = frame.node_id
self.prior_outcome = frame.prior_outcome
self.activated_incoming_edge = frame.activated_incoming_edge
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return asdict(self) return asdict(self)
+61 -22
View File
@@ -15,7 +15,14 @@ from .model import (
NodeUse, NodeUse,
Workflow, Workflow,
) )
from .run_state import InterruptRequest, RunState, RunStatus, RuntimeContext, TraceEntry from .run_state import (
ExecutionFrame,
InterruptRequest,
RunState,
RunStatus,
RuntimeContext,
TraceEntry,
)
from .schema_tools import validate_payload_against_schema from .schema_tools import validate_payload_against_schema
from .state_ops import apply_mapped_state, apply_output_map, project_output from .state_ops import apply_mapped_state, apply_output_map, project_output
from .tokens import END from .tokens import END
@@ -34,8 +41,17 @@ def execute_workflow(
status=RunStatus.PENDING, status=RunStatus.PENDING,
workflow_input=dict(workflow_input), workflow_input=dict(workflow_input),
state=dict(workflow_input), state=dict(workflow_input),
frames={
"root": ExecutionFrame(
id="root",
kind="workflow",
node_id=workflow.start,
)
},
current_frame_id="root",
current_node_id=workflow.start, current_node_id=workflow.start,
) )
run.sync_from_current_frame()
try: try:
workflow.validate_structure().raise_for_errors() workflow.validate_structure().raise_for_errors()
@@ -62,6 +78,10 @@ def resume_workflow(
f"run state belongs to workflow {run.workflow_name!r}, not {workflow.name!r}" f"run state belongs to workflow {run.workflow_name!r}, not {workflow.name!r}"
) )
if run.current_frame_id is None:
raise WorkflowExecutionError("run has no current frame")
run.sync_from_current_frame()
if run.current_node_id is None: if run.current_node_id is None:
raise WorkflowExecutionError("run has no current node") raise WorkflowExecutionError("run has no current node")
@@ -124,6 +144,10 @@ def step_workflow(
nodes_by_id: dict[str, Any] | None = None, nodes_by_id: dict[str, Any] | None = None,
edge_map: dict[tuple[str, str], str] | None = None, edge_map: dict[tuple[str, str], str] | None = None,
) -> RunState: ) -> RunState:
if run.current_frame_id is None:
raise WorkflowExecutionError("run has no current frame")
run.sync_from_current_frame()
if run.current_node_id is None or run.current_node_id == END: if run.current_node_id is None or run.current_node_id == END:
return run return run
if run.status == RunStatus.INTERRUPTED: if run.status == RunStatus.INTERRUPTED:
@@ -141,7 +165,8 @@ def step_workflow(
(edge.from_, edge.outcome): edge.to for edge in workflow.edges (edge.from_, edge.outcome): edge.to for edge in workflow.edges
} }
step = nodes_by_id[run.current_node_id] frame = run.current_frame()
step = nodes_by_id[frame.node_id]
if isinstance(step, NodeUse): if isinstance(step, NodeUse):
node_def = node_defs[step.node] node_def = node_defs[step.node]
@@ -149,7 +174,7 @@ def step_workflow(
outcome = step_result["outcome"] outcome = step_result["outcome"]
elif isinstance(step, ConditionNode): elif isinstance(step, ConditionNode):
predicate = eval_condition( predicate = eval_condition(
step.check, run.state, run.workflow_input, run.prior_outcome step.check, run.state, run.workflow_input, frame.prior_outcome
) )
outcome = "true" if predicate else "false" outcome = "true" if predicate else "false"
step_result = { step_result = {
@@ -167,18 +192,20 @@ def step_workflow(
elif isinstance(step, InterruptNode): elif isinstance(step, InterruptNode):
interrupt_request = _build_interrupt_request( interrupt_request = _build_interrupt_request(
step, step,
run.state, frame_id=frame.id,
run.workflow_input, state=run.state,
workflow_input=run.workflow_input,
) )
run.interrupt = interrupt_request run.interrupt = interrupt_request
run.status = RunStatus.INTERRUPTED run.status = RunStatus.INTERRUPTED
run.trace.append( run.trace.append(
TraceEntry( TraceEntry(
node_id=run.current_node_id, frame_id=frame.id,
node_id=frame.node_id,
step_type=step.type, step_type=step.type,
resolved_input=interrupt_request.payload, resolved_input=interrupt_request.payload,
outcome="interrupt", outcome="interrupt",
next_node_id=run.current_node_id, next_node_id=frame.node_id,
output=interrupt_request.payload, output=interrupt_request.payload,
state_changes={}, state_changes={},
) )
@@ -189,15 +216,16 @@ def step_workflow(
else: else:
raise WorkflowExecutionError(f"unsupported step type {step.type!r}") raise WorkflowExecutionError(f"unsupported step type {step.type!r}")
next_node_id = edge_map.get((run.current_node_id, outcome)) next_node_id = edge_map.get((frame.node_id, outcome))
if next_node_id is None: if next_node_id is None:
raise WorkflowExecutionError( raise WorkflowExecutionError(
f"no edge found for node {run.current_node_id!r} and outcome {outcome!r}" f"no edge found for node {frame.node_id!r} and outcome {outcome!r}"
) )
run.trace.append( run.trace.append(
TraceEntry( TraceEntry(
node_id=run.current_node_id, frame_id=frame.id,
node_id=frame.node_id,
step_type=step.type, step_type=step.type,
resolved_input=step_result["resolved_input"], resolved_input=step_result["resolved_input"],
outcome=outcome, outcome=outcome,
@@ -207,9 +235,10 @@ def step_workflow(
) )
) )
run.prior_outcome = outcome frame.prior_outcome = outcome
run.activated_incoming_edge = run.current_node_id frame.activated_incoming_edge = frame.node_id
run.current_node_id = next_node_id frame.node_id = next_node_id
run.sync_from_current_frame()
return run return run
@@ -239,10 +268,12 @@ def _execute_node_use(
node_def.input_schema, resolved_input, f"node input for {node.id}" node_def.input_schema, resolved_input, f"node input for {node.id}"
) )
frame = run.current_frame()
context = RuntimeContext( context = RuntimeContext(
current_node_id=node.id, current_node_id=node.id,
prior_outcome=run.prior_outcome, frame_id=frame.id,
activated_incoming_edge=run.activated_incoming_edge, prior_outcome=frame.prior_outcome,
activated_incoming_edge=frame.activated_incoming_edge,
) )
raw_result = handler(resolved_input, context) raw_result = handler(resolved_input, context)
result = coerce_node_result(raw_result) result = coerce_node_result(raw_result)
@@ -274,6 +305,8 @@ def coerce_node_result(raw_result: NodeResult | dict[str, Any]) -> NodeResult:
def _build_interrupt_request( def _build_interrupt_request(
node: InterruptNode, node: InterruptNode,
*,
frame_id: str,
state: dict[str, Any], state: dict[str, Any],
workflow_input: dict[str, Any], workflow_input: dict[str, Any],
) -> InterruptRequest: ) -> InterruptRequest:
@@ -288,6 +321,7 @@ def _build_interrupt_request(
} }
return InterruptRequest( return InterruptRequest(
id=f"interrupt:{node.id}", id=f"interrupt:{node.id}",
frame_id=frame_id,
node_id=node.id, node_id=node.id,
kind=node.kind, kind=node.kind,
payload=payload, payload=payload,
@@ -303,12 +337,15 @@ def _resume_interrupt(
resume_payload: dict[str, Any], resume_payload: dict[str, Any],
resume_outcome: str, resume_outcome: str,
) -> None: ) -> None:
if run.current_frame_id is None:
raise WorkflowExecutionError("interrupted run has no current frame")
if run.current_node_id is None: if run.current_node_id is None:
raise WorkflowExecutionError("interrupted run has no current node") raise WorkflowExecutionError("interrupted run has no current node")
if run.interrupt is None: if run.interrupt is None:
raise WorkflowExecutionError("run is interrupted but has no interrupt request") raise WorkflowExecutionError("run is interrupted but has no interrupt request")
step = nodes_by_id[run.current_node_id] frame = run.current_frame()
step = nodes_by_id[frame.node_id]
if not isinstance(step, InterruptNode): if not isinstance(step, InterruptNode):
raise WorkflowExecutionError( raise WorkflowExecutionError(
f"interrupted run expected interrupt node, got {step.type!r}" f"interrupted run expected interrupt node, got {step.type!r}"
@@ -325,15 +362,16 @@ def _resume_interrupt(
run.state, run.state,
missing_field_message="interrupt resume payload is missing required field {field}", missing_field_message="interrupt resume payload is missing required field {field}",
) )
next_node_id = edge_map.get((run.current_node_id, resume_outcome)) next_node_id = edge_map.get((frame.node_id, resume_outcome))
if next_node_id is None: if next_node_id is None:
raise WorkflowExecutionError( raise WorkflowExecutionError(
f"no edge found for interrupt node {run.current_node_id!r} and outcome {resume_outcome!r}" f"no edge found for interrupt node {frame.node_id!r} and outcome {resume_outcome!r}"
) )
run.trace.append( run.trace.append(
TraceEntry( TraceEntry(
node_id=run.current_node_id, frame_id=frame.id,
node_id=frame.node_id,
step_type=step.type, step_type=step.type,
resolved_input=resume_payload, resolved_input=resume_payload,
outcome=resume_outcome, outcome=resume_outcome,
@@ -342,7 +380,8 @@ def _resume_interrupt(
state_changes=state_changes, state_changes=state_changes,
) )
) )
run.prior_outcome = resume_outcome frame.prior_outcome = resume_outcome
run.activated_incoming_edge = run.current_node_id frame.activated_incoming_edge = frame.node_id
run.current_node_id = next_node_id frame.node_id = next_node_id
run.interrupt = None run.interrupt = None
run.sync_from_current_frame()