agent-runtime-core 0.4.0__tar.gz → 0.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/LICENSE +1 -1
  2. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/PKG-INFO +96 -4
  3. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/README.md +92 -0
  4. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/__init__.py +19 -1
  5. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/interfaces.py +8 -0
  6. agent_runtime_core-0.5.1/agent_runtime_core/steps.py +373 -0
  7. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/pyproject.toml +4 -4
  8. agent_runtime_core-0.5.1/tests/test_steps.py +365 -0
  9. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/.gitignore +0 -0
  10. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/config.py +0 -0
  11. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/events/__init__.py +0 -0
  12. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/events/base.py +0 -0
  13. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/events/memory.py +0 -0
  14. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/events/redis.py +0 -0
  15. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/events/sqlite.py +0 -0
  16. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/llm/__init__.py +0 -0
  17. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/llm/anthropic.py +0 -0
  18. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/llm/litellm_client.py +0 -0
  19. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/llm/openai.py +0 -0
  20. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/persistence/__init__.py +0 -0
  21. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/persistence/base.py +0 -0
  22. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/persistence/file.py +0 -0
  23. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/persistence/manager.py +0 -0
  24. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/queue/__init__.py +0 -0
  25. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/queue/base.py +0 -0
  26. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/queue/memory.py +0 -0
  27. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/queue/redis.py +0 -0
  28. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/queue/sqlite.py +0 -0
  29. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/registry.py +0 -0
  30. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/runner.py +0 -0
  31. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/state/__init__.py +0 -0
  32. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/state/base.py +0 -0
  33. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/state/memory.py +0 -0
  34. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/state/redis.py +0 -0
  35. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/state/sqlite.py +0 -0
  36. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/testing.py +0 -0
  37. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/tracing/__init__.py +0 -0
  38. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/tracing/langfuse.py +0 -0
  39. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/tracing/noop.py +0 -0
  40. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/tests/__init__.py +0 -0
  41. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/tests/test_events.py +0 -0
  42. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/tests/test_imports.py +0 -0
  43. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/tests/test_persistence.py +0 -0
  44. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/tests/test_queue.py +0 -0
  45. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/tests/test_state.py +0 -0
  46. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/tests/test_testing.py +0 -0
  47. {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Chris Olstrom
3
+ Copyright (c) 2026 Chris Barry
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-runtime-core
3
- Version: 0.4.0
3
+ Version: 0.5.1
4
4
  Summary: Framework-agnostic Python library for executing AI agents with consistent patterns
5
- Project-URL: Homepage, https://github.com/colstrom/agent_runtime_core
6
- Project-URL: Repository, https://github.com/colstrom/agent_runtime_core
7
- Author: Chris Olstrom
5
+ Project-URL: Homepage, https://github.com/makemore/agent-runtime-core
6
+ Project-URL: Repository, https://github.com/makemore/agent-runtime-core
7
+ Author: Chris Barry
8
8
  License-Expression: MIT
9
9
  License-File: LICENSE
10
10
  Keywords: agents,ai,async,llm,runtime
@@ -720,6 +720,98 @@ result = await run_agent_test(MyAgent(), ctx)
720
720
  assert result.final_output["response"] == "Hi there!"
721
721
  ```
722
722
 
723
+ ## Step Executor
724
+
725
+ The `StepExecutor` provides a structured way to execute multi-step operations with automatic checkpointing, resume capability, retries, and progress reporting. Ideal for long-running agent tasks.
726
+
727
+ ### Basic Usage
728
+
729
+ ```python
730
+ from agent_runtime_core.steps import StepExecutor, Step
731
+
732
+ class MyAgent(AgentRuntime):
733
+ async def run(self, ctx: RunContext) -> RunResult:
734
+ executor = StepExecutor(ctx)
735
+
736
+ results = await executor.run([
737
+ Step("fetch", self.fetch_data),
738
+ Step("process", self.process_data, retries=3),
739
+ Step("validate", self.validate_results),
740
+ ])
741
+
742
+ return RunResult(final_output=results)
743
+
744
+ async def fetch_data(self, ctx, state):
745
+ # Fetch data from external API
746
+ return {"items": [...]}
747
+
748
+ async def process_data(self, ctx, state):
749
+ # Access results from previous steps via state
750
+ return {"processed": True}
751
+
752
+ async def validate_results(self, ctx, state):
753
+ return {"valid": True}
754
+ ```
755
+
756
+ ### Step Options
757
+
758
+ ```python
759
+ Step(
760
+ name="process", # Unique step identifier
761
+ fn=process_data, # Async function(ctx, state) -> result
762
+ retries=3, # Retry attempts on failure (default: 0)
763
+ retry_delay=2.0, # Seconds between retries (default: 1.0)
764
+ timeout=30.0, # Step timeout in seconds (optional)
765
+ description="Process data", # Human-readable description
766
+ checkpoint=True, # Save checkpoint after step (default: True)
767
+ )
768
+ ```
769
+
770
+ ### Resume from Checkpoint
771
+
772
+ Steps automatically checkpoint after completion. If execution is interrupted, it resumes from the last checkpoint:
773
+
774
+ ```python
775
+ # First run - completes step1, fails during step2
776
+ executor = StepExecutor(ctx)
777
+ await executor.run([step1, step2, step3]) # Checkpoints after step1
778
+
779
+ # Second run - skips step1, resumes from step2
780
+ executor = StepExecutor(ctx)
781
+ await executor.run([step1, step2, step3]) # step1 skipped
782
+ ```
783
+
784
+ ### Custom State
785
+
786
+ Pass state between steps using `initial_state` and the `state` dict:
787
+
788
+ ```python
789
+ async def step1(ctx, state):
790
+ state["counter"] = 1
791
+ return "done"
792
+
793
+ async def step2(ctx, state):
794
+ state["counter"] += 1 # Access state from step1
795
+ return state["counter"]
796
+
797
+ executor = StepExecutor(ctx)
798
+ results = await executor.run(
799
+ [Step("step1", step1), Step("step2", step2)],
800
+ initial_state={"counter": 0},
801
+ )
802
+ ```
803
+
804
+ ### Events
805
+
806
+ The executor emits events for observability:
807
+
808
+ - `EventType.STEP_STARTED` - Step execution began
809
+ - `EventType.STEP_COMPLETED` - Step completed successfully
810
+ - `EventType.STEP_FAILED` - Step failed after all retries
811
+ - `EventType.STEP_RETRYING` - Step is being retried
812
+ - `EventType.STEP_SKIPPED` - Step skipped (already completed)
813
+ - `EventType.PROGRESS_UPDATE` - Progress percentage update
814
+
723
815
  ## API Reference
724
816
 
725
817
  ### Configuration
@@ -677,6 +677,98 @@ result = await run_agent_test(MyAgent(), ctx)
677
677
  assert result.final_output["response"] == "Hi there!"
678
678
  ```
679
679
 
680
+ ## Step Executor
681
+
682
+ The `StepExecutor` provides a structured way to execute multi-step operations with automatic checkpointing, resume capability, retries, and progress reporting. Ideal for long-running agent tasks.
683
+
684
+ ### Basic Usage
685
+
686
+ ```python
687
+ from agent_runtime_core.steps import StepExecutor, Step
688
+
689
+ class MyAgent(AgentRuntime):
690
+ async def run(self, ctx: RunContext) -> RunResult:
691
+ executor = StepExecutor(ctx)
692
+
693
+ results = await executor.run([
694
+ Step("fetch", self.fetch_data),
695
+ Step("process", self.process_data, retries=3),
696
+ Step("validate", self.validate_results),
697
+ ])
698
+
699
+ return RunResult(final_output=results)
700
+
701
+ async def fetch_data(self, ctx, state):
702
+ # Fetch data from external API
703
+ return {"items": [...]}
704
+
705
+ async def process_data(self, ctx, state):
706
+ # Access results from previous steps via state
707
+ return {"processed": True}
708
+
709
+ async def validate_results(self, ctx, state):
710
+ return {"valid": True}
711
+ ```
712
+
713
+ ### Step Options
714
+
715
+ ```python
716
+ Step(
717
+ name="process", # Unique step identifier
718
+ fn=process_data, # Async function(ctx, state) -> result
719
+ retries=3, # Retry attempts on failure (default: 0)
720
+ retry_delay=2.0, # Seconds between retries (default: 1.0)
721
+ timeout=30.0, # Step timeout in seconds (optional)
722
+ description="Process data", # Human-readable description
723
+ checkpoint=True, # Save checkpoint after step (default: True)
724
+ )
725
+ ```
726
+
727
+ ### Resume from Checkpoint
728
+
729
+ Steps automatically checkpoint after completion. If execution is interrupted, it resumes from the last checkpoint:
730
+
731
+ ```python
732
+ # First run - completes step1, fails during step2
733
+ executor = StepExecutor(ctx)
734
+ await executor.run([step1, step2, step3]) # Checkpoints after step1
735
+
736
+ # Second run - skips step1, resumes from step2
737
+ executor = StepExecutor(ctx)
738
+ await executor.run([step1, step2, step3]) # step1 skipped
739
+ ```
740
+
741
+ ### Custom State
742
+
743
+ Pass state between steps using `initial_state` and the `state` dict:
744
+
745
+ ```python
746
+ async def step1(ctx, state):
747
+ state["counter"] = 1
748
+ return "done"
749
+
750
+ async def step2(ctx, state):
751
+ state["counter"] += 1 # Access state from step1
752
+ return state["counter"]
753
+
754
+ executor = StepExecutor(ctx)
755
+ results = await executor.run(
756
+ [Step("step1", step1), Step("step2", step2)],
757
+ initial_state={"counter": 0},
758
+ )
759
+ ```
760
+
761
+ ### Events
762
+
763
+ The executor emits events for observability:
764
+
765
+ - `EventType.STEP_STARTED` - Step execution began
766
+ - `EventType.STEP_COMPLETED` - Step completed successfully
767
+ - `EventType.STEP_FAILED` - Step failed after all retries
768
+ - `EventType.STEP_RETRYING` - Step is being retried
769
+ - `EventType.STEP_SKIPPED` - Step skipped (already completed)
770
+ - `EventType.PROGRESS_UPDATE` - Progress percentage update
771
+
680
772
  ## API Reference
681
773
 
682
774
  ### Configuration
@@ -34,7 +34,7 @@ Example usage:
34
34
  return RunResult(final_output={"message": "Hello!"})
35
35
  """
36
36
 
37
- __version__ = "0.3.0"
37
+ __version__ = "0.5.1"
38
38
 
39
39
  # Core interfaces
40
40
  from agent_runtime_core.interfaces import (
@@ -76,6 +76,16 @@ from agent_runtime_core.runner import (
76
76
  RunContextImpl,
77
77
  )
78
78
 
79
+ # Step execution for long-running multi-step agents
80
+ from agent_runtime_core.steps import (
81
+ Step,
82
+ StepExecutor,
83
+ StepResult,
84
+ StepStatus,
85
+ ExecutionState,
86
+ StepExecutionError,
87
+ StepCancelledError,
88
+ )
79
89
 
80
90
  # Testing utilities
81
91
  from agent_runtime_core.testing import (
@@ -146,6 +156,14 @@ __all__ = [
146
156
  "AgentRunner",
147
157
  "RunnerConfig",
148
158
  "RunContextImpl",
159
+ # Step execution
160
+ "Step",
161
+ "StepExecutor",
162
+ "StepResult",
163
+ "StepStatus",
164
+ "ExecutionState",
165
+ "StepExecutionError",
166
+ "StepCancelledError",
149
167
  # Testing
150
168
  "MockRunContext",
151
169
  "MockLLMClient",
@@ -41,6 +41,14 @@ class EventType(str, Enum):
41
41
  # State events
42
42
  STATE_CHECKPOINT = "state.checkpoint"
43
43
 
44
+ # Step execution events (for long-running multi-step agents)
45
+ STEP_STARTED = "step.started"
46
+ STEP_COMPLETED = "step.completed"
47
+ STEP_FAILED = "step.failed"
48
+ STEP_SKIPPED = "step.skipped" # When resuming from checkpoint
49
+ STEP_RETRYING = "step.retrying"
50
+ PROGRESS_UPDATE = "progress.update" # General progress reporting
51
+
44
52
 
45
53
  class Message(TypedDict, total=False):
46
54
  """
@@ -0,0 +1,373 @@
1
+ """
2
+ Step executor for long-running multi-step agent operations.
3
+
4
+ This module provides a structured way to execute multi-step operations
5
+ with automatic checkpointing, resume capability, retries, and progress
6
+ reporting.
7
+
8
+ Example usage:
9
+ from agent_runtime_core.steps import StepExecutor, Step
10
+
11
+ class MyAgent(AgentRuntime):
12
+ async def run(self, ctx: RunContext) -> RunResult:
13
+ executor = StepExecutor(ctx)
14
+
15
+ result = await executor.run([
16
+ Step("fetch", self.fetch_data),
17
+ Step("process", self.process_data, retries=3),
18
+ Step("validate", self.validate),
19
+ ])
20
+
21
+ return RunResult(final_output=result)
22
+ """
23
+
24
+ import asyncio
25
+ import traceback
26
+ from dataclasses import dataclass, field
27
+ from datetime import datetime
28
+ from enum import Enum
29
+ from typing import Any, Awaitable, Callable, Optional, TypeVar, Union
30
+ from uuid import UUID, uuid4
31
+
32
+ from agent_runtime_core.interfaces import EventType, RunContext
33
+
34
+
35
+ class StepStatus(str, Enum):
36
+ """Status of a step execution."""
37
+
38
+ PENDING = "pending"
39
+ RUNNING = "running"
40
+ COMPLETED = "completed"
41
+ FAILED = "failed"
42
+ SKIPPED = "skipped"
43
+ CANCELLED = "cancelled"
44
+
45
+
46
+ # Type for step functions: async def step_fn(ctx, state) -> result
47
+ StepFunction = Callable[[RunContext, dict], Awaitable[Any]]
48
+
49
+
50
+ @dataclass
51
+ class Step:
52
+ """
53
+ Definition of a single step in a multi-step operation.
54
+
55
+ Args:
56
+ name: Unique identifier for this step
57
+ fn: Async function to execute. Receives (ctx, state) and returns result.
58
+ retries: Number of retry attempts on failure (default: 0)
59
+ retry_delay: Seconds to wait between retries (default: 1.0)
60
+ timeout: Optional timeout in seconds for this step
61
+ description: Human-readable description for progress reporting
62
+ checkpoint: Whether to checkpoint after this step (default: True)
63
+ """
64
+
65
+ name: str
66
+ fn: StepFunction
67
+ retries: int = 0
68
+ retry_delay: float = 1.0
69
+ timeout: Optional[float] = None
70
+ description: Optional[str] = None
71
+ checkpoint: bool = True
72
+
73
+
74
+ @dataclass
75
+ class StepResult:
76
+ """Result of executing a single step."""
77
+
78
+ name: str
79
+ status: StepStatus
80
+ result: Any = None
81
+ error: Optional[str] = None
82
+ attempts: int = 1
83
+ started_at: Optional[datetime] = None
84
+ completed_at: Optional[datetime] = None
85
+ duration_ms: Optional[float] = None
86
+
87
+
88
+ @dataclass
89
+ class ExecutionState:
90
+ """
91
+ State of a multi-step execution.
92
+
93
+ This is what gets checkpointed and can be used to resume.
94
+ """
95
+
96
+ execution_id: UUID = field(default_factory=uuid4)
97
+ current_step_index: int = 0
98
+ completed_steps: list[str] = field(default_factory=list)
99
+ step_results: dict[str, Any] = field(default_factory=dict)
100
+ started_at: datetime = field(default_factory=datetime.utcnow)
101
+ custom_state: dict = field(default_factory=dict)
102
+
103
+ def to_dict(self) -> dict:
104
+ """Convert to dictionary for checkpointing."""
105
+ return {
106
+ "execution_id": str(self.execution_id),
107
+ "current_step_index": self.current_step_index,
108
+ "completed_steps": self.completed_steps,
109
+ "step_results": self.step_results,
110
+ "started_at": self.started_at.isoformat(),
111
+ "custom_state": self.custom_state,
112
+ }
113
+
114
+ @classmethod
115
+ def from_dict(cls, data: dict) -> "ExecutionState":
116
+ """Restore from checkpointed dictionary."""
117
+ return cls(
118
+ execution_id=UUID(data["execution_id"]),
119
+ current_step_index=data["current_step_index"],
120
+ completed_steps=data["completed_steps"],
121
+ step_results=data["step_results"],
122
+ started_at=datetime.fromisoformat(data["started_at"]),
123
+ custom_state=data.get("custom_state", {}),
124
+ )
125
+
126
+
127
+ class StepExecutionError(Exception):
128
+ """Raised when step execution fails after all retries."""
129
+
130
+ def __init__(self, step_name: str, message: str, attempts: int):
131
+ self.step_name = step_name
132
+ self.attempts = attempts
133
+ super().__init__(f"Step '{step_name}' failed after {attempts} attempts: {message}")
134
+
135
+
136
+ class StepCancelledError(Exception):
137
+ """Raised when execution is cancelled."""
138
+
139
+ def __init__(self, step_name: str):
140
+ self.step_name = step_name
141
+ super().__init__(f"Execution cancelled during step '{step_name}'")
142
+
143
+
144
+ class StepExecutor:
145
+ """
146
+ Executes a sequence of steps with checkpointing and resume capability.
147
+
148
+ Features:
149
+ - Automatic checkpointing after each step
150
+ - Resume from last checkpoint on restart
151
+ - Per-step retries with configurable delay
152
+ - Progress reporting via events
153
+ - Cancellation support
154
+ - Step-level timeouts
155
+
156
+ Example:
157
+ executor = StepExecutor(ctx)
158
+
159
+ result = await executor.run([
160
+ Step("fetch", fetch_data),
161
+ Step("process", process_data, retries=3),
162
+ Step("save", save_results),
163
+ ])
164
+ """
165
+
166
+ def __init__(
167
+ self,
168
+ ctx: RunContext,
169
+ *,
170
+ checkpoint_key: str = "_step_executor_state",
171
+ cancel_check_interval: float = 0.5,
172
+ ):
173
+ """
174
+ Initialize the step executor.
175
+
176
+ Args:
177
+ ctx: The run context from the agent runtime
178
+ checkpoint_key: Key used for storing execution state
179
+ cancel_check_interval: How often to check for cancellation (seconds)
180
+ """
181
+ self.ctx = ctx
182
+ self.checkpoint_key = checkpoint_key
183
+ self.cancel_check_interval = cancel_check_interval
184
+ self._state: Optional[ExecutionState] = None
185
+
186
+ async def run(
187
+ self,
188
+ steps: list[Step],
189
+ *,
190
+ initial_state: Optional[dict] = None,
191
+ resume: bool = True,
192
+ ) -> dict[str, Any]:
193
+ """
194
+ Execute a sequence of steps.
195
+
196
+ Args:
197
+ steps: List of steps to execute
198
+ initial_state: Optional initial custom state
199
+ resume: Whether to resume from checkpoint if available
200
+
201
+ Returns:
202
+ Dictionary mapping step names to their results
203
+
204
+ Raises:
205
+ StepExecutionError: If a step fails after all retries
206
+ StepCancelledError: If execution is cancelled
207
+ """
208
+ # Try to resume from checkpoint
209
+ if resume:
210
+ self._state = await self._load_state()
211
+
212
+ if self._state is None:
213
+ self._state = ExecutionState(
214
+ custom_state=initial_state or {}
215
+ )
216
+
217
+ total_steps = len(steps)
218
+
219
+ for i, step in enumerate(steps):
220
+ # Skip already completed steps
221
+ if step.name in self._state.completed_steps:
222
+ await self.ctx.emit(EventType.STEP_SKIPPED, {
223
+ "step_name": step.name,
224
+ "step_index": i,
225
+ "total_steps": total_steps,
226
+ "reason": "already_completed",
227
+ })
228
+ continue
229
+
230
+ # Check for cancellation
231
+ if self.ctx.cancelled():
232
+ raise StepCancelledError(step.name)
233
+
234
+ # Update state
235
+ self._state.current_step_index = i
236
+
237
+ # Execute the step
238
+ result = await self._execute_step(step, i, total_steps)
239
+
240
+ # Record completion
241
+ self._state.completed_steps.append(step.name)
242
+ self._state.step_results[step.name] = result.result
243
+
244
+ # Checkpoint if enabled
245
+ if step.checkpoint:
246
+ await self._save_state()
247
+
248
+ return self._state.step_results
249
+
250
+ async def _execute_step(
251
+ self,
252
+ step: Step,
253
+ index: int,
254
+ total: int,
255
+ ) -> StepResult:
256
+ """Execute a single step with retries."""
257
+ attempts = 0
258
+ last_error: Optional[str] = None
259
+
260
+ while attempts <= step.retries:
261
+ attempts += 1
262
+
263
+ # Emit started event
264
+ await self.ctx.emit(EventType.STEP_STARTED, {
265
+ "step_name": step.name,
266
+ "step_index": index,
267
+ "total_steps": total,
268
+ "attempt": attempts,
269
+ "max_attempts": step.retries + 1,
270
+ "description": step.description,
271
+ })
272
+
273
+ # Emit progress
274
+ await self.ctx.emit(EventType.PROGRESS_UPDATE, {
275
+ "step_name": step.name,
276
+ "step_index": index,
277
+ "total_steps": total,
278
+ "progress_percent": (index / total) * 100,
279
+ "description": step.description or f"Executing {step.name}",
280
+ })
281
+
282
+ started_at = datetime.utcnow()
283
+
284
+ try:
285
+ # Execute with optional timeout
286
+ if step.timeout:
287
+ result = await asyncio.wait_for(
288
+ step.fn(self.ctx, self._state.custom_state),
289
+ timeout=step.timeout,
290
+ )
291
+ else:
292
+ result = await step.fn(self.ctx, self._state.custom_state)
293
+
294
+ completed_at = datetime.utcnow()
295
+ duration_ms = (completed_at - started_at).total_seconds() * 1000
296
+
297
+ # Emit completed event
298
+ await self.ctx.emit(EventType.STEP_COMPLETED, {
299
+ "step_name": step.name,
300
+ "step_index": index,
301
+ "total_steps": total,
302
+ "attempt": attempts,
303
+ "duration_ms": duration_ms,
304
+ })
305
+
306
+ return StepResult(
307
+ name=step.name,
308
+ status=StepStatus.COMPLETED,
309
+ result=result,
310
+ attempts=attempts,
311
+ started_at=started_at,
312
+ completed_at=completed_at,
313
+ duration_ms=duration_ms,
314
+ )
315
+
316
+ except asyncio.CancelledError:
317
+ raise StepCancelledError(step.name)
318
+
319
+ except asyncio.TimeoutError:
320
+ last_error = f"Step timed out after {step.timeout}s"
321
+
322
+ except Exception as e:
323
+ last_error = f"{type(e).__name__}: {str(e)}"
324
+
325
+ # Check if we should retry
326
+ if attempts <= step.retries:
327
+ await self.ctx.emit(EventType.STEP_RETRYING, {
328
+ "step_name": step.name,
329
+ "step_index": index,
330
+ "attempt": attempts,
331
+ "max_attempts": step.retries + 1,
332
+ "error": last_error,
333
+ "retry_delay": step.retry_delay,
334
+ })
335
+ await asyncio.sleep(step.retry_delay)
336
+
337
+ # All retries exhausted
338
+ await self.ctx.emit(EventType.STEP_FAILED, {
339
+ "step_name": step.name,
340
+ "step_index": index,
341
+ "total_steps": total,
342
+ "attempts": attempts,
343
+ "error": last_error,
344
+ })
345
+
346
+ raise StepExecutionError(step.name, last_error or "Unknown error", attempts)
347
+
348
+ async def _load_state(self) -> Optional[ExecutionState]:
349
+ """Load execution state from checkpoint."""
350
+ checkpoint = await self.ctx.get_state()
351
+ if checkpoint and self.checkpoint_key in checkpoint:
352
+ try:
353
+ return ExecutionState.from_dict(checkpoint[self.checkpoint_key])
354
+ except (KeyError, ValueError):
355
+ return None
356
+ return None
357
+
358
+ async def _save_state(self) -> None:
359
+ """Save execution state to checkpoint."""
360
+ checkpoint = await self.ctx.get_state() or {}
361
+ checkpoint[self.checkpoint_key] = self._state.to_dict()
362
+ await self.ctx.checkpoint(checkpoint)
363
+
364
+ @property
365
+ def state(self) -> Optional[ExecutionState]:
366
+ """Get the current execution state."""
367
+ return self._state
368
+
369
+ def update_custom_state(self, updates: dict) -> None:
370
+ """Update custom state (will be checkpointed with next step)."""
371
+ if self._state:
372
+ self._state.custom_state.update(updates)
373
+
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agent-runtime-core"
7
- version = "0.4.0"
7
+ version = "0.5.1"
8
8
  description = "Framework-agnostic Python library for executing AI agents with consistent patterns"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.11"
12
12
  authors = [
13
- { name = "Chris Olstrom" }
13
+ { name = "Chris Barry" }
14
14
  ]
15
15
  classifiers = [
16
16
  "Development Status :: 3 - Alpha",
@@ -48,8 +48,8 @@ dev = [
48
48
  ]
49
49
 
50
50
  [project.urls]
51
- Homepage = "https://github.com/colstrom/agent_runtime_core"
52
- Repository = "https://github.com/colstrom/agent_runtime_core"
51
+ Homepage = "https://github.com/makemore/agent-runtime-core"
52
+ Repository = "https://github.com/makemore/agent-runtime-core"
53
53
 
54
54
  [tool.hatch.build.targets.wheel]
55
55
  packages = ["agent_runtime_core"]
@@ -0,0 +1,365 @@
1
+ """Tests for step execution module."""
2
+
3
+ import pytest
4
+ import asyncio
5
+ from datetime import datetime
6
+ from uuid import uuid4
7
+
8
+ from agent_runtime_core.steps import (
9
+ Step,
10
+ StepExecutor,
11
+ StepResult,
12
+ StepStatus,
13
+ ExecutionState,
14
+ StepExecutionError,
15
+ StepCancelledError,
16
+ )
17
+ from agent_runtime_core.interfaces import EventType
18
+ from agent_runtime_core.testing import MockRunContext
19
+
20
+
21
+ class TestStep:
22
+ """Tests for Step dataclass."""
23
+
24
+ def test_step_defaults(self):
25
+ """Test Step with default values."""
26
+ async def dummy(ctx, state):
27
+ return "result"
28
+
29
+ step = Step("test", dummy)
30
+
31
+ assert step.name == "test"
32
+ assert step.fn == dummy
33
+ assert step.retries == 0
34
+ assert step.retry_delay == 1.0
35
+ assert step.timeout is None
36
+ assert step.description is None
37
+ assert step.checkpoint is True
38
+
39
+ def test_step_with_options(self):
40
+ """Test Step with custom options."""
41
+ async def dummy(ctx, state):
42
+ return "result"
43
+
44
+ step = Step(
45
+ name="fetch",
46
+ fn=dummy,
47
+ retries=3,
48
+ retry_delay=2.0,
49
+ timeout=30.0,
50
+ description="Fetch data from API",
51
+ checkpoint=False,
52
+ )
53
+
54
+ assert step.retries == 3
55
+ assert step.retry_delay == 2.0
56
+ assert step.timeout == 30.0
57
+ assert step.description == "Fetch data from API"
58
+ assert step.checkpoint is False
59
+
60
+
61
+ class TestExecutionState:
62
+ """Tests for ExecutionState."""
63
+
64
+ def test_state_defaults(self):
65
+ """Test ExecutionState with defaults."""
66
+ state = ExecutionState()
67
+
68
+ assert state.current_step_index == 0
69
+ assert state.completed_steps == []
70
+ assert state.step_results == {}
71
+ assert state.custom_state == {}
72
+
73
+ def test_state_serialization(self):
74
+ """Test state to_dict and from_dict."""
75
+ state = ExecutionState(
76
+ current_step_index=2,
77
+ completed_steps=["step1", "step2"],
78
+ step_results={"step1": "result1", "step2": "result2"},
79
+ custom_state={"key": "value"},
80
+ )
81
+
82
+ data = state.to_dict()
83
+ restored = ExecutionState.from_dict(data)
84
+
85
+ assert restored.current_step_index == 2
86
+ assert restored.completed_steps == ["step1", "step2"]
87
+ assert restored.step_results == {"step1": "result1", "step2": "result2"}
88
+ assert restored.custom_state == {"key": "value"}
89
+
90
+
91
+ class TestStepExecutor:
92
+ """Tests for StepExecutor."""
93
+
94
+ @pytest.mark.asyncio
95
+ async def test_execute_single_step(self):
96
+ """Test executing a single step."""
97
+ ctx = MockRunContext()
98
+
99
+ async def my_step(ctx, state):
100
+ return {"data": "hello"}
101
+
102
+ executor = StepExecutor(ctx)
103
+ results = await executor.run([Step("my_step", my_step)])
104
+
105
+ assert results == {"my_step": {"data": "hello"}}
106
+
107
+ @pytest.mark.asyncio
108
+ async def test_execute_multiple_steps(self):
109
+ """Test executing multiple steps in sequence."""
110
+ ctx = MockRunContext()
111
+ call_order = []
112
+
113
+ async def step1(ctx, state):
114
+ call_order.append("step1")
115
+ return "result1"
116
+
117
+ async def step2(ctx, state):
118
+ call_order.append("step2")
119
+ return "result2"
120
+
121
+ async def step3(ctx, state):
122
+ call_order.append("step3")
123
+ return "result3"
124
+
125
+ executor = StepExecutor(ctx)
126
+ results = await executor.run([
127
+ Step("step1", step1),
128
+ Step("step2", step2),
129
+ Step("step3", step3),
130
+ ])
131
+
132
+ assert call_order == ["step1", "step2", "step3"]
133
+ assert results == {
134
+ "step1": "result1",
135
+ "step2": "result2",
136
+ "step3": "result3",
137
+ }
138
+
139
+ @pytest.mark.asyncio
140
+ async def test_step_receives_custom_state(self):
141
+ """Test that steps receive and can modify custom state."""
142
+ ctx = MockRunContext()
143
+
144
+ async def step1(ctx, state):
145
+ state["counter"] = 1
146
+ return "done"
147
+
148
+ async def step2(ctx, state):
149
+ state["counter"] += 1
150
+ return state["counter"]
151
+
152
+ executor = StepExecutor(ctx)
153
+ results = await executor.run(
154
+ [Step("step1", step1), Step("step2", step2)],
155
+ initial_state={"counter": 0},
156
+ )
157
+
158
+ assert results["step2"] == 2
159
+
160
+ @pytest.mark.asyncio
161
+ async def test_step_emits_events(self):
162
+ """Test that step execution emits proper events."""
163
+ ctx = MockRunContext()
164
+
165
+ async def my_step(ctx, state):
166
+ return "done"
167
+
168
+ executor = StepExecutor(ctx)
169
+ await executor.run([Step("my_step", my_step, description="Test step")])
170
+
171
+ events = ctx.get_events()
172
+ event_types = [event_type for event_type, _ in events]
173
+
174
+ assert EventType.STEP_STARTED.value in event_types
175
+ assert EventType.STEP_COMPLETED.value in event_types
176
+ assert EventType.PROGRESS_UPDATE.value in event_types
177
+
178
+ @pytest.mark.asyncio
179
+ async def test_step_retry_on_failure(self):
180
+ """Test that steps retry on failure."""
181
+ ctx = MockRunContext()
182
+ attempts = []
183
+
184
+ async def flaky_step(ctx, state):
185
+ attempts.append(1)
186
+ if len(attempts) < 3:
187
+ raise ValueError("Temporary failure")
188
+ return "success"
189
+
190
+ executor = StepExecutor(ctx)
191
+ results = await executor.run([
192
+ Step("flaky", flaky_step, retries=3, retry_delay=0.01)
193
+ ])
194
+
195
+ assert len(attempts) == 3
196
+ assert results["flaky"] == "success"
197
+
198
+ # Check retry events
199
+ events = ctx.get_events()
200
+ retry_events = [
201
+ (event_type, payload)
202
+ for event_type, payload in events
203
+ if event_type == EventType.STEP_RETRYING.value
204
+ ]
205
+ assert len(retry_events) == 2 # 2 retries before success
206
+
207
+ @pytest.mark.asyncio
208
+ async def test_step_fails_after_max_retries(self):
209
+ """Test that step fails after exhausting retries."""
210
+ ctx = MockRunContext()
211
+
212
+ async def always_fails(ctx, state):
213
+ raise ValueError("Always fails")
214
+
215
+ executor = StepExecutor(ctx)
216
+
217
+ with pytest.raises(StepExecutionError) as exc_info:
218
+ await executor.run([
219
+ Step("failing", always_fails, retries=2, retry_delay=0.01)
220
+ ])
221
+
222
+ assert exc_info.value.step_name == "failing"
223
+ assert exc_info.value.attempts == 3 # 1 initial + 2 retries
224
+
225
+ @pytest.mark.asyncio
226
+ async def test_step_timeout(self):
227
+ """Test step timeout handling."""
228
+ ctx = MockRunContext()
229
+
230
+ async def slow_step(ctx, state):
231
+ await asyncio.sleep(10)
232
+ return "done"
233
+
234
+ executor = StepExecutor(ctx)
235
+
236
+ with pytest.raises(StepExecutionError) as exc_info:
237
+ await executor.run([
238
+ Step("slow", slow_step, timeout=0.1)
239
+ ])
240
+
241
+ assert "timed out" in str(exc_info.value)
242
+
243
+ @pytest.mark.asyncio
244
+ async def test_cancellation(self):
245
+ """Test cancellation during execution."""
246
+ ctx = MockRunContext()
247
+
248
+ async def step1(ctx, state):
249
+ ctx.cancel() # Cancel during first step
250
+ return "done"
251
+
252
+ async def step2(ctx, state):
253
+ return "should not run"
254
+
255
+ executor = StepExecutor(ctx)
256
+
257
+ with pytest.raises(StepCancelledError):
258
+ await executor.run([
259
+ Step("step1", step1),
260
+ Step("step2", step2),
261
+ ])
262
+
263
+ @pytest.mark.asyncio
264
+ async def test_checkpoint_and_resume(self):
265
+ """Test checkpointing and resuming execution."""
266
+ ctx = MockRunContext()
267
+ call_order = []
268
+
269
+ async def step1(ctx, state):
270
+ call_order.append("step1")
271
+ return "result1"
272
+
273
+ async def step2(ctx, state):
274
+ call_order.append("step2")
275
+ return "result2"
276
+
277
+ # First run - complete step1
278
+ executor1 = StepExecutor(ctx)
279
+ await executor1.run([Step("step1", step1)])
280
+
281
+ # Second run - should skip step1, run step2
282
+ executor2 = StepExecutor(ctx)
283
+ results = await executor2.run([
284
+ Step("step1", step1),
285
+ Step("step2", step2),
286
+ ])
287
+
288
+ # step1 should only be called once (first run)
289
+ # step2 should be called in second run
290
+ assert call_order == ["step1", "step2"]
291
+ assert results["step2"] == "result2"
292
+
293
+ # Check skip event
294
+ events = ctx.get_events()
295
+ skip_events = [
296
+ (event_type, payload)
297
+ for event_type, payload in events
298
+ if event_type == EventType.STEP_SKIPPED.value
299
+ ]
300
+ assert len(skip_events) == 1
301
+ _, payload = skip_events[0]
302
+ assert payload["step_name"] == "step1"
303
+
304
+ @pytest.mark.asyncio
305
+ async def test_resume_disabled(self):
306
+ """Test that resume can be disabled."""
307
+ ctx = MockRunContext()
308
+ call_count = 0
309
+
310
+ async def my_step(ctx, state):
311
+ nonlocal call_count
312
+ call_count += 1
313
+ return "done"
314
+
315
+ # First run
316
+ executor1 = StepExecutor(ctx)
317
+ await executor1.run([Step("my_step", my_step)])
318
+
319
+ # Second run with resume=False
320
+ executor2 = StepExecutor(ctx)
321
+ await executor2.run([Step("my_step", my_step)], resume=False)
322
+
323
+ # Step should be called twice
324
+ assert call_count == 2
325
+
326
+ @pytest.mark.asyncio
327
+ async def test_update_custom_state(self):
328
+ """Test updating custom state via executor."""
329
+ ctx = MockRunContext()
330
+
331
+ async def my_step(ctx, state):
332
+ return state.get("value", 0)
333
+
334
+ executor = StepExecutor(ctx)
335
+ executor._state = ExecutionState(custom_state={"value": 42})
336
+ executor.update_custom_state({"value": 100})
337
+
338
+ assert executor.state.custom_state["value"] == 100
339
+
340
+ @pytest.mark.asyncio
341
+ async def test_progress_percentage(self):
342
+ """Test progress percentage calculation."""
343
+ ctx = MockRunContext()
344
+
345
+ async def dummy(ctx, state):
346
+ return "done"
347
+
348
+ executor = StepExecutor(ctx)
349
+ await executor.run([
350
+ Step("step1", dummy),
351
+ Step("step2", dummy),
352
+ Step("step3", dummy),
353
+ Step("step4", dummy),
354
+ ])
355
+
356
+ events = ctx.get_events()
357
+ progress_events = [
358
+ (event_type, payload)
359
+ for event_type, payload in events
360
+ if event_type == EventType.PROGRESS_UPDATE.value
361
+ ]
362
+
363
+ # Check progress percentages
364
+ percentages = [payload["progress_percent"] for _, payload in progress_events]
365
+ assert percentages == [0.0, 25.0, 50.0, 75.0]