@owlmeans/llm 0.1.18-rc.2 → 0.1.18-rc.21

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 (76) hide show
  1. package/README.md +2 -2
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/llm/SKILL.md +239 -116
  4. package/agent-meta/skills/llm-prompt-caching/SKILL.md +37 -3
  5. package/build/execution/service.d.ts.map +1 -1
  6. package/build/execution/service.js +46 -11
  7. package/build/execution/service.js.map +1 -1
  8. package/build/execution/types.d.ts +72 -12
  9. package/build/execution/types.d.ts.map +1 -1
  10. package/build/execution/utils.d.ts.map +1 -1
  11. package/build/execution/utils.js +15 -9
  12. package/build/execution/utils.js.map +1 -1
  13. package/build/helpers/retry.d.ts +14 -0
  14. package/build/helpers/retry.d.ts.map +1 -1
  15. package/build/helpers/retry.js +14 -0
  16. package/build/helpers/retry.js.map +1 -1
  17. package/build/helpers/spectate.d.ts.map +1 -1
  18. package/build/helpers/spectate.js +12 -5
  19. package/build/helpers/spectate.js.map +1 -1
  20. package/build/index.d.ts +2 -2
  21. package/build/index.d.ts.map +1 -1
  22. package/build/index.js +2 -2
  23. package/build/index.js.map +1 -1
  24. package/build/model.d.ts +1 -1
  25. package/build/model.d.ts.map +1 -1
  26. package/build/model.js +60 -28
  27. package/build/model.js.map +1 -1
  28. package/build/plugins/anthropic.d.ts +40 -0
  29. package/build/plugins/anthropic.d.ts.map +1 -1
  30. package/build/plugins/anthropic.js +79 -6
  31. package/build/plugins/anthropic.js.map +1 -1
  32. package/build/plugins/openai.d.ts +12 -0
  33. package/build/plugins/openai.d.ts.map +1 -1
  34. package/build/plugins/openai.js +29 -3
  35. package/build/plugins/openai.js.map +1 -1
  36. package/build/plugins/types.d.ts +7 -0
  37. package/build/plugins/types.d.ts.map +1 -1
  38. package/build/prompt/service.d.ts.map +1 -1
  39. package/build/prompt/service.js +11 -0
  40. package/build/prompt/service.js.map +1 -1
  41. package/build/prompt/types.d.ts +20 -0
  42. package/build/prompt/types.d.ts.map +1 -1
  43. package/build/service.d.ts.map +1 -1
  44. package/build/service.js +42 -7
  45. package/build/service.js.map +1 -1
  46. package/build/types.d.ts +71 -1
  47. package/build/types.d.ts.map +1 -1
  48. package/build/utils/config.d.ts +11 -0
  49. package/build/utils/config.d.ts.map +1 -1
  50. package/build/utils/config.js +21 -1
  51. package/build/utils/config.js.map +1 -1
  52. package/build/utils/null-report.d.ts.map +1 -1
  53. package/build/utils/null-report.js +9 -1
  54. package/build/utils/null-report.js.map +1 -1
  55. package/package.json +13 -13
  56. package/src/execution/service.ts +46 -11
  57. package/src/execution/types.ts +77 -13
  58. package/src/execution/utils.ts +16 -9
  59. package/src/helpers/retry.ts +16 -0
  60. package/src/helpers/spectate.ts +14 -5
  61. package/src/index.ts +4 -2
  62. package/src/model.ts +77 -27
  63. package/src/plugins/anthropic.ts +89 -6
  64. package/src/plugins/openai.ts +33 -3
  65. package/src/plugins/types.ts +8 -0
  66. package/src/prompt/service.ts +12 -0
  67. package/src/prompt/types.ts +20 -0
  68. package/src/service.ts +53 -7
  69. package/src/types.ts +71 -1
  70. package/src/utils/config.ts +23 -1
  71. package/src/utils/null-report.ts +9 -1
  72. package/tests/context.ts +3 -0
  73. package/tests/execution.spec.ts +148 -13
  74. package/tests/helpers.spec.ts +4 -1
  75. package/tests/plugins.spec.ts +211 -0
  76. package/tests/prompt.spec.ts +43 -0
package/README.md CHANGED
@@ -20,7 +20,7 @@ abstraction that resolves models from an inheritable policy.
20
20
  ## Installation
21
21
 
22
22
  ```bash
23
- bun add @owlmeans/llm @owlmeans/llm-common
23
+ bun add @owlmeans/llm@^0.1.18-rc.12 @owlmeans/llm-common@^0.1.18-rc.11
24
24
  bun add @langchain/core @langchain/openai @langchain/anthropic # peer dependencies
25
25
  ```
26
26
 
@@ -184,7 +184,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
184
184
  your project's skill store (`.agents/skills/`):
185
185
 
186
186
  ```sh
187
- npx @owlmeans/agent-skills
187
+ npx @owlmeans/agent-skills@^0.1.18-rc.20
188
188
  ```
189
189
 
190
190
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/llm",
4
- "version": "0.1.18-rc.0",
5
- "generatedAt": "2026-08-16T22:20:50.505Z",
4
+ "version": "0.1.18-rc.21",
5
+ "generatedAt": "2026-09-12T14:21:25.464Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -8,45 +8,37 @@ user-invocable: false
8
8
  # @owlmeans/llm
9
9
 
10
10
  **Layer:** Core
11
- **Install:** `"@owlmeans/llm": "^0.1.18-rc.0"` in `dependencies` (plus the `@langchain/*` peers)
11
+ **Install:** `"@owlmeans/llm": "^0.1.18-rc.21"` in `dependencies` (plus the `@langchain/*` peers)
12
12
 
13
- The inference runtime. Everything provider-specific is a **plugin**; the model itself only
14
- owns the provider-independent parts (streaming discipline, retries, validation,
15
- observability). Serializable contracts live in `@owlmeans/llm-common`.
13
+ The inference runtime. Everything provider-specific is a **plugin**; the model itself only owns the
14
+ provider-independent parts (streaming discipline, retries, validation, observability). Serializable
15
+ contracts live in `@owlmeans/llm-common`. `src/helpers/` is exported — functions a **consumer** may
16
+ use alongside a model; `src/utils/` is internal and **never exported** (a spec that needs one imports
17
+ it from `../src/utils/…`). Decide the side before placing a function, and never export a `utils/`
18
+ symbol "because a test needs it".
16
19
 
17
20
  ## Key Exports
18
21
 
19
22
  | Export | Description |
20
23
  |--------|-------------|
21
- | `makeLlmModel(options, spectator)` | The four-method model: `ask` / `talk` / `invoke` / `request`. |
22
- | `makeLlmService(options, alias?)` · `appendLlmService(ctx, options, alias?)` | Model factory/registry — resolves a `ModelConfig` by alias, memoized per alias+override. |
23
- | `llmServiceApi(options, self)` | The factory half WITHOUT `createService`, to compose into your own service (role accessors, domain helpers). |
24
- | `makeExecutionService(alias?, options?)` · `appendExecutionService(ctx, alias?, options?)` | Frozen 3-level executions + policy resolution + snapshot/restore/checkpoint. |
24
+ | `makeLlmModel({ model, purpose, prompt?, prompts?, files?, utility?, retries?, … }, spectator)` | The four-method model: `ask` / `talk` / `invoke(input, schema, opts)` / `request`. `model` is an already-resolved `BaseChatModel`. |
25
+ | `makeLlmService(options, alias?)` · `appendLlmService(ctx, options, alias?)` · `llmServiceApi(options, self)` | Model factory/registry — `makeLlmService({ models: () => configs }).getModel(alias, override?)` resolves a `ModelConfig` by alias, memoized per alias+override. The `…Api` half omits `createService`, to compose into your own service (role accessors, domain helpers). |
26
+ | `makeExecutionService(alias?, options?)` · `appendExecutionService(ctx, alias?, options?)` · `executionServiceApi(options, self)` | Frozen 3-level executions + policy resolution + snapshot/restore + advice, and the same composable half. |
25
27
  | `makePromptService(options?, alias?)` · `appendPromptService(ctx, options?, alias?)` · `promptServiceApi(options, self)` | Skill registry + the composition plugin chain. Also at `@owlmeans/llm/prompt`. |
26
28
  | `rolePlugin`, `skillsPlugin`, `contextPlugin`, `BUILT_IN_PROMPT_PLUGINS` | The built-in composition plugins. |
27
- | `renderSkill`, `sortSkills`, `joinChunks`, `compareAlias`, `prefixHash` | Deterministic rendering primitives — reuse them, never re-implement. |
28
- | `readCacheUsage`, `hasCacheActivity` | Normalized prompt-cache accounting from a completion. |
29
- | `executionServiceApi(options, self)` | The execution half without `createService`, for the same composition pattern. |
29
+ | `PromptContext.claim(key)` · `PromptComposeParams.utility` | Per-composition ownership of a key; a cheap model for one plugin-side call. |
30
+ | `renderSkill`, `sortSkills`, `joinChunks`, `compareAlias`, `prefixHash` · `readCacheUsage`, `hasCacheActivity` | Deterministic rendering primitives — reuse them, never re-implement — and normalized prompt-cache accounting from a completion. |
30
31
  | `plugins`, `registerLlmPlugin`, `resolvePlugin`, `pluginOf`, `pluginFor` | The provider-plugin registry. Also at `@owlmeans/llm/plugins`. |
31
32
  | `anthropicPlugin`, `openAiPlugin`, `compatiblePlugin`, `openAiFamily` | Built-in providers; `openAiFamily` is the shared OpenAI-client behaviour to spread into a new plugin. |
32
- | `withRetry`, `registerFatalError`, `spectate`, `normalizeInput`, `parseJsonContent`, `coerceToSchema` | Helpers usable alongside a model. Also at `@owlmeans/llm/helpers`. |
33
+ | `NO_SAMPLING_PREFIXES` / `rejectsSampling(model)` · `RESPONSES_API_PREFIXES` / `usesResponsesApi(model)` | Which families reject which sampling parameters — see the table below. Consumers pin presets against them. |
34
+ | `withRetry`, `registerFatalError`, `isFatalError`, `spectate`, `normalizeInput`, `parseJsonContent`, `coerceToSchema` | Helpers usable alongside a model. Also at `@owlmeans/llm/helpers`. |
33
35
  | `LlmError`, `LlmModelError`, `LlmMissconfiguredError`, `LlmPluginError`, `LlmRetryExceededError` | `ResilientError` family. `LlmModelError` is the RETRYABLE one. |
34
36
  | `mergePrompt`, `mergePolicy`, `resolveRole`, `effortPatch` | Execution merge helpers; `mergePrompt` unions skills and takes the deepest role. |
35
37
  | `DEFAULT_MODEL_RETRIES`, `MODEL_STREAM_TIMEOUT_MS` (3 min idle), `FALLBACK_AFTER_ATTEMPTS`, `DEFAULT_EFFORT`, `EFFORT_TABLE`, `MAX_CACHE_BREAKPOINTS`, `MAX_SYSTEM_BREAKPOINTS`, `MIN_CACHEABLE_TOKENS`, `LLM_SERVICE`, `EXECUTION_SERVICE`, `PROMPT_SERVICE` | Tuning + aliases. |
36
38
 
37
- ## helpers/ vs utils/ — the rule this package follows
38
-
39
- - `src/helpers/` — functions a **consumer** may use alongside a model. Exported.
40
- - `src/utils/` — used only inside the library. **Never exported**; a spec that needs one
41
- imports it from `../src/utils/…`.
42
-
43
- Adding a function? Decide which side it belongs to first, then place it. Do not export a
44
- `utils/` symbol "because a test needs it".
45
-
46
39
  ## Provider differences are plugins, never `if`s
47
40
 
48
- `LlmPlugin` is the single seam. If you find yourself writing `instanceof ChatAnthropic` or
49
- `config.provider === …` in `model.ts` or `service.ts`, it belongs on the plugin instead:
41
+ `LlmPlugin` is the single seam: an `instanceof ChatAnthropic` or `config.provider === …` in `model.ts` or `service.ts` belongs on the plugin.
50
42
 
51
43
  | Plugin member | Replaces |
52
44
  |---|---|
@@ -59,130 +51,261 @@ Adding a function? Decide which side it belongs to first, then place it. Do not
59
51
  | `patchCache` | the message-prefix cache marker |
60
52
  | `cacheKey` (via `ModelConfig.cacheKey`) | provider cache-routing hints such as OpenAI's `prompt_cache_key` |
61
53
  | `isFatal` | "this error can never be retried" |
62
-
63
- **Registration order is load-bearing.** Instance-based lookup (`pluginFor`) returns the
64
- FIRST plugin whose `owns` matches. `compatible` is registered before `openai` because both
65
- build a `ChatOpenAI`, and assuming the tool-calling hack for an unlabelled model is safe
66
- everywhere while assuming native JSON-schema support is not.
54
+ | `suppressesThinking` | whether the plugin turns reasoning off on the wire FOR THIS CONFIG, which is what drops the `/no_think` prompt directive from the prepared messages |
55
+
56
+ ### A provider that removes a parameter is a plugin concern too
57
+
58
+ `build` is not the only place a parameter reaches the wire, and `refine` is not only a retry hook —
59
+ **every** call is made on the instance `refine` returns, attempt 0 included. A knob suppressed in
60
+ `build` and re-applied in `refine` therefore ships on every single request: an unsupported
61
+ `temperature` restored there 400s the model's whole family on the first call, and `isFatal`
62
+ correctly refuses to retry it. Both built-in plugins gate their rejected parameters in **both**
63
+ hooks, through a predicate the package root exports:
64
+
65
+ | Family | Rejects | Predicate |
66
+ |---|---|---|
67
+ | Claude 4.7+ and the 5 family | `temperature`, `top_p`, `top_k` | `NO_SAMPLING_PREFIXES` / `rejectsSampling(model)` |
68
+ | OpenAI Responses API (`gpt-5*`, `codex-*`) | `temperature`, `top_p` | `RESPONSES_API_PREFIXES` / `usesResponsesApi(model)` |
69
+
70
+ Models below those lines keep the deterministic `temperature: 0` default. Each `refine` re-derives
71
+ the family from the ACTIVE base instance it is handed — Anthropic from `modelName ?? model`, OpenAI
72
+ from `model ?? lc_kwargs.model` plus a `useResponsesApi` already on `lc_kwargs` — so a same-family
73
+ `fallback` rung is judged on its own id, not the primary's. Keep the predicate exported: consumers
74
+ pin presets against it (viable-agent's `tests/presets.test.ts` asserts no preset entry declares a
75
+ parameter its model rejects), and a second hand-written copy drifts when a family is added.
76
+
77
+ **Registration order is load-bearing.** Instance lookup (`pluginFor`) returns the FIRST plugin whose
78
+ `owns` matches, and `compatible` is registered before `openai` because both build a `ChatOpenAI`:
79
+ assuming the tool-calling hack for an unlabelled model is safe everywhere, assuming native
80
+ JSON-schema support is not.
67
81
 
68
82
  ## System prompts: a role and skills, never a hand-built message
69
83
 
70
84
  `makeLlmModel` takes `prompt` (a `PromptInput`) and a `prompts` resolver. The prompt service
71
- composes them into an ordered, cacheable system message; a caller's own leading
72
- `SystemMessage` is folded into the volatile `Context` block, so an unmigrated call site
73
- still works. **Do not build a persona as a `SystemMessage` in a helper** — declare it as
74
- `PromptPolicy.role` plus registered skills, or the knowledge duplicates and the cache
75
- prefix stops being stable.
76
-
77
- Skills accumulate down the execution chain (project → task → helper) and the deepest
78
- declared `role` wins — see `mergePrompt`. Full rules, breakpoint budget and the provider
79
- facts behind them: [[llm-prompt-caching]].
85
+ composes them into an ordered, cacheable system message; a caller's own leading `SystemMessage` is
86
+ folded into the volatile `Context` block, so an unmigrated call site still works. **Do not build a
87
+ persona as a `SystemMessage` in a helper** — declare it as `PromptPolicy.role` plus registered
88
+ skills, or the knowledge duplicates and the cache prefix stops being stable. Skills accumulate down
89
+ the execution chain (project → task → helper) and the deepest declared `role` wins (`mergePrompt`).
90
+ Block order, the breakpoint budget, the provider facts behind them, and the plugin seams
91
+ `claim(key)` / `utility`: [[llm-prompt-caching]].
80
92
 
81
93
  ## Execution: policy in, model out
82
94
 
83
- ```
84
- ProjectExecution ← root: policy + purpose + models resolver
85
- └─ TaskExecution ← + resumable state (phase/cursor/completed/data)
86
- └─ HelperExecution ← + a RESOLVED model + temperatureFactory, bound to a role
87
- ```
88
-
89
- `prompt` (role + skills) travels on `ExecutionState`, so it survives snapshot/restore;
90
- `prompts` and `files` are collaborators and never enter a snapshot.
91
-
92
- Every method returns a NEW `Object.freeze`d object. Resolution precedence in
93
- `model(exec, role, override)`: **roleOverride → modelOverride → effort tier →
94
- `LlmService.getModel`**. `escalate(exec, { effort })` raises the tier once and cascades to
95
- everything derived from it.
96
-
97
- Extending it for a domain: declare your own `Execution`/input types, list your collaborator
98
- fields in `ExecutionServiceOptions.collaboratorKeys` so they stay out of snapshots, and
99
- instantiate the service generic with your own `ExecutionShape` — **do not narrow the
100
- inherited method signatures**, which would be a contravariance error.
101
-
102
- `snapshot` excludes `state` itself; without that, every `derive`/`escalate`/`withPurpose`
103
- on a task would nest another copy of the previous state.
95
+ `ProjectExecution` (policy + purpose + models resolver) → `TaskExecution` (+ resumable state:
96
+ phase/cursor/completed/data) → `HelperExecution` (+ a RESOLVED model + `temperatureFactory`, a role).
97
+
98
+ `prompt` (role + skills) travels on `ExecutionState`, so it survives snapshot/restore; `prompts` and
99
+ `files` are collaborators and never enter a snapshot. Every method returns a NEW `Object.freeze`d
100
+ object. Resolution precedence in `model(exec, role, override)`: **roleOverride → modelOverride →
101
+ effort tier → `LlmService.getModel`**; `escalate(exec, { effort })` raises the tier once and cascades
102
+ to everything derived from it. `snapshot` excludes `state` itself — without that, every
103
+ `derive`/`escalate`/`withPurpose` on a task would nest another copy of the previous state.
104
+
105
+ Extending it for a domain: declare your own `Execution`/input types, list collaborator fields in
106
+ `ExecutionServiceOptions.collaboratorKeys` (kept out of snapshots), instantiate the service generic
107
+ with your own `ExecutionShape`, and **never narrow an inherited method signature** (contravariance).
108
+
109
+ `forHelper` also accepts `output` — an initial `maxTokens` for a helper whose one answer is genuinely
110
+ large (a whole source file, not a decision). It is a model selector, not a field the helper carries:
111
+ it becomes a call override, clamped to `maxOutput`, doubling under retry, surviving `temperatureFactory`.
112
+
113
+ ### The cheap tier: `utility(exec, override?)`
114
+
115
+ Work that is not the work — a relevance pick, a classification, a one-line judgement a plugin needs
116
+ before the real call can be shaped — runs on `ExecutionService.utility`, never on the helper's own
117
+ model. It resolves `policy.utilityRole ?? UTILITY_ROLE` (`@owlmeans/llm-common`, value `'utility'`)
118
+ at `ExecutionEffort.Economy`, through the SAME ladder as `model()`: `roleOverrides` remap it and
119
+ `modelOverrides` pin it as for any other role. The economy floor is local — the execution it was
120
+ asked on keeps its own tier — and `utilityRole` travels on `ModelPolicy`, so it survives
121
+ `forTask`/`escalate` and a snapshot/restore round trip. `utility` returns a `BaseChatModel`, never
122
+ `undefined`: an alias with no registered config reaches `createModel` through `model()` and throws
123
+ `LlmMissconfiguredError`, like any other unregistered role. The `undefined` a prompt plugin has to
124
+ handle comes from the other end — `PromptComposeParams.utility` (and `AgentOptions.utility`) is an
125
+ OPTIONAL resolver, unset wherever no cheap tier is wired, so a plugin that cannot get one degrades
126
+ rather than fails.
127
+
128
+ ### The plugin seam is `advise`-only, and `use()` seats by alias
129
+
130
+ `ExecutionPlugin` carries one hook: `advise` — answer a performer's question about the project it
131
+ works in (`ExecutionService.advise(exec, request)`, first usable answer wins, a throwing plugin is
132
+ skipped, `null` when nobody answers). Advice is advisory by contract: a caller appends whatever
133
+ comes back and proceeds unchanged on `null`.
134
+
135
+ **There is no checkpoint pair here, and that is a decision rather than a gap.** An execution is a
136
+ COLLABORATOR — a model policy, a purpose, a file helper — rebuilt per run from durable inputs, not
137
+ an artifact something restores. Resumability belongs to `@owlmeans/agent`'s pipeline runner, where
138
+ the persisted run ROW is the authority on where a run stands. Two half-truths about that would be
139
+ worse than one; `ExecutionService.checkpoint` and `ExecutionPlugin.onCheckpoint|onRestore` were
140
+ deleted for exactly that reason, having never been implemented by anything.
141
+ `TaskExecutionState.{phase, completed, cursor}` survive as **labels** for traces and prompts — a
142
+ human-readable "where am I" for a model to read, never a position anything resumes from.
143
+
144
+ `use(plugin)` seats **by alias**, replacing a plugin already registered under the same one. Mixins
145
+ compose, and a layer wired twice would otherwise answer twice — silently, since the first usable
146
+ answer wins.
104
147
 
105
148
  ## Classify a provider error by walking `cause`, never by `instanceof` or a surface read
106
149
 
107
- Two independent layers hide the wire shape of a provider failure, and each one alone is
108
- enough to make a fatal error look retryable — which costs all eight attempts with the real
109
- message buried under the repeats.
150
+ Two independent layers hide the wire shape of a provider failure, and each alone makes a fatal error
151
+ look retryable — costing the whole retry budget with the real message buried under the repeats.
110
152
 
111
- 1. **Nested SDK copies.** `@langchain/anthropic` and `@langchain/openai` bundle their OWN
112
- copies of the provider SDKs, so an error they throw is an instance of a DIFFERENT class
113
- than the one this package imports — `e instanceof BadRequestError` silently returns
114
- `false`.
153
+ 1. **Nested SDK copies.** `@langchain/anthropic` and `@langchain/openai` bundle their OWN copies of
154
+ the provider SDKs, so `e instanceof BadRequestError` compares against a DIFFERENT class than the
155
+ one this package imports and silently returns `false`.
115
156
  2. **Langchain's own error wrappers.** A failure is re-wrapped in a typed langchain error
116
- (`ContextOverflowError` for an input past the context window, and its siblings) that
117
- carries the original **only under `cause`** and has no `status` of its own — so
118
- `e.status === 400` misses it too.
119
-
120
- Use `isBadRequest` from `plugins/utils.ts`: it walks the `cause` chain looking for
121
- `status === 400`, bounded in depth so a self-referential chain terminates. Both built-in
122
- `isFatal` implementations go through it.
157
+ (`ContextOverflowError` for an input past the context window, and its siblings) carrying the
158
+ original **only under `cause`**, no `status` of its own — so `e.status === 400` misses it.
123
159
 
124
- A context overflow is the case that makes this urgent rather than merely untidy: `refine`
125
- escalates the **output** budget on each retry, so an over-limit **input** can never improve —
126
- every attempt re-sends the identical oversized request. Consumers hold their locks for the
127
- whole loop, so a single unfixable call becomes minutes of thrash on the caller's side.
128
-
129
- The same trap applies to any cross-copy `instanceof`; it is the runtime face of the
130
- peer-dependency identity rule below.
160
+ Use `isBadRequest` from `plugins/utils.ts`: it walks the `cause` chain for `status === 400`, bounded
161
+ in depth so a self-referential chain terminates. Both built-in `isFatal` implementations go through
162
+ it. A context overflow makes this urgent: `refine` escalates the **output** budget on each retry, so
163
+ an over-limit **input** can never improve — every attempt re-sends the identical oversized request,
164
+ and consumers hold their locks for the whole loop. The same trap applies to any cross-copy
165
+ `instanceof`; it is the runtime face of the peer-dependency rule below.
131
166
 
132
167
  ## Peer-dependency rule (langchain identity)
133
168
 
134
- `@langchain/core`, `@langchain/openai` and `@langchain/anthropic` are **peer** dependencies:
135
- model instances cross the package boundary, and two installed copies of a class with
136
- protected members are nominally distinct types. In a linked-workspace checkout the consumer
137
- must pin them to a single copy — see the `bun-linked-workspaces` skill.
138
-
139
- ## Usage
140
-
141
- ```typescript
142
- import { makeLlmModel, makeLlmService } from '@owlmeans/llm'
143
- import { ModelProvider } from '@owlmeans/llm-common'
144
-
145
- const llm = makeLlmService({ models: () => configs })
146
- const model = makeLlmModel(
147
- { model: llm.getModel('analyst'), purpose: { type: 'analysis' } }, spectator
148
- )
149
- const spec = await model.invoke('Describe the app', SpecSchema, { action: 'spec' })
150
- ```
169
+ `@langchain/core`, `@langchain/openai` and `@langchain/anthropic` are **peer** dependencies: model
170
+ instances cross the package boundary, and two installed copies of a class with protected members are
171
+ nominally distinct types. A consumer must end up with exactly ONE copy of each — declare them at one
172
+ range and check no nested `node_modules` holds a second, or every model instance crossing a boundary
173
+ becomes a foreign type and `instanceof` starts lying.
151
174
 
152
175
  ## Hangs are bounded by an IDLE deadline, not a total one
153
176
 
154
- A stalled provider is aborted after `MODEL_STREAM_TIMEOUT_MS` (3 min) of SILENCE and
155
- surfaces as a retryable `LlmModelError`, so the escalator moves on. The timer re-arms on
156
- every token, so a long-but-productive generation is never cut off — which is why the value
157
- can be low. Set it for a whole deployment with `LlmServiceOptions.streamTimeout` where the
158
- application composes its context; a preset that names its own `ModelConfig.streamTimeout`
159
- keeps it.
160
-
161
- Note what this does NOT bound: a call that keeps streaming forever, and retries. A fatal
162
- error misclassified as retryable multiplies its own latency by `DEFAULT_MODEL_RETRIES` —
163
- see the `instanceof` trap above.
177
+ A stalled provider is aborted after `MODEL_STREAM_TIMEOUT_MS` (3 min) of SILENCE and surfaces as a
178
+ retryable `LlmModelError`, so the escalator moves on. The timer re-arms on every token, so a
179
+ long-but-productive generation is never cut off — which is why the value can be low. Set it for a
180
+ deployment with `LlmServiceOptions.streamTimeout` where the application composes its context; a
181
+ preset naming its own `ModelConfig.streamTimeout` keeps it. It does NOT bound a call that keeps
182
+ streaming forever, nor retries — a fatal error misclassified as retryable multiplies its own latency
183
+ by `DEFAULT_MODEL_RETRIES`.
184
+
185
+ ## An outer validation loop must pass its attempt in
186
+
187
+ A caller that validates the OUTPUT — a diff that has to apply, a file that must not come back
188
+ truncated — runs its own retry loop around whole `ask` calls. Every one of those calls starts a FRESH
189
+ inner loop at attempt 0, so the escalator's two rungs never move: same model, same output budget,
190
+ same deterministic answer, N times. Pass `escalation: <outer attempt>` in `LlmCallOptions` and the
191
+ per-call escalator starts that far up its ladder instead — `maxTokens` doubling and the
192
+ `FALLBACK_AFTER_ATTEMPTS` switch to `ModelConfig.fallback` both advance. It is clamped to
193
+ `retries - 1`, moves the STARTING rung only, and never changes how many attempts the call makes. Two
194
+ things it depends on, both preset data rather than code: the role must declare a `fallback`, and that
195
+ fallback must be in the same plugin `family` (a cross-family one is skipped with a warning, because
196
+ switching provider mid-call flips the structured-output shape). `LlmCallOptions.fatal` is the lever
197
+ in the other direction — a per-call resolver consulted before the global ones and the plugin's
198
+ `isFatal`, for an error the caller knows no retry can fix.
199
+
200
+ ### A loop ABOVE the model asks the same question with `isFatalError`
201
+
202
+ A retry loop is not the only place that decides to carry on: a fix ladder rescues a failed repair and
203
+ climbs to a stronger model, an agent runner catches a round that threw and reports "gave up". Both
204
+ are right for a model that answered badly and wrong for a budget that ran out, and a blanket `catch`
205
+ cannot tell them apart — an exhausted balance becomes more expensive calls instead of a halt.
206
+ `isFatalError(e, fatal?)` runs the same resolvers, in the same order, that `withRetry` uses, and
207
+ returns the error to abort WITH (a resolver may unwrap a carrier and hand back the real cause) or
208
+ `null` when nothing considers it terminal. Ask it rather than re-deriving the rule.
209
+
210
+ ## Output caps: what the deployment wants vs what the provider allows
211
+
212
+ Four fields, and conflating them turns an escalation into a fatal 400 hours into a run:
213
+
214
+ | Field | Means |
215
+ |---|---|
216
+ | `maxTokens` | the budget asked for on the FIRST attempt |
217
+ | `maxTokensCap` | the ceiling the deployment budgets for the escalator |
218
+ | `maxOutput` | what the PROVIDER accepts in one request — a fact about the model |
219
+ | `contextWindow` | total window (input + output); informational, never sent |
220
+
221
+ `resolveOutputCap` (`utils/config.ts`) reconciles them: the declared cap chooses the ceiling and the
222
+ capability trims it, and `DEFAULT_MAX_OUTPUT_CAP` applies only when neither is stated. `createModel`
223
+ also clamps `maxTokens` to `maxOutput` and warns about a cap above it. For an aggregated model
224
+ `maxOutput` is the limit of the `inferenceProvider` actually pinned, often far below what the model
225
+ can do elsewhere. `combinedWindow: true` marks a model whose window is shared between input and
226
+ output (MiniMax M2.x, gpt-oss) — nothing enforces it at runtime; it keeps presets honest about
227
+ leaving room for the prompt. **A `fallback` that changes `model` must restate
228
+ `contextWindow`/`maxOutput`** (and reset `combinedWindow`): the fallback config is
229
+ `{...primaryConfig, ...fallback}`, so every field the patch does not name is inherited from a
230
+ different model.
231
+
232
+ ### Reasoning is off unless a preset asks for it — and it is billed against the same budget
233
+
234
+ The models `NO_SAMPLING_PREFIXES` names think ADAPTIVELY unless the request says otherwise: an absent
235
+ `thinking` parameter means adaptive, and `@langchain/anthropic` forwards the parameter only when the
236
+ caller sets it (its own field default of `disabled` is never sent). Left on, the reasoning costs
237
+ twice — it is spent from `max_tokens`, the same allowance as the answer (a budget sized for the
238
+ answer alone goes entirely on reasoning and the completion arrives well-formed, `stop_reason:
239
+ "max_tokens"`, with no text block), and its SUMMARISED stream arrives in bursts minutes apart, which
240
+ the idle deadline reads as a dead connection and retries from scratch
241
+ (`llm:model:stream-stalled:no token for 180000ms (idle deadline)`).
242
+
243
+ **`ModelConfig.disableThinking` is the switch, and where it lands depends on the plugin.**
244
+ `makeLlmModel` appends the literal `/no_think` to every request's prepared messages whenever the flag
245
+ is set AND the plugin's `suppressesThinking(config)` does not answer `true` — the soft switch for
246
+ models with no request-level control (Qwen3). The Anthropic plugin answers `true` only for
247
+ `rejectsSampling(model)`, and for those puts `thinking: { type: 'disabled' }` on the request in
248
+ `build`, which `refine` carries through `lc_kwargs` on every attempt. Below that line
249
+ (`claude-haiku-4-5`, `claude-sonnet-4-6`) and under any plugin declaring no hook the flag injects
250
+ prompt text instead, so set it where the wire honours it. **A preset must set the flag on every
251
+ adaptive Anthropic role**; it reaches the `fallback` rung only by inheritance from the entry
252
+ (viable-agent's `presets.test.ts` pins this). Turning reasoning ON is a per-role decision.
253
+
254
+ `ADAPTIVE_MIN_MAX_TOKENS` (32k) is the output floor the Anthropic plugin's `build` applies to every
255
+ `rejectsSampling(model)` config — `disableThinking` is not consulted, so a role with reasoning turned
256
+ off is floored just the same. It is a floor, not an override (a preset asking for more keeps it) and
257
+ it is clamped through `resolveOutputCap`, so it can never exceed what the provider accepts and turn a
258
+ retryable empty answer into a fatal 400. Raising or removing it re-opens empty completions.
259
+
260
+ Two diagnostics the above depends on:
261
+
262
+ - **An empty completion is a null result, not a filter rejection.** `ask` tests emptiness BEFORE the
263
+ caller's filter — every shipped filter returns null only for empty input, so a filter run first
264
+ blames the caller for a provider problem and skips `reportNull`, losing the `finishReason` and
265
+ `outputTokens` that name the cause.
266
+ - **Anthropic's stop reason is not `finish_reason`.** langchain puts it in
267
+ `additional_kwargs.stop_reason` and `response_metadata.finish_reason` does not exist there, so
268
+ `null-report.ts` reads both; `thinkingOnly` marks a completion that was all reasoning.
269
+
270
+ OpenAI reasoning models get it handled by shrinking the reasoning cap on retry (`plugins/openai.ts`);
271
+ escalating `maxTokens` alone fixes neither family — the retry draws from an unchanged distribution.
272
+
273
+ ## Config precedence: a preset is a base, not a final word
274
+
275
+ `createModel` layers four sources, lowest first: `presetOf(base.preset)` < `base` < `presetOf(override.preset)` < `override`.
276
+
277
+ A `preset` is a BASE that its referent refines, so it sits UNDER the config naming it. Assign it last
278
+ and a role declaring `preset:` silently discards both its own fields and the caller's override — that
279
+ is how effort-tier token caps and `temperatureFactory`'s temperature disappear for preset-based
280
+ roles. An override naming a preset (how the execution layer delivers a `modelOverrides` string pin)
281
+ outranks the alias but yields to explicit override fields; resolution is ONE level deep, so a preset
282
+ meant to carry a model must name one.
164
283
 
165
284
  ## Resilience already handled — do not reimplement
166
285
 
167
- Idle stream deadline · duplicate-final-chunk dedup · output-budget escalation · reasoning-cap
168
- shrink · same-family fallback model · schema coercion · JSON salvage from prose · `NullCapture`
169
- diagnostics · fatal-error short-circuit · blank-content sanitization (whitespace-only text
170
- blocks are dropped before every call — a blank block, e.g. an empty file read pasted into a
171
- prompt, is otherwise a fatal Anthropic 400; blank tool results are stubbed to keep their
172
- `tool_use` pairing). Details: package `README.md`.
286
+ Idle stream deadline · duplicate-final-chunk dedup · output-budget escalation · reasoning-cap shrink ·
287
+ adaptive-thinking budget floor · same-family fallback model · caller-seeded ladder position
288
+ (`escalation`) · schema coercion · JSON salvage from prose · `NullCapture` diagnostics · fatal-error
289
+ short-circuit · blank-content sanitization (whitespace-only text blocks are dropped before every call
290
+ — a blank block, e.g. an empty file read pasted into a prompt, is otherwise a fatal Anthropic 400;
291
+ blank tool results are stubbed to keep their `tool_use` pairing). Details: package `README.md`.
173
292
 
174
293
  ## Tests
175
294
 
176
- `bun test ./tests` in the package. Offline specs always run; `tests/model.spec.ts` is gated
177
- on `OPENROUTER_SECRET` / `ANTHROPIC_SECRET` in the repo-root `.env` and self-skips otherwise.
295
+ `bun test ./tests` in the package; offline specs always run. In `tests/model.spec.ts` the anthropic live
296
+ suite is gated on `ANTHROPIC_SECRET` and self-skips with a printed reason without it, and the OpenRouter
297
+ suite is disabled unconditionally: an aggregator on a separate account serving models no deployment runs,
298
+ whose `402 requires more credits` reads as a failure of the code under test. `plugins.spec.ts` covers the
299
+ `Compatible` provider offline.
178
300
 
179
301
  ## Depends On
180
302
 
181
303
  - `@owlmeans/llm-common` · `@owlmeans/context` · `@owlmeans/error` · `@owlmeans/basic-ids` · `ajv`
304
+ - `@anthropic-ai/sdk` — runtime, for the `BadRequestError` the fatal-error rules are written around
182
305
  - peer `@langchain/core`, `@langchain/openai`, `@langchain/anthropic`
183
306
 
184
307
  ## Related
185
308
 
186
- - [[llm-common]] — the serializable contracts
187
- - [[llm-prompt-caching]] — prompt composition, block order and the cache invariants
309
+ - [[llm-common]] — the serializable contracts · [[llm-prompt-caching]] — prompt composition, block
310
+ order and the cache invariants
188
311
  - [[context]] — service registration · [[error]] — the `ResilientError` family
@@ -23,7 +23,7 @@ into a single system message:
23
23
  |---|-------|----------------|---------|-----------|
24
24
  | 0 | `Role` | `rolePlugin` ← `PromptPolicy.role` | per role | — |
25
25
  | 1 | `Skills` | `skillsPlugin` ← registry + `inline` | per helper | ✅ closes role+skills |
26
- | 2 | `Packages` | app plugins (e.g. `owlmeansPackagesPlugin`) | per request | ✅ its own |
26
+ | 2 | `Packages` | application plugins (`owlmeansPackagesPlugin`) | per request | ✅ its own |
27
27
  | 3 | `Context` | `contextPlugin` ← `context`, `callSkills` | per call | **never** (unless sole block) |
28
28
 
29
29
  A caller's own leading `SystemMessage` is detached by `makeLlmModel` and re-emitted as
@@ -87,6 +87,34 @@ reproduce byte-for-byte breaks the prefix for everyone sharing it. Register by a
87
87
  re-registering the same alias replaces rather than appends, so double-wiring cannot
88
88
  double-emit.
89
89
 
90
+ ### `ctx.claim(key)` — one emitter per thing, per composition
91
+
92
+ Two plugins can each be ABLE to render the same skill (a static catalogue and a detector
93
+ that notices the request mentions it). `ctx.claim(key)` returns `true` to the first caller
94
+ and `false` to every later one within the same `compose`, so exactly one of them emits.
95
+
96
+ The claim set is per composition, never per service — a claim that outlived the call would
97
+ silently delete the content from every later prompt sharing the service. It is also free:
98
+ a composition where no plugin claims renders byte-identical output, so adding the seam
99
+ invalidated no prefix.
100
+
101
+ Claim on a STABLE key (`skill:<alias>`, `pkg:@owlmeans/llm`). A key derived from the
102
+ request makes the winner vary per call, and with it the cached block.
103
+
104
+ ### `ctx.utility()` — a cheap model, for the volatile blocks only
105
+
106
+ `PromptComposeParams.utility` is a resolver for the cheap tier
107
+ (`ExecutionService.utility` → `ModelPolicy.utilityRole ?? UTILITY_ROLE` at
108
+ `ExecutionEffort.Economy`) that a plugin may spend ONE call on while composing — picking
109
+ which of a hundred candidate skills a request is about. It may yield `undefined`: most
110
+ deployments configure no cheap tier, and a plugin that cannot get one degrades rather
111
+ than fails.
112
+
113
+ **What it returns must never land in `Role` or `Skills`.** A model's answer is not
114
+ reproducible byte-for-byte, so a selection made this way belongs in `Packages` or
115
+ `Context`, which carry their own breakpoint or none. Wiring: `makeLlmModel`'s `utility`
116
+ option (beside `files`) and `AgentOptions.utility`; unwired, the field is simply absent.
117
+
90
118
  ## Package skills in a prompt (`@owlmeans/agent-skills/llm`)
91
119
 
92
120
  `owlmeansPackagesPlugin(options)` notices which `@owlmeans/*` packages a request mentions
@@ -98,9 +126,15 @@ sandbox or remote workspace) → an installed copy under `node_modules` → the
98
126
  repository over HTTPS. Every failure is a miss, never a throw. Results, including misses,
99
127
  are cached per plugin instance.
100
128
 
129
+ A cache keyed on the file provider keys on `LlmFileProvider.key` — the provider's stable
130
+ identity (a project root, a sandbox id). Providers are late-bound and often rebuilt per
131
+ request, so object identity says nothing, and one bucket shared across projects serves the
132
+ first project's files to the second. A provider that declares no `key` is uncacheable, not
133
+ one more anonymous member of the shared bucket.
134
+
101
135
  ```typescript
102
136
  ctx.prompts().use(owlmeansPackagesPlugin({
103
- files: () => ctx.files(), // tried first
137
+ files: () => fileProvider, // the host's own LlmFileProvider, tried first
104
138
  exclude: ['@owlmeans/llm'], // already covered by the static Skills block
105
139
  fetch: false, // air-gapped: skip the repository fallback
106
140
  }))
@@ -132,4 +166,4 @@ role); the `compatible` plugin deliberately does not, because aggregators runnin
132
166
 
133
167
  - [[llm]] — the runtime and the provider-plugin seam
134
168
  - [[llm-common]] — `SkillDefinition`, `PromptPolicy`, `PromptBlock`, `LlmFileProvider`
135
- - [[agent-skills]] — `@owlmeans/agent-skills/llm`, the package-skills plugin
169
+ - [[agent-skills]] — `@owlmeans/agent-skills/llm` package-skill resolver and parser
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/execution/service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAKlE,OAAO,KAAK,EACkB,gBAAgB,EAAE,uBAAuB,EAAE,cAAc,EACrD,oBAAoB,EACrD,MAAM,YAAY,CAAA;AAMnB;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,GAAI,CAAC,SAAS,cAAc,GAAG,cAAc,WAClE,uBAAuB,QAC1B,MAAM,gBAAgB,CAAC,CAAC,CAAC,KAC9B,gBAAgB,CAAC,CAAC,CAkIpB,CAAA;AAED,eAAO,MAAM,oBAAoB,GAAI,CAAC,SAAS,cAAc,GAAG,cAAc,UACrE,MAAM,YACJ,uBAAuB,KAC/B,gBAAgB,CAAC,CAAC,CAMpB,CAAA;AAED,eAAO,MAAM,sBAAsB,GACjC,CAAC,SAAS,WAAW,EAAE,CAAC,SAAS,YAAY,CAAC,CAAC,CAAC,EAAE,CAAC,SAAS,cAAc,GAAG,cAAc,OAEtF,CAAC,UACC,MAAM,YACJ,uBAAuB,KAC/B,CAAC,GAAG,oBAAoB,CAAC,CAAC,CAQ5B,CAAA"}
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/execution/service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAKlE,OAAO,KAAK,EACkB,gBAAgB,EAAE,uBAAuB,EAAE,cAAc,EACrD,oBAAoB,EACrD,MAAM,YAAY,CAAA;AAMnB;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,GAAI,CAAC,SAAS,cAAc,GAAG,cAAc,WAClE,uBAAuB,QAC1B,MAAM,gBAAgB,CAAC,CAAC,CAAC,KAC9B,gBAAgB,CAAC,CAAC,CAqKpB,CAAA;AAED,eAAO,MAAM,oBAAoB,GAAI,CAAC,SAAS,cAAc,GAAG,cAAc,UACrE,MAAM,YACJ,uBAAuB,KAC/B,gBAAgB,CAAC,CAAC,CAMpB,CAAA;AAED,eAAO,MAAM,sBAAsB,GACjC,CAAC,SAAS,WAAW,EAAE,CAAC,SAAS,YAAY,CAAC,CAAC,CAAC,EAAE,CAAC,SAAS,cAAc,GAAG,cAAc,OAEtF,CAAC,UACC,MAAM,YACJ,uBAAuB,KAC/B,CAAC,GAAG,oBAAoB,CAAC,CAAC,CAQ5B,CAAA"}
@@ -1,5 +1,5 @@
1
1
  import { createService } from '@owlmeans/context';
2
- import { ExecutionLevel } from '@owlmeans/llm-common';
2
+ import { ExecutionEffort, ExecutionLevel, UTILITY_ROLE } from '@owlmeans/llm-common';
3
3
  import { COLLABORATOR_KEYS, EXECUTION_SERVICE } from '../consts.js';
4
4
  import { composeExecState, composeTaskState, effortPatch, freeze, mergeOverride, mergePolicy, mergePrompt, resolveRole, } from './utils.js';
5
5
  /**
@@ -61,10 +61,13 @@ export const executionServiceApi = (options, self) => {
61
61
  return freeze(taskExec);
62
62
  },
63
63
  forHelper: (parent, input) => {
64
- const { role, effort, dedication, prompt, ...extras } = input;
64
+ const { role, effort, dedication, prompt, output, ...extras } = input;
65
65
  const localPolicy = effort != null ? mergePolicy(parent.policy, { effort }) : parent.policy;
66
66
  const scoped = { ...parent, policy: localPolicy };
67
67
  const merged = mergePrompt(parent.prompt, prompt);
68
+ // Destructured out of `extras` deliberately: `output` selects a model budget, it is
69
+ // not a field the helper carries around.
70
+ const sizing = output != null ? { maxTokens: output } : undefined;
68
71
  const helperExec = {
69
72
  ...parent,
70
73
  ...extras,
@@ -75,8 +78,8 @@ export const executionServiceApi = (options, self) => {
75
78
  policy: localPolicy,
76
79
  ...(merged != null ? { prompt: merged } : {}),
77
80
  role: resolveRole(localPolicy, role),
78
- model: self().model(scoped, role),
79
- temperatureFactory: self().temperatureFactory(scoped, role),
81
+ model: self().model(scoped, role, sizing),
82
+ temperatureFactory: self().temperatureFactory(scoped, role, sizing),
80
83
  };
81
84
  // A helper is not resumable — drop a parent task's composed state.
82
85
  delete helperExec.state;
@@ -93,19 +96,51 @@ export const executionServiceApi = (options, self) => {
93
96
  const clean = Object.fromEntries(Object.entries(merged).filter(([, value]) => value !== undefined));
94
97
  return exec.models().getModel(effectiveRole, clean);
95
98
  },
96
- temperatureFactory: (exec, role) => temperature => self().model(exec, role, {
99
+ utility: (exec, override) => {
100
+ // Delegated to `model` rather than re-resolved here: the utility tier has to obey
101
+ // the same roleOverride/modelOverride precedence as any other role, and a second
102
+ // copy of that ladder drifts from the first the moment one of them changes.
103
+ const scoped = {
104
+ ...exec, policy: mergePolicy(exec.policy, { effort: ExecutionEffort.Economy }),
105
+ };
106
+ return self().model(scoped, exec.policy.utilityRole ?? UTILITY_ROLE, override);
107
+ },
108
+ temperatureFactory: (exec, role, baseOverride) => temperature => self().model(exec, role, {
109
+ // A budget the helper was built with survives a temperature refinement — the
110
+ // work is the same size whether or not it is being retried creatively.
111
+ ...(typeof baseOverride === 'object' ? baseOverride : {}),
97
112
  ...(temperature != null ? { temperature } : {}),
98
113
  ...(temperature != null && temperature > 0.2 ? { topP: 0.8 } : {}),
99
114
  }),
100
115
  use: plugin => {
101
- plugins.push(plugin);
116
+ // Seated by alias when it has one: mixins compose, and a layer wired twice would otherwise
117
+ // answer twice — silently, since the first usable answer wins.
118
+ const at = plugin.alias != null
119
+ ? plugins.findIndex(entry => entry.alias === plugin.alias)
120
+ : -1;
121
+ if (at < 0) {
122
+ plugins.push(plugin);
123
+ }
124
+ else {
125
+ plugins[at] = plugin;
126
+ }
102
127
  },
103
- checkpoint: async (exec, key) => {
104
- if (plugins.length === 0) {
105
- return;
128
+ advise: async (exec, request) => {
129
+ for (const plugin of plugins) {
130
+ if (plugin.advise == null)
131
+ continue;
132
+ try {
133
+ const advice = await plugin.advise(exec, request);
134
+ if (advice != null && advice.trim() !== '') {
135
+ return advice;
136
+ }
137
+ }
138
+ catch (e) {
139
+ // Advice is an optimization. A broken advisor must never take the work with it.
140
+ console.warn(`Execution advisor failed for "${request.kind}":`, e);
141
+ }
106
142
  }
107
- const state = self().snapshot(exec);
108
- await Promise.all(plugins.map(plugin => plugin.onCheckpoint?.(state, exec, key)));
143
+ return null;
109
144
  },
110
145
  snapshot: exec => {
111
146
  if (exec.level === ExecutionLevel.Task) {