AbstractRuntime 0.2.0__py3-none-any.whl → 0.4.0__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.
- abstractruntime/__init__.py +7 -2
- abstractruntime/core/config.py +14 -1
- abstractruntime/core/event_keys.py +62 -0
- abstractruntime/core/models.py +12 -1
- abstractruntime/core/runtime.py +2444 -14
- abstractruntime/core/vars.py +95 -0
- abstractruntime/evidence/__init__.py +10 -0
- abstractruntime/evidence/recorder.py +325 -0
- abstractruntime/integrations/abstractcore/__init__.py +3 -0
- abstractruntime/integrations/abstractcore/constants.py +19 -0
- abstractruntime/integrations/abstractcore/default_tools.py +134 -0
- abstractruntime/integrations/abstractcore/effect_handlers.py +255 -6
- abstractruntime/integrations/abstractcore/factory.py +95 -10
- abstractruntime/integrations/abstractcore/llm_client.py +456 -52
- abstractruntime/integrations/abstractcore/mcp_worker.py +586 -0
- abstractruntime/integrations/abstractcore/observability.py +80 -0
- abstractruntime/integrations/abstractcore/summarizer.py +154 -0
- abstractruntime/integrations/abstractcore/tool_executor.py +481 -24
- abstractruntime/memory/__init__.py +21 -0
- abstractruntime/memory/active_context.py +746 -0
- abstractruntime/memory/active_memory.py +452 -0
- abstractruntime/memory/compaction.py +105 -0
- abstractruntime/rendering/__init__.py +17 -0
- abstractruntime/rendering/agent_trace_report.py +256 -0
- abstractruntime/rendering/json_stringify.py +136 -0
- abstractruntime/scheduler/scheduler.py +93 -2
- abstractruntime/storage/__init__.py +3 -1
- abstractruntime/storage/artifacts.py +20 -5
- abstractruntime/storage/json_files.py +15 -2
- abstractruntime/storage/observable.py +99 -0
- {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.0.dist-info}/METADATA +5 -1
- abstractruntime-0.4.0.dist-info/RECORD +49 -0
- abstractruntime-0.4.0.dist-info/entry_points.txt +2 -0
- abstractruntime-0.2.0.dist-info/RECORD +0 -32
- {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.0.dist-info}/WHEEL +0 -0
- {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Agent scratchpad → Markdown report renderer.
|
|
2
|
+
|
|
3
|
+
Goal:
|
|
4
|
+
- Clear, complete, and token-efficient review artifact for agent runs.
|
|
5
|
+
- No truncation of tool call arguments or tool execution results.
|
|
6
|
+
|
|
7
|
+
Input shape:
|
|
8
|
+
The "scratchpad" passed around by hosts is expected to include runtime-owned node traces,
|
|
9
|
+
typically at `scratchpad["node_traces"]`, which is sourced from:
|
|
10
|
+
`RunState.vars["_runtime"]["node_traces"]` (ADR-0010).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
from .json_stringify import JsonStringifyMode, stringify_json
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class AgentTraceMarkdownReportConfig:
|
|
24
|
+
"""Rendering configuration.
|
|
25
|
+
|
|
26
|
+
Keep defaults conservative to avoid bloating outputs.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
include_timestamps: bool = False
|
|
30
|
+
json_mode: JsonStringifyMode = JsonStringifyMode.BEAUTIFY
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _as_dict(value: Any) -> Optional[Dict[str, Any]]:
|
|
34
|
+
return value if isinstance(value, dict) else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _collect_trace_steps(node_traces: Dict[str, Any]) -> List[Tuple[str, str, Dict[str, Any]]]:
|
|
38
|
+
"""Flatten node_traces into a chronologically sortable list of (ts, node_id, entry)."""
|
|
39
|
+
out: list[tuple[str, str, Dict[str, Any]]] = []
|
|
40
|
+
for node_id, trace in node_traces.items():
|
|
41
|
+
t = _as_dict(trace)
|
|
42
|
+
if not t:
|
|
43
|
+
continue
|
|
44
|
+
steps = t.get("steps")
|
|
45
|
+
if not isinstance(steps, list):
|
|
46
|
+
continue
|
|
47
|
+
for entry in steps:
|
|
48
|
+
e = _as_dict(entry)
|
|
49
|
+
if not e:
|
|
50
|
+
continue
|
|
51
|
+
ts = e.get("ts")
|
|
52
|
+
ts_s = ts if isinstance(ts, str) else ""
|
|
53
|
+
out.append((ts_s, node_id, e))
|
|
54
|
+
# ISO timestamps are lexicographically sortable.
|
|
55
|
+
out.sort(key=lambda x: x[0])
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _code_block(value: Any, *, language: str) -> str:
|
|
60
|
+
"""Render a value inside a fenced code block (no truncation)."""
|
|
61
|
+
if language == "json":
|
|
62
|
+
text = stringify_json(value, mode=JsonStringifyMode.BEAUTIFY, sort_keys=False, parse_strings=False)
|
|
63
|
+
else:
|
|
64
|
+
text = "" if value is None else str(value)
|
|
65
|
+
return f"```{language}\n{text}\n```"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _render_tool_call(call: Dict[str, Any], result: Optional[Dict[str, Any]], *, cfg: AgentTraceMarkdownReportConfig) -> str:
|
|
69
|
+
name = call.get("name")
|
|
70
|
+
call_id = call.get("call_id") or call.get("id") or ""
|
|
71
|
+
args = call.get("arguments", {})
|
|
72
|
+
|
|
73
|
+
title = f"#### Tool: `{name}`"
|
|
74
|
+
if isinstance(call_id, str) and call_id:
|
|
75
|
+
title += f" (call_id={call_id})"
|
|
76
|
+
|
|
77
|
+
lines: list[str] = [title, "", "**Arguments**", _code_block(args, language="json")]
|
|
78
|
+
|
|
79
|
+
if result is None:
|
|
80
|
+
lines.extend(["", "**Result**", "_missing tool result in trace entry_"])
|
|
81
|
+
return "\n".join(lines)
|
|
82
|
+
|
|
83
|
+
success = result.get("success") if isinstance(result.get("success"), bool) else None
|
|
84
|
+
error = result.get("error")
|
|
85
|
+
output = result.get("output")
|
|
86
|
+
|
|
87
|
+
lines.append("")
|
|
88
|
+
lines.append("**Result**")
|
|
89
|
+
if success is not None:
|
|
90
|
+
lines.append(f"- **success**: {str(success).lower()}")
|
|
91
|
+
if error is not None:
|
|
92
|
+
lines.append(f"- **error**: {error}")
|
|
93
|
+
|
|
94
|
+
# Output can be string or JSON.
|
|
95
|
+
if isinstance(output, (dict, list, bool, int, float)) or output is None:
|
|
96
|
+
lines.append(_code_block(output, language="json"))
|
|
97
|
+
else:
|
|
98
|
+
lines.append(_code_block(output, language="text"))
|
|
99
|
+
|
|
100
|
+
return "\n".join(lines)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _index_tool_results_by_call_id(tool_results: Any) -> Dict[str, Dict[str, Any]]:
|
|
104
|
+
"""Build a call_id → result mapping from a TOOL_CALLS effect outcome."""
|
|
105
|
+
if not isinstance(tool_results, dict):
|
|
106
|
+
return {}
|
|
107
|
+
results = tool_results.get("results")
|
|
108
|
+
if not isinstance(results, list):
|
|
109
|
+
return {}
|
|
110
|
+
out: dict[str, Dict[str, Any]] = {}
|
|
111
|
+
for r in results:
|
|
112
|
+
rr = _as_dict(r)
|
|
113
|
+
if not rr:
|
|
114
|
+
continue
|
|
115
|
+
call_id = rr.get("call_id")
|
|
116
|
+
if isinstance(call_id, str) and call_id:
|
|
117
|
+
out[call_id] = rr
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def render_agent_trace_markdown(scratchpad: Any, *, config: Optional[AgentTraceMarkdownReportConfig] = None) -> str:
|
|
122
|
+
"""Render an agent scratchpad (runtime-owned node traces) into Markdown."""
|
|
123
|
+
cfg = config or AgentTraceMarkdownReportConfig()
|
|
124
|
+
|
|
125
|
+
sp = _as_dict(scratchpad)
|
|
126
|
+
if sp is None:
|
|
127
|
+
return "# Agent Trace Report\n\n_No scratchpad provided._\n"
|
|
128
|
+
|
|
129
|
+
node_traces = sp.get("node_traces")
|
|
130
|
+
if not isinstance(node_traces, dict):
|
|
131
|
+
# Allow passing node_traces directly.
|
|
132
|
+
if isinstance(scratchpad, dict) and "steps" in scratchpad and "node_id" in scratchpad:
|
|
133
|
+
node_traces = {str(scratchpad.get("node_id")): scratchpad}
|
|
134
|
+
else:
|
|
135
|
+
return "# Agent Trace Report\n\n_No node_traces found in scratchpad._\n"
|
|
136
|
+
|
|
137
|
+
header: list[str] = ["# Agent Trace Report"]
|
|
138
|
+
|
|
139
|
+
sub_run_id = sp.get("sub_run_id")
|
|
140
|
+
workflow_id = sp.get("workflow_id")
|
|
141
|
+
if isinstance(sub_run_id, str) and sub_run_id:
|
|
142
|
+
header.append(f"- **sub_run_id**: `{sub_run_id}`")
|
|
143
|
+
if isinstance(workflow_id, str) and workflow_id:
|
|
144
|
+
header.append(f"- **workflow_id**: `{workflow_id}`")
|
|
145
|
+
|
|
146
|
+
header.append("")
|
|
147
|
+
|
|
148
|
+
steps = _collect_trace_steps(node_traces)
|
|
149
|
+
if not steps:
|
|
150
|
+
return "\n".join(header + ["_No trace steps found._", ""])
|
|
151
|
+
|
|
152
|
+
lines: list[str] = header + ["## Timeline", ""]
|
|
153
|
+
|
|
154
|
+
for idx, (ts, node_id, entry) in enumerate(steps, start=1):
|
|
155
|
+
status = entry.get("status")
|
|
156
|
+
status_s = status if isinstance(status, str) else ""
|
|
157
|
+
effect = _as_dict(entry.get("effect")) or {}
|
|
158
|
+
effect_type = effect.get("type")
|
|
159
|
+
effect_type_s = effect_type if isinstance(effect_type, str) else ""
|
|
160
|
+
|
|
161
|
+
lines.append(f"### {idx}. `{node_id}` — `{effect_type_s}` ({status_s})")
|
|
162
|
+
if cfg.include_timestamps and ts:
|
|
163
|
+
lines.append(f"- **ts**: `{ts}`")
|
|
164
|
+
duration_ms = entry.get("duration_ms")
|
|
165
|
+
if isinstance(duration_ms, (int, float)) and duration_ms >= 0:
|
|
166
|
+
lines.append(f"- **duration_ms**: {float(duration_ms):.3f}")
|
|
167
|
+
|
|
168
|
+
if status_s == "failed":
|
|
169
|
+
err = entry.get("error")
|
|
170
|
+
if err is not None:
|
|
171
|
+
lines.append("")
|
|
172
|
+
lines.append("**Error**")
|
|
173
|
+
lines.append(_code_block(err, language="text"))
|
|
174
|
+
lines.append("")
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
result = _as_dict(entry.get("result"))
|
|
178
|
+
|
|
179
|
+
if effect_type_s == "llm_call":
|
|
180
|
+
# Keep it token-efficient: only show what the LLM produced + whether it asked for tools.
|
|
181
|
+
content = result.get("content") if result else None
|
|
182
|
+
tool_calls = result.get("tool_calls") if result else None
|
|
183
|
+
model = result.get("model") if result else None
|
|
184
|
+
finish_reason = result.get("finish_reason") if result else None
|
|
185
|
+
|
|
186
|
+
if isinstance(model, str) and model:
|
|
187
|
+
lines.append(f"- **model**: `{model}`")
|
|
188
|
+
if isinstance(finish_reason, str) and finish_reason:
|
|
189
|
+
lines.append(f"- **finish_reason**: `{finish_reason}`")
|
|
190
|
+
|
|
191
|
+
if isinstance(tool_calls, list) and tool_calls:
|
|
192
|
+
lines.append("- **tool_calls_requested**:")
|
|
193
|
+
for c in tool_calls:
|
|
194
|
+
cc = _as_dict(c)
|
|
195
|
+
if not cc:
|
|
196
|
+
continue
|
|
197
|
+
nm = cc.get("name")
|
|
198
|
+
cid = cc.get("call_id") or ""
|
|
199
|
+
if isinstance(nm, str) and nm:
|
|
200
|
+
suffix = f" (call_id={cid})" if isinstance(cid, str) and cid else ""
|
|
201
|
+
lines.append(f" - `{nm}`{suffix}")
|
|
202
|
+
else:
|
|
203
|
+
lines.append("- **tool_calls_requested**: none")
|
|
204
|
+
|
|
205
|
+
if isinstance(content, str) and content.strip():
|
|
206
|
+
lines.append("")
|
|
207
|
+
lines.append("**Assistant content**")
|
|
208
|
+
lines.append(_code_block(content, language="markdown"))
|
|
209
|
+
lines.append("")
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
if effect_type_s == "tool_calls":
|
|
213
|
+
payload = _as_dict(effect.get("payload")) or {}
|
|
214
|
+
calls = payload.get("tool_calls")
|
|
215
|
+
calls_list: list[Any]
|
|
216
|
+
if isinstance(calls, list):
|
|
217
|
+
calls_list = calls
|
|
218
|
+
elif calls is None:
|
|
219
|
+
calls_list = []
|
|
220
|
+
else:
|
|
221
|
+
calls_list = [calls]
|
|
222
|
+
|
|
223
|
+
results_by_id = _index_tool_results_by_call_id(result)
|
|
224
|
+
if not calls_list:
|
|
225
|
+
lines.append("- **tool_calls**: none")
|
|
226
|
+
lines.append("")
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
lines.append("")
|
|
230
|
+
for call_any in calls_list:
|
|
231
|
+
call = _as_dict(call_any)
|
|
232
|
+
if not call:
|
|
233
|
+
continue
|
|
234
|
+
call_id = call.get("call_id")
|
|
235
|
+
call_id_s = call_id if isinstance(call_id, str) else ""
|
|
236
|
+
r = results_by_id.get(call_id_s) if call_id_s else None
|
|
237
|
+
lines.append(_render_tool_call(call, r, cfg=cfg))
|
|
238
|
+
lines.append("")
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
# Fallback: show a compact JSON of the result (still no truncation).
|
|
242
|
+
if result is not None:
|
|
243
|
+
lines.append("")
|
|
244
|
+
lines.append("**Result (raw)**")
|
|
245
|
+
lines.append(_code_block(result, language="json"))
|
|
246
|
+
lines.append("")
|
|
247
|
+
|
|
248
|
+
# Validate that report is JSON-safe when embedded (defensive, should always be true).
|
|
249
|
+
try:
|
|
250
|
+
json.dumps({"report": "\n".join(lines)})
|
|
251
|
+
except Exception:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
255
|
+
|
|
256
|
+
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""JSON stringify utilities.
|
|
2
|
+
|
|
3
|
+
Why this exists in AbstractRuntime:
|
|
4
|
+
- Many hosts need consistent "JSON → string" semantics (UI preview, reports, prompts).
|
|
5
|
+
- Keeping the core logic in runtime avoids host-specific divergence (layering, ADR-0001).
|
|
6
|
+
|
|
7
|
+
This intentionally stays dependency-light (stdlib only).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import json
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class JsonStringifyMode(str, Enum):
|
|
19
|
+
"""Formatting mode for JSON stringification."""
|
|
20
|
+
|
|
21
|
+
NONE = "none" # default json.dumps formatting (single line, spaces after separators)
|
|
22
|
+
BEAUTIFY = "beautify" # multi-line, indented
|
|
23
|
+
MINIFIED = "minified" # condensed separators (no spaces)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _strip_code_fence(text: str) -> str:
|
|
27
|
+
s = text.strip()
|
|
28
|
+
if not s.startswith("```"):
|
|
29
|
+
return s
|
|
30
|
+
# Opening fence line can be ```json / ```js etc; drop it.
|
|
31
|
+
nl = s.find("\n")
|
|
32
|
+
if nl == -1:
|
|
33
|
+
return s.strip("`").strip()
|
|
34
|
+
body = s[nl + 1 :]
|
|
35
|
+
end = body.rfind("```")
|
|
36
|
+
if end != -1:
|
|
37
|
+
body = body[:end]
|
|
38
|
+
return body.strip()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _jsonify(value: Any) -> Any:
|
|
42
|
+
"""Convert a value into JSON-serializable types (best-effort)."""
|
|
43
|
+
if value is None or isinstance(value, (bool, int, float, str)):
|
|
44
|
+
return value
|
|
45
|
+
if isinstance(value, dict):
|
|
46
|
+
return {str(k): _jsonify(v) for k, v in value.items()}
|
|
47
|
+
if isinstance(value, list):
|
|
48
|
+
return [_jsonify(v) for v in value]
|
|
49
|
+
if isinstance(value, tuple):
|
|
50
|
+
return [_jsonify(v) for v in value]
|
|
51
|
+
return str(value)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _parse_jsonish_maybe(text: str) -> Optional[Any]:
|
|
55
|
+
"""Best-effort parse of JSON-ish strings.
|
|
56
|
+
|
|
57
|
+
Accepts:
|
|
58
|
+
- strict JSON
|
|
59
|
+
- JSON embedded in a larger string (extract first object/array substring)
|
|
60
|
+
- Python-literal dict/list (common LLM output), via ast.literal_eval
|
|
61
|
+
"""
|
|
62
|
+
s = _strip_code_fence(text)
|
|
63
|
+
if not s:
|
|
64
|
+
return None
|
|
65
|
+
s = s.strip()
|
|
66
|
+
if not s:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
return json.loads(s)
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
# Best-effort: parse the first JSON object/array substring.
|
|
75
|
+
decoder = json.JSONDecoder()
|
|
76
|
+
starts: list[int] = []
|
|
77
|
+
for i, ch in enumerate(s):
|
|
78
|
+
if ch in "{[":
|
|
79
|
+
starts.append(i)
|
|
80
|
+
if len(starts) >= 64:
|
|
81
|
+
break
|
|
82
|
+
for i in starts:
|
|
83
|
+
try:
|
|
84
|
+
parsed, _end = decoder.raw_decode(s[i:])
|
|
85
|
+
return parsed
|
|
86
|
+
except Exception:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
# Last resort: tolerate Python-literal dict/list output.
|
|
90
|
+
try:
|
|
91
|
+
return ast.literal_eval(s)
|
|
92
|
+
except Exception:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def stringify_json(
|
|
97
|
+
value: Any,
|
|
98
|
+
*,
|
|
99
|
+
mode: str | JsonStringifyMode = JsonStringifyMode.BEAUTIFY,
|
|
100
|
+
beautify_indent: int = 2,
|
|
101
|
+
sort_keys: bool = False,
|
|
102
|
+
parse_strings: bool = True,
|
|
103
|
+
) -> str:
|
|
104
|
+
"""Render a JSON-like value into a string.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
value: Any JSON-like value (dict/list/scalar). If `parse_strings=True`, a string
|
|
108
|
+
that contains JSON (or JSON-ish text) is parsed and then rendered.
|
|
109
|
+
mode: none | beautify | minified.
|
|
110
|
+
beautify_indent: Indentation width for beautify mode.
|
|
111
|
+
sort_keys: When true, sort object keys for deterministic output.
|
|
112
|
+
parse_strings: When true, attempt to parse JSON-ish strings before rendering.
|
|
113
|
+
"""
|
|
114
|
+
mode_value = mode.value if isinstance(mode, JsonStringifyMode) else str(mode or "").strip().lower()
|
|
115
|
+
if mode_value not in {m.value for m in JsonStringifyMode}:
|
|
116
|
+
mode_value = JsonStringifyMode.BEAUTIFY.value
|
|
117
|
+
|
|
118
|
+
if parse_strings and isinstance(value, str) and value.strip():
|
|
119
|
+
parsed = _parse_jsonish_maybe(value)
|
|
120
|
+
if parsed is not None:
|
|
121
|
+
value = parsed
|
|
122
|
+
|
|
123
|
+
safe = _jsonify(value)
|
|
124
|
+
|
|
125
|
+
if mode_value == JsonStringifyMode.MINIFIED.value:
|
|
126
|
+
return json.dumps(safe, ensure_ascii=False, sort_keys=sort_keys, separators=(",", ":"))
|
|
127
|
+
|
|
128
|
+
if mode_value == JsonStringifyMode.NONE.value:
|
|
129
|
+
return json.dumps(safe, ensure_ascii=False, sort_keys=sort_keys)
|
|
130
|
+
|
|
131
|
+
indent = beautify_indent if isinstance(beautify_indent, int) else 2
|
|
132
|
+
if indent < 0:
|
|
133
|
+
indent = 2
|
|
134
|
+
return json.dumps(safe, ensure_ascii=False, sort_keys=sort_keys, indent=indent)
|
|
135
|
+
|
|
136
|
+
|
|
@@ -19,6 +19,7 @@ from typing import Any, Callable, Dict, List, Optional
|
|
|
19
19
|
|
|
20
20
|
from ..core.models import RunState, RunStatus, WaitReason, WaitState
|
|
21
21
|
from ..core.runtime import Runtime
|
|
22
|
+
from ..core.event_keys import build_event_wait_key
|
|
22
23
|
from ..storage.base import QueryableRunStore
|
|
23
24
|
from .registry import WorkflowRegistry
|
|
24
25
|
|
|
@@ -30,6 +31,18 @@ def utc_now_iso() -> str:
|
|
|
30
31
|
return datetime.now(timezone.utc).isoformat()
|
|
31
32
|
|
|
32
33
|
|
|
34
|
+
def _is_paused(vars: Any) -> bool:
|
|
35
|
+
if not isinstance(vars, dict):
|
|
36
|
+
return False
|
|
37
|
+
runtime_ns = vars.get("_runtime")
|
|
38
|
+
if not isinstance(runtime_ns, dict):
|
|
39
|
+
return False
|
|
40
|
+
control = runtime_ns.get("control")
|
|
41
|
+
if not isinstance(control, dict):
|
|
42
|
+
return False
|
|
43
|
+
return bool(control.get("paused") is True)
|
|
44
|
+
|
|
45
|
+
|
|
33
46
|
@dataclass
|
|
34
47
|
class SchedulerStats:
|
|
35
48
|
"""Statistics about scheduler operation."""
|
|
@@ -218,6 +231,76 @@ class Scheduler:
|
|
|
218
231
|
payload=payload,
|
|
219
232
|
)
|
|
220
233
|
|
|
234
|
+
def emit_event(
|
|
235
|
+
self,
|
|
236
|
+
*,
|
|
237
|
+
name: str,
|
|
238
|
+
payload: Dict[str, Any],
|
|
239
|
+
scope: str = "session",
|
|
240
|
+
session_id: Optional[str] = None,
|
|
241
|
+
workflow_id: Optional[str] = None,
|
|
242
|
+
run_id: Optional[str] = None,
|
|
243
|
+
max_steps: int = 100,
|
|
244
|
+
limit: int = 10_000,
|
|
245
|
+
) -> List[RunState]:
|
|
246
|
+
"""Emit an event and resume all matching WAIT_EVENT runs.
|
|
247
|
+
|
|
248
|
+
This is the host-facing API for external signals (Temporal-style).
|
|
249
|
+
|
|
250
|
+
Default scope is "session" (workflow instance). For session scope, you must
|
|
251
|
+
provide `session_id` (typically the root run_id for that instance).
|
|
252
|
+
"""
|
|
253
|
+
name2 = str(name or "").strip()
|
|
254
|
+
if not name2:
|
|
255
|
+
raise ValueError("emit_event requires a non-empty name")
|
|
256
|
+
|
|
257
|
+
scope2 = str(scope or "session").strip().lower() or "session"
|
|
258
|
+
|
|
259
|
+
wait_key = build_event_wait_key(
|
|
260
|
+
scope=scope2,
|
|
261
|
+
name=name2,
|
|
262
|
+
session_id=session_id,
|
|
263
|
+
workflow_id=workflow_id,
|
|
264
|
+
run_id=run_id,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Find runs waiting for this event key.
|
|
268
|
+
waiting_runs = self._run_store.list_runs(
|
|
269
|
+
status=RunStatus.WAITING,
|
|
270
|
+
wait_reason=WaitReason.EVENT,
|
|
271
|
+
limit=limit,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
resumed: List[RunState] = []
|
|
275
|
+
envelope: Dict[str, Any] = {
|
|
276
|
+
"event_id": None,
|
|
277
|
+
"name": name2,
|
|
278
|
+
"scope": scope2,
|
|
279
|
+
"session_id": session_id,
|
|
280
|
+
"payload": dict(payload) if isinstance(payload, dict) else {"value": payload},
|
|
281
|
+
"emitted_at": utc_now_iso(),
|
|
282
|
+
"emitter": {"source": "external"},
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
for r in waiting_runs:
|
|
286
|
+
if _is_paused(getattr(r, "vars", None)):
|
|
287
|
+
continue
|
|
288
|
+
if r.waiting is None:
|
|
289
|
+
continue
|
|
290
|
+
if r.waiting.wait_key != wait_key:
|
|
291
|
+
continue
|
|
292
|
+
wf = self._registry.get_or_raise(r.workflow_id)
|
|
293
|
+
new_state = self._runtime.resume(
|
|
294
|
+
workflow=wf,
|
|
295
|
+
run_id=r.run_id,
|
|
296
|
+
wait_key=wait_key,
|
|
297
|
+
payload=envelope,
|
|
298
|
+
max_steps=max_steps,
|
|
299
|
+
)
|
|
300
|
+
resumed.append(new_state)
|
|
301
|
+
|
|
302
|
+
return resumed
|
|
303
|
+
|
|
221
304
|
def find_waiting_runs(
|
|
222
305
|
self,
|
|
223
306
|
*,
|
|
@@ -289,11 +372,17 @@ class Scheduler:
|
|
|
289
372
|
|
|
290
373
|
resumed_count = 0
|
|
291
374
|
for run in due_runs:
|
|
375
|
+
if _is_paused(getattr(run, "vars", None)):
|
|
376
|
+
continue
|
|
377
|
+
# Record resumption immediately to avoid a race where the run completes
|
|
378
|
+
# (via runtime.tick) before the main thread observes updated stats.
|
|
379
|
+
self._stats.runs_resumed += 1
|
|
380
|
+
resumed_count += 1
|
|
292
381
|
try:
|
|
293
382
|
self._resume_wait_until(run)
|
|
294
|
-
resumed_count += 1
|
|
295
|
-
self._stats.runs_resumed += 1
|
|
296
383
|
except Exception as e:
|
|
384
|
+
self._stats.runs_resumed -= 1
|
|
385
|
+
resumed_count -= 1
|
|
297
386
|
logger.error("Failed to resume run %s: %s", run.run_id, e)
|
|
298
387
|
self._stats.runs_failed += 1
|
|
299
388
|
self._record_error(f"Run {run.run_id}: {e}")
|
|
@@ -368,6 +457,8 @@ class Scheduler:
|
|
|
368
457
|
)
|
|
369
458
|
|
|
370
459
|
for run in waiting_runs:
|
|
460
|
+
if _is_paused(getattr(run, "vars", None)):
|
|
461
|
+
continue
|
|
371
462
|
if run.waiting is None:
|
|
372
463
|
continue
|
|
373
464
|
if run.waiting.reason != WaitReason.SUBWORKFLOW:
|
|
@@ -4,6 +4,7 @@ from .base import RunStore, LedgerStore, QueryableRunStore
|
|
|
4
4
|
from .in_memory import InMemoryRunStore, InMemoryLedgerStore
|
|
5
5
|
from .json_files import JsonFileRunStore, JsonlLedgerStore
|
|
6
6
|
from .ledger_chain import HashChainedLedgerStore, verify_ledger_chain
|
|
7
|
+
from .observable import ObservableLedgerStore, ObservableLedgerStoreProtocol
|
|
7
8
|
from .snapshots import Snapshot, SnapshotStore, InMemorySnapshotStore, JsonSnapshotStore
|
|
8
9
|
|
|
9
10
|
__all__ = [
|
|
@@ -16,10 +17,11 @@ __all__ = [
|
|
|
16
17
|
"JsonlLedgerStore",
|
|
17
18
|
"HashChainedLedgerStore",
|
|
18
19
|
"verify_ledger_chain",
|
|
20
|
+
"ObservableLedgerStore",
|
|
21
|
+
"ObservableLedgerStoreProtocol",
|
|
19
22
|
"Snapshot",
|
|
20
23
|
"SnapshotStore",
|
|
21
24
|
"InMemorySnapshotStore",
|
|
22
25
|
"JsonSnapshotStore",
|
|
23
26
|
]
|
|
24
27
|
|
|
25
|
-
|
|
@@ -86,9 +86,24 @@ class Artifact:
|
|
|
86
86
|
return json.loads(self.content.decode("utf-8"))
|
|
87
87
|
|
|
88
88
|
|
|
89
|
-
def compute_artifact_id(content: bytes) -> str:
|
|
90
|
-
"""Compute
|
|
91
|
-
|
|
89
|
+
def compute_artifact_id(content: bytes, *, run_id: Optional[str] = None) -> str:
|
|
90
|
+
"""Compute a deterministic artifact id.
|
|
91
|
+
|
|
92
|
+
By default, artifacts are content-addressed (SHA-256, truncated) so the same bytes
|
|
93
|
+
produce the same id.
|
|
94
|
+
|
|
95
|
+
If `run_id` is provided, the id is *namespaced to that run* to avoid cross-run
|
|
96
|
+
collisions when using a shared `FileArtifactStore(base_dir)` and to preserve
|
|
97
|
+
correct `list_by_run(...)` / purge-by-run semantics.
|
|
98
|
+
"""
|
|
99
|
+
h = hashlib.sha256()
|
|
100
|
+
if run_id is not None:
|
|
101
|
+
rid = str(run_id).strip()
|
|
102
|
+
if rid:
|
|
103
|
+
h.update(rid.encode("utf-8"))
|
|
104
|
+
h.update(b"\0")
|
|
105
|
+
h.update(content)
|
|
106
|
+
return h.hexdigest()[:32]
|
|
92
107
|
|
|
93
108
|
|
|
94
109
|
def validate_artifact_id(artifact_id: str) -> None:
|
|
@@ -318,7 +333,7 @@ class InMemoryArtifactStore(ArtifactStore):
|
|
|
318
333
|
artifact_id: Optional[str] = None,
|
|
319
334
|
) -> ArtifactMetadata:
|
|
320
335
|
if artifact_id is None:
|
|
321
|
-
artifact_id = compute_artifact_id(content)
|
|
336
|
+
artifact_id = compute_artifact_id(content, run_id=run_id)
|
|
322
337
|
|
|
323
338
|
metadata = ArtifactMetadata(
|
|
324
339
|
artifact_id=artifact_id,
|
|
@@ -397,7 +412,7 @@ class FileArtifactStore(ArtifactStore):
|
|
|
397
412
|
artifact_id: Optional[str] = None,
|
|
398
413
|
) -> ArtifactMetadata:
|
|
399
414
|
if artifact_id is None:
|
|
400
|
-
artifact_id = compute_artifact_id(content)
|
|
415
|
+
artifact_id = compute_artifact_id(content, run_id=run_id)
|
|
401
416
|
|
|
402
417
|
metadata = ArtifactMetadata(
|
|
403
418
|
artifact_id=artifact_id,
|
|
@@ -10,6 +10,7 @@ This is meant as a straightforward MVP backend.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import json
|
|
13
|
+
import uuid
|
|
13
14
|
from dataclasses import asdict
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
from typing import Any, Dict, List, Optional
|
|
@@ -36,8 +37,20 @@ class JsonFileRunStore(RunStore):
|
|
|
36
37
|
|
|
37
38
|
def save(self, run: RunState) -> None:
|
|
38
39
|
p = self._path(run.run_id)
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
# Atomic write to prevent corrupted/partial JSON when multiple threads/processes
|
|
41
|
+
# (e.g. WS tick loop + UI pause/cancel) write the same run file concurrently.
|
|
42
|
+
tmp = p.with_name(f"{p.name}.{uuid.uuid4().hex}.tmp")
|
|
43
|
+
try:
|
|
44
|
+
with tmp.open("w", encoding="utf-8") as f:
|
|
45
|
+
json.dump(asdict(run), f, ensure_ascii=False, indent=2)
|
|
46
|
+
tmp.replace(p)
|
|
47
|
+
finally:
|
|
48
|
+
# Best-effort cleanup if replace() failed.
|
|
49
|
+
try:
|
|
50
|
+
if tmp.exists():
|
|
51
|
+
tmp.unlink()
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
41
54
|
|
|
42
55
|
def load(self, run_id: str) -> Optional[RunState]:
|
|
43
56
|
p = self._path(run_id)
|