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.
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/LICENSE +1 -1
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/PKG-INFO +96 -4
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/README.md +92 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/__init__.py +19 -1
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/interfaces.py +8 -0
- agent_runtime_core-0.5.1/agent_runtime_core/steps.py +373 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/pyproject.toml +4 -4
- agent_runtime_core-0.5.1/tests/test_steps.py +365 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/.gitignore +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/config.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/events/__init__.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/events/base.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/events/memory.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/events/redis.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/events/sqlite.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/llm/__init__.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/llm/anthropic.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/llm/litellm_client.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/llm/openai.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/persistence/__init__.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/persistence/base.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/persistence/file.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/persistence/manager.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/queue/__init__.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/queue/base.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/queue/memory.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/queue/redis.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/queue/sqlite.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/registry.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/runner.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/state/__init__.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/state/base.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/state/memory.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/state/redis.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/state/sqlite.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/testing.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/tracing/__init__.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/tracing/langfuse.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/tracing/noop.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/tests/__init__.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/tests/test_events.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/tests/test_imports.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/tests/test_persistence.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/tests/test_queue.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/tests/test_state.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/tests/test_testing.py +0 -0
- {agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/uv.lock +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-runtime-core
|
|
3
|
-
Version: 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/
|
|
6
|
-
Project-URL: Repository, https://github.com/
|
|
7
|
-
Author: Chris
|
|
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.
|
|
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.
|
|
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
|
|
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/
|
|
52
|
-
Repository = "https://github.com/
|
|
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]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/llm/litellm_client.py
RENAMED
|
File without changes
|
|
File without changes
|
{agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/persistence/__init__.py
RENAMED
|
File without changes
|
{agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/persistence/base.py
RENAMED
|
File without changes
|
{agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/persistence/file.py
RENAMED
|
File without changes
|
{agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/persistence/manager.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/tracing/__init__.py
RENAMED
|
File without changes
|
{agent_runtime_core-0.4.0 → agent_runtime_core-0.5.1}/agent_runtime_core/tracing/langfuse.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|