agent-runtime-core 0.6.0__py3-none-any.whl → 0.7.1__py3-none-any.whl
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/__init__.py +118 -2
- agent_runtime_core/agentic_loop.py +254 -0
- agent_runtime_core/config.py +54 -4
- agent_runtime_core/config_schema.py +307 -0
- agent_runtime_core/contexts.py +348 -0
- agent_runtime_core/interfaces.py +106 -0
- agent_runtime_core/json_runtime.py +509 -0
- agent_runtime_core/llm/__init__.py +80 -7
- agent_runtime_core/llm/anthropic.py +133 -12
- agent_runtime_core/llm/models_config.py +180 -0
- agent_runtime_core/memory/__init__.py +70 -0
- agent_runtime_core/memory/manager.py +554 -0
- agent_runtime_core/memory/mixin.py +294 -0
- agent_runtime_core/multi_agent.py +569 -0
- agent_runtime_core/persistence/__init__.py +2 -0
- agent_runtime_core/persistence/file.py +277 -0
- agent_runtime_core/rag/__init__.py +65 -0
- agent_runtime_core/rag/chunking.py +224 -0
- agent_runtime_core/rag/indexer.py +253 -0
- agent_runtime_core/rag/retriever.py +261 -0
- agent_runtime_core/runner.py +193 -15
- agent_runtime_core/tool_calling_agent.py +88 -130
- agent_runtime_core/tools.py +179 -0
- agent_runtime_core/vectorstore/__init__.py +193 -0
- agent_runtime_core/vectorstore/base.py +138 -0
- agent_runtime_core/vectorstore/embeddings.py +242 -0
- agent_runtime_core/vectorstore/sqlite_vec.py +328 -0
- agent_runtime_core/vectorstore/vertex.py +295 -0
- {agent_runtime_core-0.6.0.dist-info → agent_runtime_core-0.7.1.dist-info}/METADATA +202 -1
- agent_runtime_core-0.7.1.dist-info/RECORD +57 -0
- agent_runtime_core-0.6.0.dist-info/RECORD +0 -38
- {agent_runtime_core-0.6.0.dist-info → agent_runtime_core-0.7.1.dist-info}/WHEEL +0 -0
- {agent_runtime_core-0.6.0.dist-info → agent_runtime_core-0.7.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentConfig - Canonical JSON schema for portable agent definitions.
|
|
3
|
+
|
|
4
|
+
This schema defines the format for agent configurations that can be:
|
|
5
|
+
1. Stored in Django as JSON revisions
|
|
6
|
+
2. Loaded from standalone .json files
|
|
7
|
+
3. Used by agent_runtime_core without Django dependency
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
# Load from file
|
|
11
|
+
config = AgentConfig.from_file("my_agent.json")
|
|
12
|
+
|
|
13
|
+
# Create runtime and run
|
|
14
|
+
runtime = JsonAgentRuntime(config, llm_client)
|
|
15
|
+
result = await runtime.run(ctx)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
import json
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class SubAgentToolConfig:
|
|
27
|
+
"""
|
|
28
|
+
Configuration for a sub-agent tool (agent-as-tool pattern).
|
|
29
|
+
|
|
30
|
+
This allows an agent to delegate to another agent as if it were a tool.
|
|
31
|
+
The sub-agent can either be referenced by slug (resolved at runtime)
|
|
32
|
+
or embedded inline (for portable standalone configs).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
name: str # Tool name the parent uses to invoke this agent
|
|
36
|
+
description: str # When to use this agent (shown to parent LLM)
|
|
37
|
+
|
|
38
|
+
# Reference to sub-agent (one of these should be set)
|
|
39
|
+
agent_slug: str = "" # Reference by slug (resolved at runtime from registry)
|
|
40
|
+
agent_config: Optional["AgentConfig"] = None # Embedded config (for standalone)
|
|
41
|
+
|
|
42
|
+
# Invocation settings
|
|
43
|
+
invocation_mode: str = "delegate" # "delegate" or "handoff"
|
|
44
|
+
context_mode: str = "full" # "full", "summary", or "message_only"
|
|
45
|
+
max_turns: Optional[int] = None
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict:
|
|
48
|
+
result = {
|
|
49
|
+
"name": self.name,
|
|
50
|
+
"description": self.description,
|
|
51
|
+
"tool_type": "subagent",
|
|
52
|
+
"invocation_mode": self.invocation_mode,
|
|
53
|
+
"context_mode": self.context_mode,
|
|
54
|
+
}
|
|
55
|
+
if self.agent_slug:
|
|
56
|
+
result["agent_slug"] = self.agent_slug
|
|
57
|
+
if self.agent_config:
|
|
58
|
+
result["agent_config"] = self.agent_config.to_dict()
|
|
59
|
+
if self.max_turns is not None:
|
|
60
|
+
result["max_turns"] = self.max_turns
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_dict(cls, data: dict) -> "SubAgentToolConfig":
|
|
65
|
+
agent_config = None
|
|
66
|
+
if "agent_config" in data and data["agent_config"]:
|
|
67
|
+
# Defer import to avoid circular dependency
|
|
68
|
+
agent_config = AgentConfig.from_dict(data["agent_config"])
|
|
69
|
+
|
|
70
|
+
return cls(
|
|
71
|
+
name=data["name"],
|
|
72
|
+
description=data["description"],
|
|
73
|
+
agent_slug=data.get("agent_slug", ""),
|
|
74
|
+
agent_config=agent_config,
|
|
75
|
+
invocation_mode=data.get("invocation_mode", "delegate"),
|
|
76
|
+
context_mode=data.get("context_mode", "full"),
|
|
77
|
+
max_turns=data.get("max_turns"),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class ToolConfig:
|
|
83
|
+
"""Configuration for a single tool."""
|
|
84
|
+
|
|
85
|
+
name: str
|
|
86
|
+
description: str
|
|
87
|
+
parameters: dict # JSON Schema for parameters
|
|
88
|
+
function_path: str # Import path like "myapp.services.orders.lookup_order"
|
|
89
|
+
|
|
90
|
+
# Optional metadata
|
|
91
|
+
requires_confirmation: bool = False
|
|
92
|
+
is_safe: bool = True # No side effects
|
|
93
|
+
timeout_seconds: int = 30
|
|
94
|
+
|
|
95
|
+
def to_dict(self) -> dict:
|
|
96
|
+
return {
|
|
97
|
+
"name": self.name,
|
|
98
|
+
"description": self.description,
|
|
99
|
+
"parameters": self.parameters,
|
|
100
|
+
"function_path": self.function_path,
|
|
101
|
+
"requires_confirmation": self.requires_confirmation,
|
|
102
|
+
"is_safe": self.is_safe,
|
|
103
|
+
"timeout_seconds": self.timeout_seconds,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def from_dict(cls, data: dict) -> "ToolConfig":
|
|
108
|
+
return cls(
|
|
109
|
+
name=data["name"],
|
|
110
|
+
description=data["description"],
|
|
111
|
+
parameters=data.get("parameters", {}),
|
|
112
|
+
function_path=data.get("function_path", ""),
|
|
113
|
+
requires_confirmation=data.get("requires_confirmation", False),
|
|
114
|
+
is_safe=data.get("is_safe", True),
|
|
115
|
+
timeout_seconds=data.get("timeout_seconds", 30),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class KnowledgeConfig:
|
|
121
|
+
"""Configuration for a knowledge source."""
|
|
122
|
+
|
|
123
|
+
name: str
|
|
124
|
+
knowledge_type: str # "text", "file", "url"
|
|
125
|
+
inclusion_mode: str = "always" # "always", "on_demand", "rag"
|
|
126
|
+
|
|
127
|
+
# Content (depends on type)
|
|
128
|
+
content: str = "" # For text type
|
|
129
|
+
file_path: str = "" # For file type
|
|
130
|
+
url: str = "" # For url type
|
|
131
|
+
|
|
132
|
+
def to_dict(self) -> dict:
|
|
133
|
+
return {
|
|
134
|
+
"name": self.name,
|
|
135
|
+
"type": self.knowledge_type,
|
|
136
|
+
"inclusion_mode": self.inclusion_mode,
|
|
137
|
+
"content": self.content,
|
|
138
|
+
"file_path": self.file_path,
|
|
139
|
+
"url": self.url,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def from_dict(cls, data: dict) -> "KnowledgeConfig":
|
|
144
|
+
return cls(
|
|
145
|
+
name=data["name"],
|
|
146
|
+
knowledge_type=data.get("type", "text"),
|
|
147
|
+
inclusion_mode=data.get("inclusion_mode", "always"),
|
|
148
|
+
content=data.get("content", ""),
|
|
149
|
+
file_path=data.get("file_path", ""),
|
|
150
|
+
url=data.get("url", ""),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class AgentConfig:
|
|
156
|
+
"""
|
|
157
|
+
Canonical configuration for an agent.
|
|
158
|
+
|
|
159
|
+
This is the portable format that can be serialized to JSON and
|
|
160
|
+
loaded by any runtime (Django or standalone).
|
|
161
|
+
|
|
162
|
+
For multi-agent systems, sub_agents contains embedded agent configs
|
|
163
|
+
that this agent can delegate to. The sub_agent_tools list defines
|
|
164
|
+
how each sub-agent is exposed as a tool.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
# Identity
|
|
168
|
+
name: str
|
|
169
|
+
slug: str
|
|
170
|
+
description: str = ""
|
|
171
|
+
|
|
172
|
+
# Core configuration
|
|
173
|
+
system_prompt: str = ""
|
|
174
|
+
model: str = "gpt-4o"
|
|
175
|
+
model_settings: dict = field(default_factory=dict)
|
|
176
|
+
|
|
177
|
+
# Tools and knowledge
|
|
178
|
+
tools: list[ToolConfig] = field(default_factory=list)
|
|
179
|
+
knowledge: list[KnowledgeConfig] = field(default_factory=list)
|
|
180
|
+
|
|
181
|
+
# Sub-agent tools (agent-as-tool pattern)
|
|
182
|
+
# These define other agents this agent can delegate to
|
|
183
|
+
sub_agent_tools: list[SubAgentToolConfig] = field(default_factory=list)
|
|
184
|
+
|
|
185
|
+
# Metadata
|
|
186
|
+
version: str = "1.0"
|
|
187
|
+
schema_version: str = "1" # For future schema migrations
|
|
188
|
+
created_at: Optional[str] = None
|
|
189
|
+
updated_at: Optional[str] = None
|
|
190
|
+
|
|
191
|
+
# Extra config (for extensibility)
|
|
192
|
+
extra: dict = field(default_factory=dict)
|
|
193
|
+
|
|
194
|
+
def to_dict(self) -> dict:
|
|
195
|
+
"""Serialize to dictionary (JSON-compatible)."""
|
|
196
|
+
result = {
|
|
197
|
+
"schema_version": self.schema_version,
|
|
198
|
+
"version": self.version,
|
|
199
|
+
"name": self.name,
|
|
200
|
+
"slug": self.slug,
|
|
201
|
+
"description": self.description,
|
|
202
|
+
"system_prompt": self.system_prompt,
|
|
203
|
+
"model": self.model,
|
|
204
|
+
"model_settings": self.model_settings,
|
|
205
|
+
"tools": [t.to_dict() for t in self.tools],
|
|
206
|
+
"knowledge": [k.to_dict() for k in self.knowledge],
|
|
207
|
+
"created_at": self.created_at,
|
|
208
|
+
"updated_at": self.updated_at,
|
|
209
|
+
"extra": self.extra,
|
|
210
|
+
}
|
|
211
|
+
# Only include sub_agent_tools if there are any
|
|
212
|
+
if self.sub_agent_tools:
|
|
213
|
+
result["sub_agent_tools"] = [s.to_dict() for s in self.sub_agent_tools]
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
def to_json(self, indent: int = 2) -> str:
|
|
217
|
+
"""Serialize to JSON string."""
|
|
218
|
+
return json.dumps(self.to_dict(), indent=indent)
|
|
219
|
+
|
|
220
|
+
def save(self, path: str | Path) -> None:
|
|
221
|
+
"""Save to a JSON file."""
|
|
222
|
+
path = Path(path)
|
|
223
|
+
path.write_text(self.to_json())
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def from_dict(cls, data: dict) -> "AgentConfig":
|
|
227
|
+
"""Load from dictionary."""
|
|
228
|
+
# Parse regular tools (skip subagent tools - they're in sub_agent_tools)
|
|
229
|
+
tools = []
|
|
230
|
+
for t in data.get("tools", []):
|
|
231
|
+
if t.get("tool_type") != "subagent":
|
|
232
|
+
tools.append(ToolConfig.from_dict(t))
|
|
233
|
+
|
|
234
|
+
knowledge = [KnowledgeConfig.from_dict(k) for k in data.get("knowledge", [])]
|
|
235
|
+
|
|
236
|
+
# Parse sub-agent tools
|
|
237
|
+
sub_agent_tools = []
|
|
238
|
+
for s in data.get("sub_agent_tools", []):
|
|
239
|
+
sub_agent_tools.append(SubAgentToolConfig.from_dict(s))
|
|
240
|
+
# Also check tools list for subagent type (backwards compat)
|
|
241
|
+
for t in data.get("tools", []):
|
|
242
|
+
if t.get("tool_type") == "subagent":
|
|
243
|
+
sub_agent_tools.append(SubAgentToolConfig.from_dict(t))
|
|
244
|
+
|
|
245
|
+
return cls(
|
|
246
|
+
name=data["name"],
|
|
247
|
+
slug=data["slug"],
|
|
248
|
+
description=data.get("description", ""),
|
|
249
|
+
system_prompt=data.get("system_prompt", ""),
|
|
250
|
+
model=data.get("model", "gpt-4o"),
|
|
251
|
+
model_settings=data.get("model_settings", {}),
|
|
252
|
+
tools=tools,
|
|
253
|
+
knowledge=knowledge,
|
|
254
|
+
sub_agent_tools=sub_agent_tools,
|
|
255
|
+
version=data.get("version", "1.0"),
|
|
256
|
+
schema_version=data.get("schema_version", "1"),
|
|
257
|
+
created_at=data.get("created_at"),
|
|
258
|
+
updated_at=data.get("updated_at"),
|
|
259
|
+
extra=data.get("extra", {}),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
@classmethod
|
|
263
|
+
def from_json(cls, json_str: str) -> "AgentConfig":
|
|
264
|
+
"""Load from JSON string."""
|
|
265
|
+
return cls.from_dict(json.loads(json_str))
|
|
266
|
+
|
|
267
|
+
@classmethod
|
|
268
|
+
def from_file(cls, path: str | Path) -> "AgentConfig":
|
|
269
|
+
"""Load from a JSON file."""
|
|
270
|
+
path = Path(path)
|
|
271
|
+
return cls.from_json(path.read_text())
|
|
272
|
+
|
|
273
|
+
def with_timestamp(self) -> "AgentConfig":
|
|
274
|
+
"""Return a copy with updated timestamp."""
|
|
275
|
+
now = datetime.utcnow().isoformat() + "Z"
|
|
276
|
+
return AgentConfig(
|
|
277
|
+
name=self.name,
|
|
278
|
+
slug=self.slug,
|
|
279
|
+
description=self.description,
|
|
280
|
+
system_prompt=self.system_prompt,
|
|
281
|
+
model=self.model,
|
|
282
|
+
model_settings=self.model_settings,
|
|
283
|
+
tools=self.tools,
|
|
284
|
+
knowledge=self.knowledge,
|
|
285
|
+
sub_agent_tools=self.sub_agent_tools,
|
|
286
|
+
version=self.version,
|
|
287
|
+
schema_version=self.schema_version,
|
|
288
|
+
created_at=self.created_at or now,
|
|
289
|
+
updated_at=now,
|
|
290
|
+
extra=self.extra,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def get_all_embedded_agents(self) -> dict[str, "AgentConfig"]:
|
|
294
|
+
"""
|
|
295
|
+
Get all embedded agent configs (for standalone execution).
|
|
296
|
+
|
|
297
|
+
Returns a dict mapping slug -> AgentConfig for all sub-agents
|
|
298
|
+
that have embedded configs (not just slug references).
|
|
299
|
+
"""
|
|
300
|
+
agents = {}
|
|
301
|
+
for sub_tool in self.sub_agent_tools:
|
|
302
|
+
if sub_tool.agent_config:
|
|
303
|
+
agents[sub_tool.agent_config.slug] = sub_tool.agent_config
|
|
304
|
+
# Recursively get nested sub-agents
|
|
305
|
+
agents.update(sub_tool.agent_config.get_all_embedded_agents())
|
|
306
|
+
return agents
|
|
307
|
+
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Concrete RunContext implementations for different use cases.
|
|
3
|
+
|
|
4
|
+
These implementations satisfy the RunContext protocol and can be used
|
|
5
|
+
directly with StepExecutor and agent runtimes.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
# For simple scripts (in-memory, no persistence)
|
|
9
|
+
ctx = InMemoryRunContext(run_id=uuid4())
|
|
10
|
+
|
|
11
|
+
# For scripts that need persistence across restarts
|
|
12
|
+
ctx = FileRunContext(run_id=uuid4(), checkpoint_dir="./checkpoints")
|
|
13
|
+
|
|
14
|
+
# Use with StepExecutor
|
|
15
|
+
from agent_runtime_core.steps import StepExecutor, Step
|
|
16
|
+
executor = StepExecutor(ctx)
|
|
17
|
+
results = await executor.run([
|
|
18
|
+
Step("fetch", fetch_data),
|
|
19
|
+
Step("process", process_data),
|
|
20
|
+
])
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Callable, Optional
|
|
28
|
+
from uuid import UUID, uuid4
|
|
29
|
+
|
|
30
|
+
from agent_runtime_core.interfaces import EventType, Message, ToolRegistry
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InMemoryRunContext:
|
|
34
|
+
"""
|
|
35
|
+
In-memory RunContext implementation.
|
|
36
|
+
|
|
37
|
+
Good for:
|
|
38
|
+
- Unit testing
|
|
39
|
+
- Simple scripts that don't need persistence
|
|
40
|
+
- Development and prototyping
|
|
41
|
+
|
|
42
|
+
State is lost when the process exits.
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
ctx = InMemoryRunContext(
|
|
46
|
+
run_id=uuid4(),
|
|
47
|
+
input_messages=[{"role": "user", "content": "Hello"}],
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Use with an agent
|
|
51
|
+
result = await my_agent.run(ctx)
|
|
52
|
+
|
|
53
|
+
# Or with StepExecutor
|
|
54
|
+
executor = StepExecutor(ctx)
|
|
55
|
+
results = await executor.run(steps)
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
run_id: Optional[UUID] = None,
|
|
61
|
+
*,
|
|
62
|
+
conversation_id: Optional[UUID] = None,
|
|
63
|
+
input_messages: Optional[list[Message]] = None,
|
|
64
|
+
params: Optional[dict] = None,
|
|
65
|
+
metadata: Optional[dict] = None,
|
|
66
|
+
tool_registry: Optional[ToolRegistry] = None,
|
|
67
|
+
on_event: Optional[Callable[[str, dict], None]] = None,
|
|
68
|
+
):
|
|
69
|
+
"""
|
|
70
|
+
Initialize an in-memory run context.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
run_id: Unique identifier for this run (auto-generated if not provided)
|
|
74
|
+
conversation_id: Associated conversation ID (optional)
|
|
75
|
+
input_messages: Input messages for this run
|
|
76
|
+
params: Additional parameters
|
|
77
|
+
metadata: Run metadata
|
|
78
|
+
tool_registry: Registry of available tools
|
|
79
|
+
on_event: Optional callback for events (for testing/debugging)
|
|
80
|
+
"""
|
|
81
|
+
self._run_id = run_id or uuid4()
|
|
82
|
+
self._conversation_id = conversation_id
|
|
83
|
+
self._input_messages = input_messages or []
|
|
84
|
+
self._params = params or {}
|
|
85
|
+
self._metadata = metadata or {}
|
|
86
|
+
self._tool_registry = tool_registry or ToolRegistry()
|
|
87
|
+
self._cancelled = False
|
|
88
|
+
self._state: Optional[dict] = None
|
|
89
|
+
self._events: list[dict] = []
|
|
90
|
+
self._on_event = on_event
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def run_id(self) -> UUID:
|
|
94
|
+
"""Unique identifier for this run."""
|
|
95
|
+
return self._run_id
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def conversation_id(self) -> Optional[UUID]:
|
|
99
|
+
"""Conversation this run belongs to (if any)."""
|
|
100
|
+
return self._conversation_id
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def input_messages(self) -> list[Message]:
|
|
104
|
+
"""Input messages for this run."""
|
|
105
|
+
return self._input_messages
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def params(self) -> dict:
|
|
109
|
+
"""Additional parameters for this run."""
|
|
110
|
+
return self._params
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def metadata(self) -> dict:
|
|
114
|
+
"""Metadata associated with this run."""
|
|
115
|
+
return self._metadata
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def tool_registry(self) -> ToolRegistry:
|
|
119
|
+
"""Registry of available tools for this agent."""
|
|
120
|
+
return self._tool_registry
|
|
121
|
+
|
|
122
|
+
async def emit(self, event_type: EventType | str, payload: dict) -> None:
|
|
123
|
+
"""Emit an event (stored in memory)."""
|
|
124
|
+
event_type_str = event_type.value if hasattr(event_type, 'value') else str(event_type)
|
|
125
|
+
event = {
|
|
126
|
+
"event_type": event_type_str,
|
|
127
|
+
"payload": payload,
|
|
128
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
129
|
+
}
|
|
130
|
+
self._events.append(event)
|
|
131
|
+
if self._on_event:
|
|
132
|
+
self._on_event(event_type_str, payload)
|
|
133
|
+
|
|
134
|
+
async def checkpoint(self, state: dict) -> None:
|
|
135
|
+
"""Save a state checkpoint (in memory)."""
|
|
136
|
+
self._state = state
|
|
137
|
+
|
|
138
|
+
async def get_state(self) -> Optional[dict]:
|
|
139
|
+
"""Get the last checkpointed state."""
|
|
140
|
+
return self._state
|
|
141
|
+
|
|
142
|
+
def cancelled(self) -> bool:
|
|
143
|
+
"""Check if cancellation has been requested."""
|
|
144
|
+
return self._cancelled
|
|
145
|
+
|
|
146
|
+
def cancel(self) -> None:
|
|
147
|
+
"""Request cancellation of this run."""
|
|
148
|
+
self._cancelled = True
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def events(self) -> list[dict]:
|
|
152
|
+
"""Get all emitted events (for testing/debugging)."""
|
|
153
|
+
return self._events.copy()
|
|
154
|
+
|
|
155
|
+
def clear_events(self) -> None:
|
|
156
|
+
"""Clear all events (for testing)."""
|
|
157
|
+
self._events.clear()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class FileRunContext:
|
|
161
|
+
"""
|
|
162
|
+
File-based RunContext implementation with persistent checkpoints.
|
|
163
|
+
|
|
164
|
+
Good for:
|
|
165
|
+
- Scripts that need to resume after restart
|
|
166
|
+
- Long-running processes without a database
|
|
167
|
+
- Simple persistence without external dependencies
|
|
168
|
+
|
|
169
|
+
Checkpoints are saved as JSON files in the specified directory.
|
|
170
|
+
|
|
171
|
+
Example:
|
|
172
|
+
ctx = FileRunContext(
|
|
173
|
+
run_id=uuid4(),
|
|
174
|
+
checkpoint_dir="./checkpoints",
|
|
175
|
+
input_messages=[{"role": "user", "content": "Process this"}],
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Checkpoints are saved to ./checkpoints/{run_id}.json
|
|
179
|
+
executor = StepExecutor(ctx)
|
|
180
|
+
results = await executor.run(steps)
|
|
181
|
+
|
|
182
|
+
# To resume after restart, use the same run_id:
|
|
183
|
+
ctx = FileRunContext(run_id=previous_run_id, checkpoint_dir="./checkpoints")
|
|
184
|
+
results = await executor.run(steps, resume=True)
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def __init__(
|
|
188
|
+
self,
|
|
189
|
+
run_id: Optional[UUID] = None,
|
|
190
|
+
*,
|
|
191
|
+
checkpoint_dir: str = "./checkpoints",
|
|
192
|
+
conversation_id: Optional[UUID] = None,
|
|
193
|
+
input_messages: Optional[list[Message]] = None,
|
|
194
|
+
params: Optional[dict] = None,
|
|
195
|
+
metadata: Optional[dict] = None,
|
|
196
|
+
tool_registry: Optional[ToolRegistry] = None,
|
|
197
|
+
on_event: Optional[Callable[[str, dict], None]] = None,
|
|
198
|
+
):
|
|
199
|
+
"""
|
|
200
|
+
Initialize a file-based run context.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
run_id: Unique identifier for this run (auto-generated if not provided)
|
|
204
|
+
checkpoint_dir: Directory to store checkpoint files
|
|
205
|
+
conversation_id: Associated conversation ID (optional)
|
|
206
|
+
input_messages: Input messages for this run
|
|
207
|
+
params: Additional parameters
|
|
208
|
+
metadata: Run metadata
|
|
209
|
+
tool_registry: Registry of available tools
|
|
210
|
+
on_event: Optional callback for events
|
|
211
|
+
"""
|
|
212
|
+
self._run_id = run_id or uuid4()
|
|
213
|
+
self._checkpoint_dir = Path(checkpoint_dir)
|
|
214
|
+
self._conversation_id = conversation_id
|
|
215
|
+
self._input_messages = input_messages or []
|
|
216
|
+
self._params = params or {}
|
|
217
|
+
self._metadata = metadata or {}
|
|
218
|
+
self._tool_registry = tool_registry or ToolRegistry()
|
|
219
|
+
self._cancelled = False
|
|
220
|
+
self._on_event = on_event
|
|
221
|
+
self._state_cache: Optional[dict] = None
|
|
222
|
+
|
|
223
|
+
# Ensure checkpoint directory exists
|
|
224
|
+
self._checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def run_id(self) -> UUID:
|
|
228
|
+
"""Unique identifier for this run."""
|
|
229
|
+
return self._run_id
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def conversation_id(self) -> Optional[UUID]:
|
|
233
|
+
"""Conversation this run belongs to (if any)."""
|
|
234
|
+
return self._conversation_id
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def input_messages(self) -> list[Message]:
|
|
238
|
+
"""Input messages for this run."""
|
|
239
|
+
return self._input_messages
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def params(self) -> dict:
|
|
243
|
+
"""Additional parameters for this run."""
|
|
244
|
+
return self._params
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def metadata(self) -> dict:
|
|
248
|
+
"""Metadata associated with this run."""
|
|
249
|
+
return self._metadata
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def tool_registry(self) -> ToolRegistry:
|
|
253
|
+
"""Registry of available tools for this agent."""
|
|
254
|
+
return self._tool_registry
|
|
255
|
+
|
|
256
|
+
def _checkpoint_path(self) -> Path:
|
|
257
|
+
"""Get the path to the checkpoint file for this run."""
|
|
258
|
+
return self._checkpoint_dir / f"{self._run_id}.json"
|
|
259
|
+
|
|
260
|
+
def _events_path(self) -> Path:
|
|
261
|
+
"""Get the path to the events file for this run."""
|
|
262
|
+
return self._checkpoint_dir / f"{self._run_id}_events.jsonl"
|
|
263
|
+
|
|
264
|
+
async def emit(self, event_type: EventType | str, payload: dict) -> None:
|
|
265
|
+
"""Emit an event (appended to events file)."""
|
|
266
|
+
event_type_str = event_type.value if hasattr(event_type, 'value') else str(event_type)
|
|
267
|
+
event = {
|
|
268
|
+
"event_type": event_type_str,
|
|
269
|
+
"payload": payload,
|
|
270
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
# Append to events file (JSONL format)
|
|
274
|
+
with open(self._events_path(), "a") as f:
|
|
275
|
+
f.write(json.dumps(event) + "\n")
|
|
276
|
+
|
|
277
|
+
if self._on_event:
|
|
278
|
+
self._on_event(event_type_str, payload)
|
|
279
|
+
|
|
280
|
+
async def checkpoint(self, state: dict) -> None:
|
|
281
|
+
"""Save a state checkpoint to file."""
|
|
282
|
+
self._state_cache = state
|
|
283
|
+
checkpoint_data = {
|
|
284
|
+
"run_id": str(self._run_id),
|
|
285
|
+
"state": state,
|
|
286
|
+
"updated_at": datetime.utcnow().isoformat(),
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
# Write atomically using temp file
|
|
290
|
+
temp_path = self._checkpoint_path().with_suffix(".tmp")
|
|
291
|
+
with open(temp_path, "w") as f:
|
|
292
|
+
json.dump(checkpoint_data, f, indent=2)
|
|
293
|
+
temp_path.rename(self._checkpoint_path())
|
|
294
|
+
|
|
295
|
+
async def get_state(self) -> Optional[dict]:
|
|
296
|
+
"""Get the last checkpointed state from file."""
|
|
297
|
+
if self._state_cache is not None:
|
|
298
|
+
return self._state_cache
|
|
299
|
+
|
|
300
|
+
checkpoint_path = self._checkpoint_path()
|
|
301
|
+
if not checkpoint_path.exists():
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
with open(checkpoint_path) as f:
|
|
306
|
+
data = json.load(f)
|
|
307
|
+
self._state_cache = data.get("state")
|
|
308
|
+
return self._state_cache
|
|
309
|
+
except (json.JSONDecodeError, IOError):
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
def cancelled(self) -> bool:
|
|
313
|
+
"""Check if cancellation has been requested."""
|
|
314
|
+
return self._cancelled
|
|
315
|
+
|
|
316
|
+
def cancel(self) -> None:
|
|
317
|
+
"""Request cancellation of this run."""
|
|
318
|
+
self._cancelled = True
|
|
319
|
+
|
|
320
|
+
def get_events(self) -> list[dict]:
|
|
321
|
+
"""Read all events from the events file."""
|
|
322
|
+
events_path = self._events_path()
|
|
323
|
+
if not events_path.exists():
|
|
324
|
+
return []
|
|
325
|
+
|
|
326
|
+
events = []
|
|
327
|
+
with open(events_path) as f:
|
|
328
|
+
for line in f:
|
|
329
|
+
line = line.strip()
|
|
330
|
+
if line:
|
|
331
|
+
try:
|
|
332
|
+
events.append(json.loads(line))
|
|
333
|
+
except json.JSONDecodeError:
|
|
334
|
+
pass
|
|
335
|
+
return events
|
|
336
|
+
|
|
337
|
+
def clear(self) -> None:
|
|
338
|
+
"""Delete checkpoint and events files for this run."""
|
|
339
|
+
checkpoint_path = self._checkpoint_path()
|
|
340
|
+
events_path = self._events_path()
|
|
341
|
+
|
|
342
|
+
if checkpoint_path.exists():
|
|
343
|
+
checkpoint_path.unlink()
|
|
344
|
+
if events_path.exists():
|
|
345
|
+
events_path.unlink()
|
|
346
|
+
|
|
347
|
+
self._state_cache = None
|
|
348
|
+
|