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.
Files changed (53) hide show
  1. godel/__init__.py +100 -0
  2. godel/__main__.py +3 -0
  3. godel/_config.py +319 -0
  4. godel/_context.py +149 -0
  5. godel/_dag_render.py +274 -0
  6. godel/_decorators.py +1049 -0
  7. godel/_event_log.py +313 -0
  8. godel/_events.py +110 -0
  9. godel/_exceptions.py +468 -0
  10. godel/_formatters.py +203 -0
  11. godel/_guides/__init__.py +30 -0
  12. godel/_guides/api-reference.md +187 -0
  13. godel/_guides/best-practices.md +351 -0
  14. godel/_guides/cli.md +194 -0
  15. godel/_guides/concepts.md +211 -0
  16. godel/_guides/engineer.md +133 -0
  17. godel/_guides/getting-started.md +114 -0
  18. godel/_guides/monitoring.md +202 -0
  19. godel/_guides/runner.md +98 -0
  20. godel/_linter.py +692 -0
  21. godel/_pause.py +146 -0
  22. godel/_redact.py +165 -0
  23. godel/_replay.py +373 -0
  24. godel/_rewind.py +389 -0
  25. godel/_run.py +418 -0
  26. godel/_run_summary.py +145 -0
  27. godel/_stdout_capture.py +171 -0
  28. godel/_strict_ast.py +113 -0
  29. godel/_strict_audit.py +43 -0
  30. godel/_strict_imports.py +49 -0
  31. godel/_tail.py +986 -0
  32. godel/_transcript.py +477 -0
  33. godel/_watch.py +1577 -0
  34. godel/_watch_model.py +364 -0
  35. godel/agents/__init__.py +9 -0
  36. godel/agents/_adapters.py +279 -0
  37. godel/agents/_claude.py +185 -0
  38. godel/agents/_common.py +619 -0
  39. godel/agents/_copilot.py +216 -0
  40. godel/agents/_stream_parser.py +282 -0
  41. godel/cli.py +1609 -0
  42. godel/det.py +231 -0
  43. godel/intervention/__init__.py +47 -0
  44. godel/intervention/_context.py +289 -0
  45. godel/intervention/_tools.py +400 -0
  46. godel/intervention/default_agent.py +383 -0
  47. godel/io.py +688 -0
  48. godel/testing.py +5 -0
  49. godel_py-3.13.2.dist-info/METADATA +82 -0
  50. godel_py-3.13.2.dist-info/RECORD +53 -0
  51. godel_py-3.13.2.dist-info/WHEEL +4 -0
  52. godel_py-3.13.2.dist-info/entry_points.txt +2 -0
  53. 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
@@ -0,0 +1,3 @@
1
+ from godel.cli import main
2
+
3
+ main()
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