keelrun 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. keelrun-0.1.0/PKG-INFO +10 -0
  2. keelrun-0.1.0/README.md +92 -0
  3. keelrun-0.1.0/pyproject.toml +40 -0
  4. keelrun-0.1.0/setup.cfg +4 -0
  5. keelrun-0.1.0/src/keel/__init__.py +26 -0
  6. keelrun-0.1.0/src/keel/__main__.py +7 -0
  7. keelrun-0.1.0/src/keel/_backend.py +149 -0
  8. keelrun-0.1.0/src/keel/_defaults.py +116 -0
  9. keelrun-0.1.0/src/keel/_discovery.py +368 -0
  10. keelrun-0.1.0/src/keel/_errors.py +31 -0
  11. keelrun-0.1.0/src/keel/_flow.py +327 -0
  12. keelrun-0.1.0/src/keel/_hook.py +156 -0
  13. keelrun-0.1.0/src/keel/_policy.py +136 -0
  14. keelrun-0.1.0/src/keel/_record.py +266 -0
  15. keelrun-0.1.0/src/keel/_run.py +131 -0
  16. keelrun-0.1.0/src/keel/_runtime.py +62 -0
  17. keelrun-0.1.0/src/keel/_sim.py +268 -0
  18. keelrun-0.1.0/src/keel/_targets.py +214 -0
  19. keelrun-0.1.0/src/keel/_wrap.py +157 -0
  20. keelrun-0.1.0/src/keel/adapters/__init__.py +178 -0
  21. keelrun-0.1.0/src/keel/adapters/_http.py +527 -0
  22. keelrun-0.1.0/src/keel/adapters/_llm_policy.py +290 -0
  23. keelrun-0.1.0/src/keel/adapters/_pack.py +53 -0
  24. keelrun-0.1.0/src/keel/adapters/aiohttp_pack.py +437 -0
  25. keelrun-0.1.0/src/keel/adapters/boto3_pack.py +360 -0
  26. keelrun-0.1.0/src/keel/adapters/httpx_pack.py +653 -0
  27. keelrun-0.1.0/src/keel/adapters/psycopg_pack.py +317 -0
  28. keelrun-0.1.0/src/keel/adapters/requests_pack.py +390 -0
  29. keelrun-0.1.0/src/keel/adapters/urllib3_pack.py +342 -0
  30. keelrun-0.1.0/src/keel/bootstrap.py +210 -0
  31. keelrun-0.1.0/src/keel/packs/__init__.py +111 -0
  32. keelrun-0.1.0/src/keel/packs/_framework.py +66 -0
  33. keelrun-0.1.0/src/keel/packs/_provider.py +95 -0
  34. keelrun-0.1.0/src/keel/packs/adk_pack.py +356 -0
  35. keelrun-0.1.0/src/keel/packs/anthropic_pack.py +41 -0
  36. keelrun-0.1.0/src/keel/packs/crewai_pack.py +243 -0
  37. keelrun-0.1.0/src/keel/packs/google_genai_pack.py +76 -0
  38. keelrun-0.1.0/src/keel/packs/langgraph_pack.py +590 -0
  39. keelrun-0.1.0/src/keel/packs/llm.py +145 -0
  40. keelrun-0.1.0/src/keel/packs/mcp_pack.py +485 -0
  41. keelrun-0.1.0/src/keel/packs/openai_agents_pack.py +208 -0
  42. keelrun-0.1.0/src/keel/packs/openai_pack.py +41 -0
  43. keelrun-0.1.0/src/keel/packs/pydantic_ai_pack.py +192 -0
  44. keelrun-0.1.0/src/keel/packs/tool.py +307 -0
  45. keelrun-0.1.0/src/keel/testing.py +181 -0
  46. keelrun-0.1.0/src/keel_core_stub/__init__.py +744 -0
  47. keelrun-0.1.0/src/keelrun.egg-info/PKG-INFO +10 -0
  48. keelrun-0.1.0/src/keelrun.egg-info/SOURCES.txt +90 -0
  49. keelrun-0.1.0/src/keelrun.egg-info/dependency_links.txt +1 -0
  50. keelrun-0.1.0/src/keelrun.egg-info/entry_points.txt +2 -0
  51. keelrun-0.1.0/src/keelrun.egg-info/requires.txt +5 -0
  52. keelrun-0.1.0/src/keelrun.egg-info/top_level.txt +2 -0
  53. keelrun-0.1.0/tests/test_adapters_aiohttp.py +357 -0
  54. keelrun-0.1.0/tests/test_adapters_boto3.py +354 -0
  55. keelrun-0.1.0/tests/test_adapters_disable.py +159 -0
  56. keelrun-0.1.0/tests/test_adapters_http.py +249 -0
  57. keelrun-0.1.0/tests/test_adapters_httpx.py +281 -0
  58. keelrun-0.1.0/tests/test_adapters_psycopg.py +293 -0
  59. keelrun-0.1.0/tests/test_adapters_requests.py +213 -0
  60. keelrun-0.1.0/tests/test_adapters_urllib3.py +233 -0
  61. keelrun-0.1.0/tests/test_demos.py +90 -0
  62. keelrun-0.1.0/tests/test_discovery.py +311 -0
  63. keelrun-0.1.0/tests/test_flaky_demo.py +45 -0
  64. keelrun-0.1.0/tests/test_flows.py +727 -0
  65. keelrun-0.1.0/tests/test_hook.py +90 -0
  66. keelrun-0.1.0/tests/test_journal_policy.py +85 -0
  67. keelrun-0.1.0/tests/test_layer_resolution.py +201 -0
  68. keelrun-0.1.0/tests/test_llm_budget_fallback.py +192 -0
  69. keelrun-0.1.0/tests/test_llm_policy.py +155 -0
  70. keelrun-0.1.0/tests/test_native_adapters.py +203 -0
  71. keelrun-0.1.0/tests/test_packs_adk.py +335 -0
  72. keelrun-0.1.0/tests/test_packs_crewai.py +309 -0
  73. keelrun-0.1.0/tests/test_packs_langgraph.py +581 -0
  74. keelrun-0.1.0/tests/test_packs_llm.py +337 -0
  75. keelrun-0.1.0/tests/test_packs_llm_e2e.py +183 -0
  76. keelrun-0.1.0/tests/test_packs_mcp.py +500 -0
  77. keelrun-0.1.0/tests/test_packs_openai_agents.py +249 -0
  78. keelrun-0.1.0/tests/test_packs_pydantic_ai.py +241 -0
  79. keelrun-0.1.0/tests/test_packs_tool.py +303 -0
  80. keelrun-0.1.0/tests/test_persistent_cache.py +79 -0
  81. keelrun-0.1.0/tests/test_policy.py +92 -0
  82. keelrun-0.1.0/tests/test_record.py +175 -0
  83. keelrun-0.1.0/tests/test_record_capture.py +73 -0
  84. keelrun-0.1.0/tests/test_resume_demo.py +165 -0
  85. keelrun-0.1.0/tests/test_run.py +237 -0
  86. keelrun-0.1.0/tests/test_sim.py +234 -0
  87. keelrun-0.1.0/tests/test_stub_validation.py +224 -0
  88. keelrun-0.1.0/tests/test_targets.py +219 -0
  89. keelrun-0.1.0/tests/test_testing.py +147 -0
  90. keelrun-0.1.0/tests/test_vendored_stub_parity.py +30 -0
  91. keelrun-0.1.0/tests/test_wrap.py +163 -0
  92. keelrun-0.1.0/tests/test_wrap_native.py +138 -0
keelrun-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: keelrun
3
+ Version: 0.1.0
4
+ Summary: Keel — resilience as a zero-config Python front end (Tier 1 bootstrap, import hook, discovery).
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: keelrun-core==0.1.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: httpx<0.29,>=0.27; extra == "dev"
10
+ Requires-Dist: requests<2.33,>=2.31; extra == "dev"
@@ -0,0 +1,92 @@
1
+ # keel (Python front end)
2
+
3
+ Production-grade resilience for any Python program, with **zero code changes**.
4
+ `keel run app.py` intercepts your outbound calls (httpx, requests, aiohttp,
5
+ urllib3, boto3, psycopg, LLM SDKs via `llm:` packs, `tool:`/`mcp:` targets,
6
+ agent-framework packs, and `py:` function targets) and applies retries, backoff,
7
+ timeouts, circuit breakers, rate limits, and an optional dev-mode response
8
+ cache — all declared in one `keel.toml`, enforced by the native Rust core
9
+ inside your process. No daemon, no port, no login.
10
+
11
+ ```
12
+ $ cd python/keel && pip install -e . && keelrun-py-run app.py
13
+ keel ▸ wrapped 14 call sites (httpx ×9, openai ×4) with production defaults — `keel init` to customize
14
+ ```
15
+
16
+ (Not yet published to any registry; run from source. Published name will be
17
+ `keelrun` — see `docs/naming-decision.md`.)
18
+
19
+ Uninstalling Keel removes the behavior and nothing else: your code runs
20
+ identically (minus resilience). No imports, no context objects, no base classes.
21
+
22
+ ## Backends
23
+
24
+ Keel resolves a backend at startup (`KEEL_BACKEND=auto|native|stub`):
25
+
26
+ - **native** (`keel_core`) — the PyO3 module bundling the Rust core. Required
27
+ for the persistent dev cache and for Tier 2 durable flows. Built from
28
+ `crates/keel-py` (`maturin develop`); prebuilt wheels are not published yet.
29
+ - **stub** — a pure-Python core (the conformance reference). Tier 1 semantics
30
+ only; no journal, so no persistent cache and no flows.
31
+
32
+ `auto` (the default) uses the native core when importable, else the stub.
33
+
34
+ ## Tier 2 — durable flows (Level 2)
35
+
36
+ Designate an entrypoint in `keel.toml` and `keel run` executes it as a durable
37
+ flow: every intercepted call inside is journaled, and a rerun after a crash
38
+ substitutes already-completed steps from the journal instead of re-firing them.
39
+
40
+ ```toml
41
+ [flows]
42
+ entrypoints = ["py:pipeline:main"] # module `pipeline`, function `main`
43
+ ```
44
+
45
+ Crash it mid-run (`kill -9`), re-run the same command, and it resumes from where
46
+ it stopped. `keel flows` shows resumable/completed flows; `keel trace <flow>`
47
+ shows the step ledger.
48
+
49
+ ### v0.1 limitations (precise, never silent)
50
+
51
+ Durability is a promise; a silent downgrade would be a Level 0 surprise. So this
52
+ is a hard, actionable error rather than a quiet fallback:
53
+
54
+ - **A journal is required.** Tier 2 replay lives in `.keel/journal.db`. If the
55
+ native core can't attach one (e.g. `KEEL_JOURNAL=""` or an unwritable dir), a
56
+ designated flow fails at startup with a config-level **KEEL-E005**
57
+ (unsupported-configuration) naming the cause and fix — it does not run
58
+ un-journaled. (The stub backend, which has no
59
+ journal at all, reports the same class of error, pointing you at the native
60
+ core.)
61
+
62
+ This is enforced before any effect fires.
63
+
64
+ ### Async flow bodies
65
+
66
+ An `async def` flow entrypoint is supported: `keel run` drives it with
67
+ `asyncio.run`, and its `await`ed intercepted calls route through the **same**
68
+ open flow handle a synchronous flow's calls use — journaled and replayed
69
+ identically, with full Tier 1 resilience per step. Concurrent awaited effects
70
+ inside one flow (`asyncio.gather`) are admitted — and therefore journaled — in
71
+ the order their calls *reach* the flow handle, never in completion order, so
72
+ replay always reproduces the same step sequence. Keep fan-out order
73
+ deterministic (await sequentially, or fan out in a fixed, data-independent
74
+ order): if the runtime reaches the handle in a different order on resume
75
+ (racing tasks whose scheduling differs run-to-run), that is nondeterminism,
76
+ handled per `flows.on_nondeterminism` like any other divergence.
77
+
78
+ ## Errors
79
+
80
+ Every Keel error carries a stable `KEEL-E0NN` code (see `keel explain <code>`),
81
+ a human first line (what / why / next), and — for machines — a `--json` twin on
82
+ the CLI. On terminal failure the original exception propagates unchanged, with a
83
+ `keel_outcome` attachment for those who look.
84
+
85
+ ## Testing
86
+
87
+ ```
88
+ python3 -m unittest discover python/keel # front-end suite (stub; native legs run if keel_core is built)
89
+ ```
90
+
91
+ Native-only tests (flows, persistent cache, native adapters) skip cleanly when
92
+ `keel_core` is not built.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ # Published as `keelrun` (docs/naming-decision.md); the IMPORT name stays
7
+ # `keel` (see [tool.setuptools.packages.find] below) — only the PyPI
8
+ # distribution name changes.
9
+ name = "keelrun"
10
+ license = "Apache-2.0"
11
+ version = "0.1.0"
12
+ description = "Keel — resilience as a zero-config Python front end (Tier 1 bootstrap, import hook, discovery)."
13
+ requires-python = ">=3.11"
14
+ # The native core wheel (import name `keel_core`, distribution name
15
+ # `keelrun-core`), version-locked to this front end. When it is absent at
16
+ # runtime (exotic platform, failed install), the vendored pure-Python stub
17
+ # (`keel_core_stub`, shipped in this wheel and byte-identical to
18
+ # python/keel-core-stub — checked by scripts/check-release-metadata.sh) takes
19
+ # over, so there is never a compile. Beyond that, runtime is stdlib-only:
20
+ # adapters activate only when their library is already present in the
21
+ # environment, and never import an absent one.
22
+ dependencies = ["keelrun-core==0.1.0"]
23
+
24
+ # Test-only dependencies (the adapter contract-test farm). Install with
25
+ # `pip install -e '.[dev]'` or `uv pip install httpx requests`. The library
26
+ # versions the packs certify are pinned in each pack's `_PINNED`; these ranges
27
+ # track them. Development for Task 10 used httpx 0.28.1 + requests 2.32.5 on
28
+ # CPython 3.14 (system interpreter).
29
+ [project.optional-dependencies]
30
+ dev = ["httpx>=0.27,<0.29", "requests>=2.31,<2.33"]
31
+
32
+ # Internal run entry. The public `keel run` CLI (Task 8) dispatches here for
33
+ # Python entrypoints; `keelrun-py-run app.py [args...]` runs a script directly.
34
+ [project.scripts]
35
+ keelrun-py-run = "keel._run:main_run_entry"
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
39
+ # `keel*` also picks up the vendored `keel_core_stub` fallback (see above).
40
+ include = ["keel*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,26 @@
1
+ """Keel — the Python front end (Tier 1: resilience, zero code changes).
2
+
3
+ `keel run app.py` installs, before user code loads: (1) a `sys.meta_path`
4
+ import hook that wraps functions matching `py:` policy targets, and (2)
5
+ per-call discovery recording to `.keel/discovery.db`. The resilience
6
+ semantics live in the backend (`keel_core` native module when importable,
7
+ else the in-repo pure-Python `keel_core_stub`); this package is the thin,
8
+ stdlib-only front end that drives it.
9
+
10
+ DX invariants (docs/dx-spec.md §3) this package must uphold:
11
+ 1. zero code changes in user code;
12
+ 2. uninstall = remove the package (no lock-in): see `uninstall_keel`;
13
+ 4. silence on success (one stderr banner at startup, then quiet);
14
+ 5. never swallow errors — the original exception propagates unchanged,
15
+ with a `keel_outcome` attachment for those who look;
16
+ and KEEL_DISABLE=1 makes a run byte-identical to one without Keel.
17
+
18
+ The public entry points are `keel.bootstrap.install_keel` /
19
+ `uninstall_keel` and the `keel._run` runner used by `python -m keel run`.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ __all__ = ["__version__"]
25
+
26
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ """`python -m keel run app.py [args...]` — the module entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._run import main_module
6
+
7
+ main_module()
@@ -0,0 +1,149 @@
1
+ """Backend selection, isolated behind one seam (architecture §5.1, "the swap").
2
+
3
+ A backend exposes `configure(policy)`, `execute(request, effect)`, and
4
+ `report()` — the four-operation core surface the stub defines (advance_clock
5
+ is test-only and unused here). Selection:
6
+
7
+ * native (`keel_core`) — the eventual PyO3 module (Task 6/14); probed by
8
+ import and required to expose `configure`/`execute`. It may not exist
9
+ yet, so a failed import is normal.
10
+ * stub (`keel_core_stub.KeelCoreStub`) — the in-repo pure-Python core.
11
+
12
+ `KEEL_BACKEND` overrides selection:
13
+ * `stub` → force the stub, never probe native
14
+ * `native` → require `keel_core`; raise KEEL-E040 if not loadable
15
+ * `auto` / unset → native if loadable, else the stub
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import importlib
21
+ import os
22
+ from pathlib import Path
23
+ from typing import Any, Mapping, Protocol, runtime_checkable
24
+
25
+ from ._errors import KeelError
26
+
27
+
28
+ @runtime_checkable
29
+ class Backend(Protocol):
30
+ def configure(self, policy: dict[str, Any]) -> None: ...
31
+
32
+ def execute(self, request: dict[str, Any], effect: Any) -> dict[str, Any]: ...
33
+
34
+ def report(self) -> dict[str, Any]: ...
35
+
36
+
37
+ class _NativeBackend:
38
+ """Front-end adapter over the native ``keel_core`` core, adding one thing the
39
+ PyO3 surface does not expose: ``layer(target, key)``, resolved from the
40
+ configured policy exactly as the stub's ``_layer`` / Node's
41
+ ``NativeBackend.layer`` do. The adapter packs read ``backend.layer`` to honor
42
+ ``idempotency.header`` and gate cache-body buffering; without this the knob is
43
+ dead under native (a Python/Node parity break). Every other attribute
44
+ (``execute``/``execute_async``/``report``/``persistent``/the flow surface)
45
+ delegates straight through, so the swap is transparent."""
46
+
47
+ def __init__(self, core: Any) -> None:
48
+ self._core = core
49
+ self._policy: dict[str, Any] = {}
50
+
51
+ def configure(self, policy: dict[str, Any]) -> None:
52
+ self._policy = policy if isinstance(policy, dict) else {}
53
+ self._core.configure(policy)
54
+
55
+ def layer(self, target: str, key: str) -> Any:
56
+ t = self._policy.get("target")
57
+ if isinstance(t, dict) and isinstance(t.get(target), dict) and key in t[target]:
58
+ return t[target][key]
59
+ defaults = self._policy.get("defaults")
60
+ if not isinstance(defaults, dict):
61
+ return None
62
+ if target.startswith("llm:"):
63
+ llm = defaults.get("llm")
64
+ if isinstance(llm, dict) and key in llm:
65
+ return llm[key]
66
+ outbound = defaults.get("outbound")
67
+ return outbound.get(key) if isinstance(outbound, dict) else None
68
+
69
+ def __getattr__(self, name: str) -> Any:
70
+ # Delegate everything else to the native core (execute, execute_async,
71
+ # report, persistent, enter_flow/exit_flow/journal_*, advance_clock, …).
72
+ return getattr(self._core, name)
73
+
74
+
75
+ def _journal_path(cwd: str | Path | None, env: Mapping[str, str]) -> str | None:
76
+ """Where the native core attaches its journal (persistent dev cache + Tier 2).
77
+ `KEEL_JOURNAL` overrides the path; an explicit empty value disables it. This
78
+ is the *construction-time* default: keel.toml's `journal` key replaces it at
79
+ configure time unless KEEL_JOURNAL is set, in which case the env wins
80
+ (see ``bootstrap.apply_journal_env_override``)."""
81
+ override = env.get("KEEL_JOURNAL")
82
+ if override is not None:
83
+ return override or None # empty string ⇒ no journal
84
+ base = Path(cwd) if cwd is not None else Path.cwd()
85
+ return str(base / ".keel" / "journal.db")
86
+
87
+
88
+ def _try_load_native(journal_path: str | None) -> Backend | None:
89
+ try:
90
+ mod = importlib.import_module("keel_core")
91
+ except ImportError:
92
+ return None
93
+ ctor = getattr(mod, "KeelCore", None) or getattr(mod, "Core", None)
94
+ if ctor is None:
95
+ return None
96
+ inst = None
97
+ if journal_path:
98
+ try:
99
+ inst = ctor(journal_path=journal_path) # attaches the persistent journal
100
+ except Exception:
101
+ inst = None # journal open failed — fall back to an in-memory native core
102
+ if inst is None:
103
+ inst = ctor()
104
+ if callable(getattr(inst, "configure", None)) and callable(getattr(inst, "execute", None)):
105
+ # The native KeelCore exposes configure/execute/execute_async/report/
106
+ # persistent; wrap it only to add `layer(target, key)` (idempotency.header
107
+ # + cache-ttl gate parity), delegating everything else.
108
+ return _NativeBackend(inst) # type: ignore[return-value]
109
+ return None
110
+
111
+
112
+ def load_backend(
113
+ preferred: str | None = None,
114
+ *,
115
+ cwd: str | Path | None = None,
116
+ env: Mapping[str, str] | None = None,
117
+ ) -> Backend:
118
+ """Resolve the runtime backend per `KEEL_BACKEND` (or `preferred`). Under a
119
+ native backend, a journal is attached at `<cwd>/.keel/journal.db` (created on
120
+ demand) so the dev cache's `scope=persistent` replays across runs."""
121
+ environ = env if env is not None else os.environ
122
+ choice = (preferred if preferred is not None else environ.get("KEEL_BACKEND", "auto")) or "auto"
123
+ if choice not in ("auto", "native", "stub"):
124
+ # A user env-var mistake, not a Keel bug — E001 (config-level), not E040.
125
+ raise KeelError(
126
+ "KEEL-E001",
127
+ f"KEEL_BACKEND must be one of auto, native, or stub (got {choice!r}); "
128
+ "unset it or correct the value",
129
+ )
130
+
131
+ if choice != "stub":
132
+ native = _try_load_native(_journal_path(cwd, environ))
133
+ if native is not None:
134
+ return native
135
+ if choice == "native":
136
+ # A missing build/install is a user-environment problem, not a Keel
137
+ # bug, and not a malformed policy: the configuration is valid, the
138
+ # capability is absent — E005 (unsupported-configuration) with a
139
+ # concrete next step, never E040 ("file an issue").
140
+ raise KeelError(
141
+ "KEEL-E005",
142
+ "KEEL_BACKEND=native requires the keel_core native module, which is not "
143
+ "installed; build it (`maturin develop -m crates/keel-py/Cargo.toml`) or "
144
+ "unset KEEL_BACKEND to use the pure-Python stub",
145
+ )
146
+
147
+ from keel_core_stub import KeelCoreStub
148
+
149
+ return KeelCoreStub()
@@ -0,0 +1,116 @@
1
+ """Level 0 embedded smart-defaults pack (DX spec §1) + the policy-merge helper.
2
+
3
+ The policy applied when no keel.toml is present. It MIRRORS
4
+ contracts/defaults.toml verbatim (that file is the frozen source of truth);
5
+ we embed rather than read it so the installed package is self-contained
6
+ (stdlib-only, works offline). Drift against the contract is caught by the
7
+ parity test, which parses contracts/defaults.toml with `tomllib` and asserts
8
+ deep equality when the repo file is present.
9
+
10
+ `apply_pack_defaults` layers these defaults (and any provider-pack fragments)
11
+ UNDER the user's keel.toml — the Python twin of `applyPackDefaults` in
12
+ node/keel/src/defaults.mjs; with no pack fragments it is identical to the Node
13
+ twin, and both front ends must agree (merge parity is a cross-language
14
+ contract).
15
+
16
+ The Level 0 hard rules — never change success-path semantics; never retry
17
+ non-idempotent calls; do nothing if a call can't be wrapped safely — are
18
+ BEHAVIOR, not config, and are enforced in the front end / backend, not here.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from copy import deepcopy
24
+ from typing import Any, Iterable
25
+
26
+
27
+ def outbound_defaults() -> dict[str, Any]:
28
+ """`[defaults.outbound]` — any intercepted network call."""
29
+ return {
30
+ "timeout": "30s",
31
+ "retry": {
32
+ "attempts": 3,
33
+ "schedule": "exp(200ms, x2, max 30s, jitter)",
34
+ "on": ["conn", "timeout", "429", "5xx"],
35
+ },
36
+ "breaker": {"failures": 5, "cooldown": "15s"},
37
+ }
38
+
39
+
40
+ def llm_defaults() -> dict[str, Any]:
41
+ """`[defaults.llm]` — any `llm:*` target; the generic LLM pack layer.
42
+
43
+ Retry-After-aware retry (retry on conn/timeout/429/5xx; the core waits
44
+ `max(schedule_wait, retry_after)`) plus the dev-loop response cache
45
+ (`cache = { mode = "dev" }`, resolved to a concrete ttl off-prod and
46
+ dropped in prod by `keel.packs.llm.resolve_dev_cache`).
47
+ """
48
+ return {
49
+ "timeout": "120s",
50
+ "retry": {
51
+ "attempts": 6,
52
+ "schedule": "exp(500ms, x2, max 60s, jitter)",
53
+ "on": ["conn", "timeout", "429", "5xx"],
54
+ },
55
+ "breaker": {"failures": 5, "cooldown": "30s"},
56
+ "cache": {"mode": "dev"},
57
+ }
58
+
59
+
60
+ def level0_defaults() -> dict[str, Any]:
61
+ """The embedded Level 0 policy, as the dict the backend's `configure`
62
+ expects (identical shape to keel.toml parsed to JSON)."""
63
+ return {"defaults": {"outbound": outbound_defaults(), "llm": llm_defaults()}}
64
+
65
+
66
+ def _table(v: Any) -> dict[str, Any]:
67
+ return v if isinstance(v, dict) else {}
68
+
69
+
70
+ def apply_pack_defaults(
71
+ policy: dict[str, Any],
72
+ pack_fragments: Iterable[dict[str, Any]] = (),
73
+ ) -> dict[str, Any]:
74
+ """Merge Level 0 defaults, then provider-pack fragments, then the user
75
+ policy — precedence ``defaults < packs < user`` — filling in any
76
+ ``defaults.outbound`` / ``defaults.llm`` key a higher layer did not set,
77
+ while a higher layer replaces a key it DOES set wholesale (the
78
+ per-layer-wholesale semantics of the defaults.toml header, matching the
79
+ engine's own per-key layer resolution).
80
+
81
+ Target tables are left untouched — the engine resolves target →
82
+ defaults.llm → defaults.outbound precedence per key at execute time.
83
+
84
+ Provider packs (``keel.packs.openai_pack`` / ``anthropic_pack``) currently
85
+ emit the generic ``[defaults.llm]`` layer, so folding their fragments is the
86
+ identity over the embedded defaults; the parameter is the seam by which a
87
+ future per-provider refinement would take effect.
88
+
89
+ Returns a NEW policy; the input is never mutated. Idempotent on
90
+ ``level0_defaults()``. Mirrors Node's ``applyPackDefaults`` (empty
91
+ ``pack_fragments`` → identical result).
92
+ """
93
+ out = deepcopy(policy) if isinstance(policy, dict) else {}
94
+ user_defaults = _table(out.get("defaults"))
95
+
96
+ pack_outbound: dict[str, Any] = {}
97
+ pack_llm: dict[str, Any] = {}
98
+ for frag in pack_fragments:
99
+ frag_defaults = _table(frag.get("defaults")) if isinstance(frag, dict) else {}
100
+ pack_outbound.update(_table(frag_defaults.get("outbound")))
101
+ pack_llm.update(_table(frag_defaults.get("llm")))
102
+
103
+ out["defaults"] = {
104
+ **user_defaults,
105
+ "outbound": {
106
+ **outbound_defaults(),
107
+ **pack_outbound,
108
+ **_table(user_defaults.get("outbound")),
109
+ },
110
+ "llm": {
111
+ **llm_defaults(),
112
+ **pack_llm,
113
+ **_table(user_defaults.get("llm")),
114
+ },
115
+ }
116
+ return out