godel-py 3.13.2__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.
- godel/__init__.py +100 -0
- godel/__main__.py +3 -0
- godel/_config.py +319 -0
- godel/_context.py +149 -0
- godel/_dag_render.py +274 -0
- godel/_decorators.py +1049 -0
- godel/_event_log.py +313 -0
- godel/_events.py +110 -0
- godel/_exceptions.py +468 -0
- godel/_formatters.py +203 -0
- godel/_guides/__init__.py +30 -0
- godel/_guides/api-reference.md +187 -0
- godel/_guides/best-practices.md +351 -0
- godel/_guides/cli.md +194 -0
- godel/_guides/concepts.md +211 -0
- godel/_guides/engineer.md +133 -0
- godel/_guides/getting-started.md +114 -0
- godel/_guides/monitoring.md +202 -0
- godel/_guides/runner.md +98 -0
- godel/_linter.py +692 -0
- godel/_pause.py +146 -0
- godel/_redact.py +165 -0
- godel/_replay.py +373 -0
- godel/_rewind.py +389 -0
- godel/_run.py +418 -0
- godel/_run_summary.py +145 -0
- godel/_stdout_capture.py +171 -0
- godel/_strict_ast.py +113 -0
- godel/_strict_audit.py +43 -0
- godel/_strict_imports.py +49 -0
- godel/_tail.py +986 -0
- godel/_transcript.py +477 -0
- godel/_watch.py +1577 -0
- godel/_watch_model.py +364 -0
- godel/agents/__init__.py +9 -0
- godel/agents/_adapters.py +279 -0
- godel/agents/_claude.py +185 -0
- godel/agents/_common.py +619 -0
- godel/agents/_copilot.py +216 -0
- godel/agents/_stream_parser.py +282 -0
- godel/cli.py +1609 -0
- godel/det.py +231 -0
- godel/intervention/__init__.py +47 -0
- godel/intervention/_context.py +289 -0
- godel/intervention/_tools.py +400 -0
- godel/intervention/default_agent.py +383 -0
- godel/io.py +688 -0
- godel/testing.py +5 -0
- godel_py-3.13.2.dist-info/METADATA +82 -0
- godel_py-3.13.2.dist-info/RECORD +53 -0
- godel_py-3.13.2.dist-info/WHEEL +4 -0
- godel_py-3.13.2.dist-info/entry_points.txt +2 -0
- godel_py-3.13.2.dist-info/licenses/LICENSE +84 -0
godel/__init__.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Godel — deterministic orchestrator for AI agent workflows."""
|
|
2
|
+
__version__ = "3.13.2"
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def version() -> str:
|
|
9
|
+
"""Return the package version string from pyproject.toml."""
|
|
10
|
+
pyproject = Path(__file__).parent.parent / "pyproject.toml"
|
|
11
|
+
if sys.version_info >= (3, 11):
|
|
12
|
+
import tomllib
|
|
13
|
+
with open(pyproject, "rb") as f:
|
|
14
|
+
data = tomllib.load(f)
|
|
15
|
+
else:
|
|
16
|
+
import tomli # type: ignore[import]
|
|
17
|
+
with open(pyproject, "rb") as f:
|
|
18
|
+
data = tomli.load(f)
|
|
19
|
+
return data["project"]["version"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
from godel._decorators import workflow, step, WorkflowFail, parallel, retry
|
|
23
|
+
from godel._run import run, CommandResult, CommandFailure
|
|
24
|
+
from godel.io import print, input, sleep, read_text, write_text
|
|
25
|
+
from godel._events import Event, EventStatus
|
|
26
|
+
from godel._event_log import EventLog
|
|
27
|
+
from godel._context import get_event_log
|
|
28
|
+
from godel._exceptions import (
|
|
29
|
+
GodelStrictError,
|
|
30
|
+
StrictViolation,
|
|
31
|
+
ResumeError,
|
|
32
|
+
UnsafeResumeError,
|
|
33
|
+
SourceEditedError,
|
|
34
|
+
RewindSignal,
|
|
35
|
+
PauseSignal,
|
|
36
|
+
GodelError,
|
|
37
|
+
AgentRefusal,
|
|
38
|
+
SchemaValidationFailure,
|
|
39
|
+
HumanTimeout,
|
|
40
|
+
NonDeterministicEscape,
|
|
41
|
+
RewindUnsafe,
|
|
42
|
+
GodelWatchNotInstalledError,
|
|
43
|
+
ConfigError,
|
|
44
|
+
StepTimeout,
|
|
45
|
+
)
|
|
46
|
+
from godel._pause import check_pause_request, write_pause_request, clear_pause_request, pause
|
|
47
|
+
from godel._config import load_config, GodelConfig, LoadedConfig, resolve_workflow, list_workflows, open_event_log
|
|
48
|
+
from godel._rewind import rewind
|
|
49
|
+
from godel._tail import tail
|
|
50
|
+
from godel import det
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"workflow",
|
|
54
|
+
"step",
|
|
55
|
+
"WorkflowFail",
|
|
56
|
+
"parallel",
|
|
57
|
+
"retry",
|
|
58
|
+
"run",
|
|
59
|
+
"CommandResult",
|
|
60
|
+
"CommandFailure",
|
|
61
|
+
"print",
|
|
62
|
+
"input",
|
|
63
|
+
"sleep",
|
|
64
|
+
"read_text",
|
|
65
|
+
"write_text",
|
|
66
|
+
"Event",
|
|
67
|
+
"EventStatus",
|
|
68
|
+
"EventLog",
|
|
69
|
+
"get_event_log",
|
|
70
|
+
"GodelStrictError",
|
|
71
|
+
"StrictViolation",
|
|
72
|
+
"ResumeError",
|
|
73
|
+
"UnsafeResumeError",
|
|
74
|
+
"SourceEditedError",
|
|
75
|
+
"RewindSignal", # internal control-flow signal; exported for isinstance checks in tests
|
|
76
|
+
"rewind",
|
|
77
|
+
"det",
|
|
78
|
+
"GodelError",
|
|
79
|
+
"AgentRefusal",
|
|
80
|
+
"SchemaValidationFailure",
|
|
81
|
+
"HumanTimeout",
|
|
82
|
+
"NonDeterministicEscape",
|
|
83
|
+
"RewindUnsafe",
|
|
84
|
+
"GodelWatchNotInstalledError",
|
|
85
|
+
"ConfigError",
|
|
86
|
+
"StepTimeout",
|
|
87
|
+
"PauseSignal",
|
|
88
|
+
"check_pause_request",
|
|
89
|
+
"write_pause_request",
|
|
90
|
+
"clear_pause_request",
|
|
91
|
+
"pause",
|
|
92
|
+
"tail",
|
|
93
|
+
"version",
|
|
94
|
+
"load_config",
|
|
95
|
+
"GodelConfig",
|
|
96
|
+
"LoadedConfig",
|
|
97
|
+
"resolve_workflow",
|
|
98
|
+
"list_workflows",
|
|
99
|
+
"open_event_log",
|
|
100
|
+
]
|
godel/__main__.py
ADDED
godel/_config.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Two-tier config for godel.
|
|
2
|
+
|
|
3
|
+
Project-level: ``<project>/.godel/settings.json`` (committed).
|
|
4
|
+
User-level: ``~/.godel/settings.json`` (+ per-project data under
|
|
5
|
+
``~/.godel/projects/<rel>/``).
|
|
6
|
+
|
|
7
|
+
Precedence (high→low): CLI flag > env var > project > user > default.
|
|
8
|
+
|
|
9
|
+
See ``plans/steady-sniffing-candy.md`` for design notes.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
20
|
+
|
|
21
|
+
from godel._exceptions import ConfigError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
CONFIG_DIR_NAME = ".godel"
|
|
25
|
+
SETTINGS_FILENAME = "settings.json"
|
|
26
|
+
WORKFLOWS_SUBDIR = "workflows"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WatchConfig(BaseModel):
|
|
30
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
31
|
+
default: bool = False
|
|
32
|
+
plain: bool = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GodelConfig(BaseModel):
|
|
36
|
+
"""Frozen merged config. All fields optional with sensible defaults."""
|
|
37
|
+
|
|
38
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
39
|
+
|
|
40
|
+
runs_dir: str | None = None
|
|
41
|
+
workflows_dir: str = f"{CONFIG_DIR_NAME}/{WORKFLOWS_SUBDIR}"
|
|
42
|
+
strict: bool = True
|
|
43
|
+
lint: bool = True
|
|
44
|
+
stream_agents: bool = True
|
|
45
|
+
watch: WatchConfig = Field(default_factory=WatchConfig)
|
|
46
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
47
|
+
redact: list[str] = Field(default_factory=list)
|
|
48
|
+
capture_stdout: bool = False
|
|
49
|
+
transcript_max_bytes: int = 10 * 1024 * 1024
|
|
50
|
+
# NOTE: auto-checkpoint mode is a GODEL_AUTO_CHECKPOINT env var / CLI flag
|
|
51
|
+
# only — intentionally not a GodelConfig field. It's execution-context
|
|
52
|
+
# metadata (how stdin answers are supplied for THIS invocation), not a
|
|
53
|
+
# persistable project setting. godel.io reads os.environ directly.
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class LoadedConfig:
|
|
58
|
+
"""Result of ``load_config``: merged model + provenance."""
|
|
59
|
+
config: GodelConfig
|
|
60
|
+
project_root: Path | None
|
|
61
|
+
sources: list[Path] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def runs_dir(self) -> Path:
|
|
65
|
+
"""Resolved absolute runs directory.
|
|
66
|
+
|
|
67
|
+
If ``config.runs_dir`` is unset, defaults to
|
|
68
|
+
``~/.godel/projects/<rel>/runs`` (or ``./runs`` when no project root
|
|
69
|
+
is known — zero-config programmatic use).
|
|
70
|
+
"""
|
|
71
|
+
if self.config.runs_dir:
|
|
72
|
+
rd = Path(self.config.runs_dir).expanduser()
|
|
73
|
+
if rd.is_absolute():
|
|
74
|
+
return rd
|
|
75
|
+
base = self.project_root if self.project_root else Path.cwd()
|
|
76
|
+
return (base / rd).resolve()
|
|
77
|
+
if self.project_root is None:
|
|
78
|
+
return (Path.cwd() / "runs").resolve()
|
|
79
|
+
return project_data_dir(self.project_root) / "runs"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Directory helpers
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def global_config_dir() -> Path:
|
|
87
|
+
"""``~/.godel/`` (or ``$GODEL_HOME`` if set)."""
|
|
88
|
+
override = os.environ.get("GODEL_HOME")
|
|
89
|
+
if override:
|
|
90
|
+
return Path(override).expanduser().resolve()
|
|
91
|
+
return (Path.home() / CONFIG_DIR_NAME).resolve()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def project_data_dir(project_root: Path) -> Path:
|
|
95
|
+
"""``~/.godel/projects/<rel>/`` for this project.
|
|
96
|
+
|
|
97
|
+
``<rel>`` = project_root with ``$HOME/`` stripped. For projects outside
|
|
98
|
+
``$HOME``, use ``_abs/<sha256(realpath)[:16]>``.
|
|
99
|
+
"""
|
|
100
|
+
project_root = Path(project_root).resolve()
|
|
101
|
+
home = Path.home().resolve()
|
|
102
|
+
try:
|
|
103
|
+
rel = project_root.relative_to(home)
|
|
104
|
+
return global_config_dir() / "projects" / rel
|
|
105
|
+
except ValueError:
|
|
106
|
+
digest = hashlib.sha256(str(project_root).encode()).hexdigest()[:16]
|
|
107
|
+
return global_config_dir() / "projects" / "_abs" / digest
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def find_project_config(cwd: Path | None = None) -> Path | None:
|
|
111
|
+
"""Walk up from *cwd* looking for a ``.godel/`` directory.
|
|
112
|
+
|
|
113
|
+
Stops at first of: filesystem root, ``$HOME``, or a dir containing ``.git/``
|
|
114
|
+
(that dir is still checked for ``.godel/`` before stopping). Returns the
|
|
115
|
+
path of the ``.godel/`` dir, or ``None`` if none found.
|
|
116
|
+
"""
|
|
117
|
+
start = Path(cwd).resolve() if cwd is not None else Path.cwd().resolve()
|
|
118
|
+
home = Path.home().resolve()
|
|
119
|
+
|
|
120
|
+
current = start
|
|
121
|
+
while True:
|
|
122
|
+
candidate = current / CONFIG_DIR_NAME
|
|
123
|
+
if candidate.is_dir():
|
|
124
|
+
return candidate
|
|
125
|
+
|
|
126
|
+
# Stop conditions (check *after* examining current dir):
|
|
127
|
+
if current == current.parent:
|
|
128
|
+
return None # filesystem root
|
|
129
|
+
if current == home:
|
|
130
|
+
return None
|
|
131
|
+
if (current / ".git").exists():
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
current = current.parent
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Loading
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
_BOOL_ENV_KEYS = {
|
|
142
|
+
"GODEL_STRICT": "strict",
|
|
143
|
+
"GODEL_LINT": "lint",
|
|
144
|
+
"GODEL_STREAM_AGENTS": "stream_agents",
|
|
145
|
+
"GODEL_CAPTURE_STDOUT": "capture_stdout",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
_STR_ENV_KEYS = {
|
|
149
|
+
"GODEL_RUNS_DIR": "runs_dir",
|
|
150
|
+
"GODEL_WORKFLOWS_DIR": "workflows_dir",
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _read_json(path: Path) -> dict:
|
|
155
|
+
try:
|
|
156
|
+
with open(path) as f:
|
|
157
|
+
data = json.load(f)
|
|
158
|
+
except json.JSONDecodeError as exc:
|
|
159
|
+
raise ConfigError(f"{path}: malformed JSON: {exc}") from exc
|
|
160
|
+
if not isinstance(data, dict):
|
|
161
|
+
raise ConfigError(f"{path}: top-level JSON must be an object")
|
|
162
|
+
return data
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _parse_bool(raw: str) -> bool:
|
|
166
|
+
return raw.strip().lower() not in ("0", "false", "no", "")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _env_overrides() -> dict:
|
|
170
|
+
out: dict = {}
|
|
171
|
+
for env_key, field_name in _STR_ENV_KEYS.items():
|
|
172
|
+
v = os.environ.get(env_key)
|
|
173
|
+
if v: # ignore unset and empty-string
|
|
174
|
+
out[field_name] = v
|
|
175
|
+
for env_key, field_name in _BOOL_ENV_KEYS.items():
|
|
176
|
+
v = os.environ.get(env_key)
|
|
177
|
+
if v is not None and v != "":
|
|
178
|
+
out[field_name] = _parse_bool(v)
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _merge(base: dict, overlay: dict) -> dict:
|
|
183
|
+
"""Shallow merge; nested dicts (watch, env) shallow-merged too."""
|
|
184
|
+
out = dict(base)
|
|
185
|
+
for k, v in overlay.items():
|
|
186
|
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
|
187
|
+
merged = dict(out[k])
|
|
188
|
+
merged.update(v)
|
|
189
|
+
out[k] = merged
|
|
190
|
+
else:
|
|
191
|
+
out[k] = v
|
|
192
|
+
return out
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
_cache: dict[tuple, "LoadedConfig"] = {}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def load_config(cwd: Path | str | None = None, *, use_cache: bool = True) -> LoadedConfig:
|
|
199
|
+
"""Load merged config.
|
|
200
|
+
|
|
201
|
+
Layers (low→high): built-in defaults < ``~/.godel/settings.json`` <
|
|
202
|
+
``<project>/.godel/settings.json`` < ``GODEL_*`` env vars. CLI flags are
|
|
203
|
+
applied by callers on top of the returned model.
|
|
204
|
+
|
|
205
|
+
Memoized on ``(realpath(cwd), frozen env snapshot)`` — deep/NFS paths hit
|
|
206
|
+
the filesystem once per invocation.
|
|
207
|
+
"""
|
|
208
|
+
cwd_path = Path(cwd).resolve() if cwd else Path.cwd().resolve()
|
|
209
|
+
|
|
210
|
+
env_snapshot = tuple(
|
|
211
|
+
sorted(
|
|
212
|
+
(k, os.environ[k])
|
|
213
|
+
for k in (*_BOOL_ENV_KEYS, *_STR_ENV_KEYS, "GODEL_HOME", "HOME")
|
|
214
|
+
if k in os.environ
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
cache_key = (str(cwd_path), env_snapshot)
|
|
218
|
+
if use_cache and cache_key in _cache:
|
|
219
|
+
return _cache[cache_key]
|
|
220
|
+
|
|
221
|
+
sources: list[Path] = []
|
|
222
|
+
merged: dict = {}
|
|
223
|
+
|
|
224
|
+
user_settings = global_config_dir() / SETTINGS_FILENAME
|
|
225
|
+
if user_settings.is_file():
|
|
226
|
+
merged = _merge(merged, _read_json(user_settings))
|
|
227
|
+
sources.append(user_settings)
|
|
228
|
+
|
|
229
|
+
project_dir = find_project_config(cwd_path)
|
|
230
|
+
project_root = project_dir.parent if project_dir else None
|
|
231
|
+
if project_dir:
|
|
232
|
+
project_settings = project_dir / SETTINGS_FILENAME
|
|
233
|
+
if project_settings.is_file():
|
|
234
|
+
merged = _merge(merged, _read_json(project_settings))
|
|
235
|
+
sources.append(project_settings)
|
|
236
|
+
|
|
237
|
+
merged = _merge(merged, _env_overrides())
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
cfg = GodelConfig(**merged)
|
|
241
|
+
except Exception as exc:
|
|
242
|
+
raise ConfigError(f"invalid config: {exc}") from exc
|
|
243
|
+
|
|
244
|
+
loaded = LoadedConfig(config=cfg, project_root=project_root, sources=sources)
|
|
245
|
+
if use_cache:
|
|
246
|
+
_cache[cache_key] = loaded
|
|
247
|
+
return loaded
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def clear_cache() -> None:
|
|
251
|
+
"""Clear the ``load_config`` memo table. Primarily for tests."""
|
|
252
|
+
_cache.clear()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# Workflow resolution
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
def _workflow_search_dirs(loaded: LoadedConfig) -> list[Path]:
|
|
260
|
+
dirs: list[Path] = []
|
|
261
|
+
if loaded.project_root:
|
|
262
|
+
p = Path(loaded.config.workflows_dir)
|
|
263
|
+
if not p.is_absolute():
|
|
264
|
+
p = loaded.project_root / p
|
|
265
|
+
dirs.append(p)
|
|
266
|
+
dirs.append(global_config_dir() / WORKFLOWS_SUBDIR)
|
|
267
|
+
return dirs
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def resolve_workflow(name_or_path: str, loaded: LoadedConfig) -> Path:
|
|
271
|
+
"""Resolve an arg to a workflow file path.
|
|
272
|
+
|
|
273
|
+
1. Existing file path → returned as-is (today's behavior).
|
|
274
|
+
2. Otherwise treat as a name; search project then user ``workflows/`` dir
|
|
275
|
+
for ``<name>.py``.
|
|
276
|
+
3. Raise ``ConfigError`` listing available names if not found.
|
|
277
|
+
"""
|
|
278
|
+
p = Path(name_or_path)
|
|
279
|
+
if p.is_file():
|
|
280
|
+
return p.resolve()
|
|
281
|
+
|
|
282
|
+
for base in _workflow_search_dirs(loaded):
|
|
283
|
+
candidate = base / f"{name_or_path}.py"
|
|
284
|
+
if candidate.is_file():
|
|
285
|
+
return candidate.resolve()
|
|
286
|
+
|
|
287
|
+
available = sorted(list_workflows(loaded).keys())
|
|
288
|
+
hint = f" available: {', '.join(available)}" if available else " (no workflows registered)"
|
|
289
|
+
raise ConfigError(
|
|
290
|
+
f"workflow not found: {name_or_path!r}\n{hint}"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def open_event_log(run_id: str, *, cwd: Path | str | None = None):
|
|
295
|
+
"""Open an existing ``EventLog`` for *run_id* using the configured runs dir.
|
|
296
|
+
|
|
297
|
+
Convenience for programmatic callers so they don't hand-wire
|
|
298
|
+
``EventLog.load(run_id, runs_dir=str(load_config().runs_dir))``.
|
|
299
|
+
"""
|
|
300
|
+
from godel._event_log import EventLog
|
|
301
|
+
loaded = load_config(cwd)
|
|
302
|
+
return EventLog.load(run_id, runs_dir=str(loaded.runs_dir))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def list_workflows(loaded: LoadedConfig) -> dict[str, Path]:
|
|
306
|
+
"""Return name → path for every ``.py`` file in the search dirs.
|
|
307
|
+
|
|
308
|
+
Project workflows shadow user workflows on name collision.
|
|
309
|
+
"""
|
|
310
|
+
found: dict[str, Path] = {}
|
|
311
|
+
# Walk in reverse so earlier (project) entries overwrite later (user) ones.
|
|
312
|
+
for base in reversed(_workflow_search_dirs(loaded)):
|
|
313
|
+
if not base.is_dir():
|
|
314
|
+
continue
|
|
315
|
+
for f in base.glob("*.py"):
|
|
316
|
+
if f.name.startswith("_"):
|
|
317
|
+
continue
|
|
318
|
+
found[f.stem] = f.resolve()
|
|
319
|
+
return found
|
godel/_context.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextvars import ContextVar
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from godel._event_log import EventLog
|
|
9
|
+
from godel._replay import ReplayWalker
|
|
10
|
+
from godel._transcript import TranscriptWriter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class WorkflowContext:
|
|
15
|
+
run_id: str
|
|
16
|
+
step_stack: list[str] = field(default_factory=list)
|
|
17
|
+
event_log: "EventLog | None" = None
|
|
18
|
+
_invocation_counts: dict = field(default_factory=dict)
|
|
19
|
+
_step_local_seq: dict = field(default_factory=dict)
|
|
20
|
+
replay_walker: "ReplayWalker | None" = None
|
|
21
|
+
source_file: str = ""
|
|
22
|
+
_event_id_stack: list[str] = field(default_factory=list)
|
|
23
|
+
# Observability: whether agent stdout is streamed to the transcript.
|
|
24
|
+
# Defaults to True; disabled by setting GODEL_STREAM_AGENTS=0 in the env
|
|
25
|
+
# (typically via `godel run --no-stream`).
|
|
26
|
+
stream_agents: bool = True
|
|
27
|
+
# Advisory transcript writer (None outside @workflow or when streaming disabled).
|
|
28
|
+
transcript: "TranscriptWriter | None" = None
|
|
29
|
+
# Ordered list of event_ids for every @step that reached FINISHED.
|
|
30
|
+
# Populated by the @step decorator; used by last_step_event_id().
|
|
31
|
+
# NOTE (WARN-2): when steps run inside parallel(), branches append to this
|
|
32
|
+
# shared list in asyncio task-scheduling order, which is non-deterministic.
|
|
33
|
+
# Do not rely on the absolute positions of parallel-branch entries — only
|
|
34
|
+
# use last_step_event_id() from sequential (non-parallel) step boundaries.
|
|
35
|
+
_step_event_history: list[str] = field(default_factory=list)
|
|
36
|
+
# Per-branch replay suppress flag. Mirrors event_log._replay_suppress but
|
|
37
|
+
# is scoped to THIS context only (not shared across parallel branches).
|
|
38
|
+
# Set to True when suppress is active at context creation / branch entry.
|
|
39
|
+
# Cleared to False when THIS branch (or a child of it) exits replay mode.
|
|
40
|
+
# Used by @step to correctly choose cached vs ephemeral event_id even when
|
|
41
|
+
# a sibling parallel branch has already cleared the shared event_log flag.
|
|
42
|
+
_local_replay_suppress: bool = False
|
|
43
|
+
|
|
44
|
+
def last_step_event_id(self, n: int = 1) -> str:
|
|
45
|
+
"""Return the n-th most recent completed step event_id.
|
|
46
|
+
|
|
47
|
+
n=1 returns the most recently completed step, n=2 returns the one
|
|
48
|
+
before that, etc.
|
|
49
|
+
|
|
50
|
+
During replay the returned ID is the *original persisted* event_id
|
|
51
|
+
from the cached log, not the ephemeral event_id emitted during replay
|
|
52
|
+
re-execution. This means the returned ID is always a valid key in
|
|
53
|
+
the persistent audit log regardless of whether the workflow is running
|
|
54
|
+
fresh or resuming.
|
|
55
|
+
|
|
56
|
+
Parallel-step caveat (WARN-2): steps that execute inside parallel()
|
|
57
|
+
append to the shared history in asyncio task-scheduling order, which
|
|
58
|
+
is non-deterministic. Only use this method from sequential (non-
|
|
59
|
+
parallel) step boundaries if you need stable positional semantics.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ValueError: if n < 1
|
|
63
|
+
IndexError: if fewer than n steps have reached their FINISHED
|
|
64
|
+
boundary (i.e. not enough completed steps in history yet).
|
|
65
|
+
"""
|
|
66
|
+
if n < 1:
|
|
67
|
+
raise ValueError(f"n must be >= 1, got {n!r}")
|
|
68
|
+
history = self._step_event_history
|
|
69
|
+
if n > len(history):
|
|
70
|
+
raise IndexError(
|
|
71
|
+
f"Only {len(history)} step(s) have reached FINISHED in this "
|
|
72
|
+
f"workflow run so far; cannot retrieve n={n}"
|
|
73
|
+
)
|
|
74
|
+
return history[-n]
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def current_parent_event_id(self) -> str | None:
|
|
78
|
+
"""Return the event_id of the innermost enclosing scope (step, fork, or workflow)."""
|
|
79
|
+
return self._event_id_stack[-1] if self._event_id_stack else None
|
|
80
|
+
|
|
81
|
+
def push_event_scope(self, event_id: str) -> None:
|
|
82
|
+
self._event_id_stack.append(event_id)
|
|
83
|
+
|
|
84
|
+
def pop_event_scope(self) -> str | None:
|
|
85
|
+
return self._event_id_stack.pop() if self._event_id_stack else None
|
|
86
|
+
|
|
87
|
+
def next_op_position(self) -> tuple[int, int]:
|
|
88
|
+
"""Return (invocation_seq, step_local_seq) for the next leaf operation.
|
|
89
|
+
|
|
90
|
+
The invocation_seq is the enclosing step's count (before increment).
|
|
91
|
+
The step_local_seq auto-increments so consecutive ops get distinct keys.
|
|
92
|
+
"""
|
|
93
|
+
step_path = tuple(self.step_stack)
|
|
94
|
+
# The @step decorator sets _invocation_counts[path] = old + 1,
|
|
95
|
+
# so the step's own invocation_seq (old) = current value - 1.
|
|
96
|
+
inv = max(0, self._invocation_counts.get(step_path, 1) - 1)
|
|
97
|
+
local_seq = self._step_local_seq.get(step_path, 0)
|
|
98
|
+
self._step_local_seq[step_path] = local_seq + 1
|
|
99
|
+
return inv, local_seq
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_current_workflow: ContextVar[WorkflowContext | None] = ContextVar(
|
|
103
|
+
"godel_workflow", default=None
|
|
104
|
+
)
|
|
105
|
+
_privileged: ContextVar[bool] = ContextVar("godel_privileged", default=False)
|
|
106
|
+
_pending_replay: ContextVar = ContextVar("godel_pending_replay", default=None)
|
|
107
|
+
_on_run_start: ContextVar = ContextVar("godel_on_run_start", default=None)
|
|
108
|
+
|
|
109
|
+
# _current_stream_path tracks the nesting path of subprocess/agent launches.
|
|
110
|
+
# Each launch site reads this contextvar on the *launching coroutine* (or
|
|
111
|
+
# thread, for any future thread-pool dispatch) to compute the parent path,
|
|
112
|
+
# then appends a fresh ULID to form the child path. The child path is
|
|
113
|
+
# stamped onto the Event by value at launch time — downstream consumers
|
|
114
|
+
# reading the persisted log never query the contextvar. This is the ONLY
|
|
115
|
+
# contextvar used for stream-path propagation; cross-thread propagation (if
|
|
116
|
+
# ever needed) must use contextvars.copy_context() + ctx.run(fn).
|
|
117
|
+
_current_stream_path: ContextVar[list[str]] = ContextVar(
|
|
118
|
+
"godel_stream_path", default=[]
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# _current_transcript holds the active TranscriptWriter for the current workflow
|
|
122
|
+
# run, or None when no transcript is open. Set by the @workflow / @step
|
|
123
|
+
# decorators when capture_stdout=True is active. @step decorators that opt
|
|
124
|
+
# into capture_stdout read this to find the shared per-run writer.
|
|
125
|
+
_current_transcript: ContextVar = ContextVar(
|
|
126
|
+
"godel_transcript", default=None
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# _line_observer is set by agent wrappers to intercept subprocess stdout
|
|
130
|
+
# line-by-line for real-time stream classification. When set, the observer
|
|
131
|
+
# callable receives each raw line (bytes, including the trailing newline) as
|
|
132
|
+
# it arrives from the subprocess pipe. The observer owns the line — run()
|
|
133
|
+
# suppresses the default raw "stdout" transcript event when an observer is
|
|
134
|
+
# active. Same contextvar pattern as _privileged and _current_stream_path:
|
|
135
|
+
# set/reset around the run() call site using ContextVar.set() + .reset().
|
|
136
|
+
_line_observer: ContextVar = ContextVar("godel_line_observer", default=None)
|
|
137
|
+
|
|
138
|
+
# _step_idempotent is True when the enclosing @step was decorated with
|
|
139
|
+
# idempotent=True. run() and agent.__call__() read this to decide whether
|
|
140
|
+
# a STARTED-only log entry is safe to re-execute.
|
|
141
|
+
_step_idempotent: ContextVar[bool] = ContextVar("godel_step_idempotent", default=False)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_event_log():
|
|
145
|
+
"""Retrieve the EventLog from the current workflow context."""
|
|
146
|
+
ctx = _current_workflow.get()
|
|
147
|
+
if ctx is None or ctx.event_log is None:
|
|
148
|
+
raise RuntimeError("get_event_log() called outside a @workflow")
|
|
149
|
+
return ctx.event_log
|