experimental-ash 0.47.0 → 0.49.0

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 (91) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/docs/internals/context.md +6 -12
  3. package/dist/docs/internals/hooks.md +15 -74
  4. package/dist/docs/internals/mechanical-invariants.md +3 -4
  5. package/dist/docs/internals/message-runtime.md +2 -3
  6. package/dist/docs/public/advanced/hooks.mdx +39 -76
  7. package/dist/docs/public/advanced/project-layout.md +1 -2
  8. package/dist/docs/public/advanced/typescript-api.md +1 -1
  9. package/dist/docs/public/channels/index.md +2 -2
  10. package/dist/docs/public/channels/slack.mdx +12 -17
  11. package/dist/docs/public/frontend/use-ash-agent.md +13 -17
  12. package/dist/src/channel/adapter.js +1 -1
  13. package/dist/src/channel/routes.d.ts +5 -7
  14. package/dist/src/channel/send.js +1 -1
  15. package/dist/src/channel/types.d.ts +3 -3
  16. package/dist/src/compiler/manifest.d.ts +1 -1
  17. package/dist/src/compiler/normalize-hook.d.ts +4 -4
  18. package/dist/src/context/build-dynamic-tools.d.ts +13 -0
  19. package/dist/src/context/build-dynamic-tools.js +1 -0
  20. package/dist/src/context/dynamic-instruction-lifecycle.d.ts +13 -13
  21. package/dist/src/context/dynamic-instruction-lifecycle.js +1 -1
  22. package/dist/src/context/dynamic-resolve-context.d.ts +12 -0
  23. package/dist/src/context/dynamic-resolve-context.js +1 -0
  24. package/dist/src/context/dynamic-skill-lifecycle.js +1 -1
  25. package/dist/src/context/dynamic-tool-lifecycle.d.ts +4 -10
  26. package/dist/src/context/dynamic-tool-lifecycle.js +1 -1
  27. package/dist/src/context/hook-lifecycle.d.ts +1 -46
  28. package/dist/src/context/hook-lifecycle.js +1 -1
  29. package/dist/src/context/keys.d.ts +30 -32
  30. package/dist/src/context/keys.js +1 -1
  31. package/dist/src/execution/create-session-step.d.ts +3 -4
  32. package/dist/src/execution/create-session-step.js +1 -1
  33. package/dist/src/execution/dispatch-runtime-actions-step.d.ts +2 -3
  34. package/dist/src/execution/dispatch-runtime-actions-step.js +1 -1
  35. package/dist/src/execution/durable-session-store.d.ts +24 -24
  36. package/dist/src/execution/durable-session-store.js +1 -1
  37. package/dist/src/execution/turn-workflow.d.ts +4 -5
  38. package/dist/src/execution/turn-workflow.js +1 -1
  39. package/dist/src/execution/workflow-entry.js +1 -1
  40. package/dist/src/execution/workflow-runtime.d.ts +1 -1
  41. package/dist/src/execution/workflow-steps.d.ts +1 -3
  42. package/dist/src/execution/workflow-steps.js +1 -1
  43. package/dist/src/harness/code-mode.js +1 -1
  44. package/dist/src/harness/compaction.js +1 -1
  45. package/dist/src/harness/messages.js +1 -1
  46. package/dist/src/harness/prompt-cache.d.ts +11 -1
  47. package/dist/src/harness/prompt-cache.js +1 -1
  48. package/dist/src/harness/step-hooks.js +1 -1
  49. package/dist/src/harness/tool-loop.js +1 -1
  50. package/dist/src/harness/types.d.ts +4 -5
  51. package/dist/src/internal/application/package.js +1 -1
  52. package/dist/src/packages/ash-scaffold/src/channels.js +1 -1
  53. package/dist/src/public/channels/ash.js +2 -2
  54. package/dist/src/public/channels/discord/discordChannel.d.ts +1 -2
  55. package/dist/src/public/channels/discord/discordChannel.js +1 -1
  56. package/dist/src/public/channels/discord/inbound.d.ts +0 -3
  57. package/dist/src/public/channels/discord/inbound.js +1 -1
  58. package/dist/src/public/channels/discord/index.d.ts +1 -1
  59. package/dist/src/public/channels/discord/index.js +1 -1
  60. package/dist/src/public/channels/slack/inbound.d.ts +4 -13
  61. package/dist/src/public/channels/slack/inbound.js +1 -1
  62. package/dist/src/public/channels/slack/slackChannel.d.ts +3 -7
  63. package/dist/src/public/channels/slack/slackChannel.js +1 -1
  64. package/dist/src/public/channels/teams/inbound.d.ts +0 -3
  65. package/dist/src/public/channels/teams/inbound.js +2 -2
  66. package/dist/src/public/channels/teams/index.d.ts +1 -1
  67. package/dist/src/public/channels/teams/index.js +1 -1
  68. package/dist/src/public/channels/teams/teamsChannel.d.ts +1 -2
  69. package/dist/src/public/channels/teams/teamsChannel.js +1 -1
  70. package/dist/src/public/channels/telegram/inbound.d.ts +0 -3
  71. package/dist/src/public/channels/telegram/inbound.js +1 -1
  72. package/dist/src/public/channels/telegram/index.d.ts +1 -1
  73. package/dist/src/public/channels/telegram/index.js +1 -1
  74. package/dist/src/public/channels/telegram/telegramChannel.d.ts +1 -2
  75. package/dist/src/public/channels/telegram/telegramChannel.js +1 -1
  76. package/dist/src/public/channels/twilio/inbound.d.ts +0 -3
  77. package/dist/src/public/channels/twilio/inbound.js +1 -1
  78. package/dist/src/public/channels/twilio/twilioChannel.js +1 -1
  79. package/dist/src/public/definitions/hook.d.ts +6 -22
  80. package/dist/src/public/definitions/instructions.d.ts +7 -12
  81. package/dist/src/public/definitions/skill.js +1 -1
  82. package/dist/src/public/hooks/index.d.ts +4 -5
  83. package/dist/src/runtime/graph.d.ts +2 -3
  84. package/dist/src/runtime/hooks/registry.d.ts +5 -20
  85. package/dist/src/runtime/hooks/registry.js +1 -1
  86. package/dist/src/runtime/resolve-hook.d.ts +2 -2
  87. package/dist/src/runtime/resolve-hook.js +1 -1
  88. package/dist/src/runtime/types.d.ts +3 -9
  89. package/dist/src/shared/dynamic-tool-definition.d.ts +20 -0
  90. package/dist/src/shared/dynamic-tool-definition.js +1 -1
  91. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # experimental-ash
2
2
 
3
+ ## 0.49.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 72cd8be: Channels now deliver their inbound `<*_context>` block as a dedicated session-context entry instead of prepending it onto the user's turn message. This applies to Slack, Teams, Discord, Telegram, and Twilio so the model sees the actor/channel metadata as its own message ahead of the user text, and the user turn stays clean. The unused `prependTeamsContext`, `prependDiscordContext`, and `prependTelegramContext` helpers are removed from the public API; use the corresponding `format*ContextBlock` functions if you need to render a block yourself.
8
+ - a703ebb: Remove lifecycle hooks (`lifecycle.session`, `lifecycle.turn`) from the hook system. Hooks now only support stream-event subscribers (`events`). The `LifecycleHook`, `LifecycleHooks` types and `SessionPreparedKey` context key are removed. The `HookDefinition` interface no longer accepts a `lifecycle` field.
9
+
10
+ ### Patch Changes
11
+
12
+ - ce09ade: Carry durable session snapshots through Workflow step results and remove `ash.session` write plumbing from current runs. The `ash.session` stream remains only as a legacy read fallback for old stream-backed handles.
13
+
14
+ ## 0.48.0
15
+
16
+ ### Minor Changes
17
+
18
+ - 28259a5: Align dynamic tools, instructions, and skills with consistent additive semantics and strict role boundaries.
19
+
20
+ **Breaking changes:**
21
+
22
+ - `InstructionsDefinition.modelContext` removed — instructions produce system messages only via `markdown`
23
+ - `DeliverPayload.modelContext` replaced with `context: string[]` — channel context is now user messages persisted in session history
24
+ - `StepInput.modelContext` replaced with `context: string[]`
25
+ - `SendPayload.modelContext` replaced with `context: string[]`
26
+ - Dynamic instructions restricted to `session.started` and `turn.started` events (no `step.started`)
27
+ - Dynamic skills restricted to `session.started` and `turn.started` events (no `step.started`)
28
+ - Compaction output changed from `role: "system"` to a user/assistant turn in conversation history
29
+
30
+ **New features:**
31
+
32
+ - `defineInstructions` contributions persist across workflow step boundaries via scoped durable keys
33
+ - `defineSkill` stamps `SKILL_BRAND` for consistent entry detection (replaces duck-typing)
34
+ - System cache breakpoint added (4th Anthropic cache slot) to preserve system prefix when tools change
35
+ - Null-returning tool resolvers correctly clear durable metadata
36
+ - Skill announcements use virtual context with durable backing (rebuild from manifest on step boundaries)
37
+ - Shared `buildResolveContext` extracted for all three dynamic lifecycle dispatchers
38
+
3
39
  ## 0.47.0
4
40
 
5
41
  ### Minor Changes
@@ -45,7 +45,6 @@ Set by the runtime entry point. Serialized at `"use step"` boundaries via their
45
45
  | `ParentSessionKey` | `SessionParent` |
46
46
  | `ChannelKey` | `Channel` |
47
47
  | `BundleKey` | `CompiledBundle` |
48
- | `SessionPreparedKey` | `boolean` |
49
48
 
50
49
  ## Virtual Keys
51
50
 
@@ -77,17 +76,12 @@ Framework providers use a superset `FrameworkContextProvider` that also receives
77
76
  4. Run the callback inside `contextStorage.run(ctx, ...)`
78
77
  5. Call framework provider `commit` hooks for provider-owned session state
79
78
 
80
- Authored hook lifecycle dispatch (`lifecycle.session`, `lifecycle.turn`)
81
- runs inside step (4)'s ALS scope, before the harness step. Lifecycle
82
- hooks receive `(ctx)`, and event hooks receive `(event, ctx)`. See
83
- [Hooks](./hooks.md) for the full pipeline and the `SessionPreparedKey`
84
- flag's failure semantics.
85
-
86
- `StepInput.modelContext` has two producers. Channels can provide
87
- ephemeral messages through `SendPayload.modelContext`, and lifecycle
88
- hooks can provide them through `LifecycleHookResult.modelContext`.
89
- The merge order is channel first, then `lifecycle.session`, then
90
- `lifecycle.turn`. The merged messages are visible to the next model
79
+ Authored hook stream-event dispatch runs inside step (4)'s ALS scope.
80
+ Event hooks receive `(event, ctx)`. See [Hooks](./hooks.md) for the
81
+ full pipeline.
82
+
83
+ `StepInput.modelContext` is provided by channels through
84
+ `SendPayload.modelContext`. The messages are visible to the next model
91
85
  call only and are never persisted to durable session history.
92
86
 
93
87
  ## Channel Context
@@ -1,8 +1,7 @@
1
1
  # Hooks
2
2
 
3
3
  Hooks are the first-class extension point for authored agent code that needs
4
- to run **around** a turn instead of inside it. Tools answer the model;
5
- hooks answer the runtime.
4
+ to observe runtime events. Tools answer the model; hooks answer the runtime.
6
5
 
7
6
  This page explains the discovery → compile → resolve → dispatch pipeline
8
7
  and the runtime contract every authored hook handler relies on. The
@@ -11,8 +10,8 @@ end-user-facing reference lives at
11
10
 
12
11
  ## Architectural Rationale
13
12
 
14
- Plugins and authored agent code need to subscribe to the same lifecycle
15
- moments. Shipping hooks first closes the gap between "extension authored
13
+ Plugins and authored agent code need to subscribe to the same stream
14
+ events. Shipping hooks first closes the gap between "extension authored
16
15
  inside the agent" and "extension contributed by a plugin": both flow
17
16
  through one runtime registry, one `HookContext`, one set of ordering
18
17
  rules.
@@ -27,20 +26,14 @@ place to look when debugging.
27
26
  `id`, `auth`, `turn`, and `parent`). `session.auth` is a nested
28
27
  `SessionAuth` with `{ current, initiator }`.
29
28
 
30
- Lifecycle hooks receive `(ctx)` — a single `HookContext` argument.
31
29
  Event hooks receive `(event, ctx)` — event first, context last (omit `ctx` when unused).
32
- Per-turn `turnId` and `sequence` come from `ctx.session.turn` for
33
- lifecycle hooks, and from `event.data.{turnId, sequence}` for
30
+ Per-turn `turnId` and `sequence` come from `event.data.{turnId, sequence}` for
34
31
  stream-event hooks — never duplicated on the context.
35
32
 
36
33
  ## Authoring shape
37
34
 
38
35
  ```ts
39
36
  defineHook({
40
- lifecycle?: {
41
- session?: LifecycleHook;
42
- turn?: LifecycleHook;
43
- };
44
37
  events?: {
45
38
  [K in HandleMessageStreamEvent["type"]]?: StreamEventHook<…>;
46
39
  "*"?: StreamEventHook<HandleMessageStreamEvent>;
@@ -48,10 +41,9 @@ defineHook({
48
41
  });
49
42
  ```
50
43
 
51
- The split between `lifecycle:` and `events:` is structural so the
52
- contract is explicit at the call site. Both are side-effect-only — hooks
53
- cannot inject model context. Use `defineDynamic` + `defineInstructions`
54
- in `agent/instructions/` to contribute runtime model messages.
44
+ Hooks are observe-only they cannot inject model context. Use
45
+ `defineDynamic` + `defineInstructions` in `agent/instructions/` to
46
+ contribute runtime model messages.
55
47
 
56
48
  ## Pipeline
57
49
 
@@ -69,17 +61,14 @@ compiler performs no per-handler validation — hook handlers are
69
61
  arbitrary functions and can only be inspected at runtime.
70
62
 
71
63
  Resolve (`src/runtime/resolve-hook.ts`) loads the authored module
72
- export and reads its nested `lifecycle` and `events` maps directly. Each
73
- declared handler must be a function; mismatches throw
74
- `ResolveAgentError` so typos surface at boot. There is no hardcoded
75
- list of valid event types unknown event keys are accepted at resolve
76
- time and simply never fire at dispatch time.
64
+ export and reads its `events` map directly. Each declared handler must
65
+ be a function; mismatches throw `ResolveAgentError` so typos surface at
66
+ boot. There is no hardcoded list of valid event types — unknown event
67
+ keys are accepted at resolve time and simply never fire at dispatch time.
77
68
 
78
69
  Registry (`src/runtime/hooks/registry.ts`) builds a
79
70
  `RuntimeHookRegistry` per resolved agent node:
80
71
 
81
- - `session` — flat ordered array of `lifecycle.session` subscribers.
82
- - `turn` — flat ordered array of `lifecycle.turn` subscribers.
83
72
  - `streamEventsByType` — bucketed map keyed by event type.
84
73
  - `streamEventsWildcard` — flat array of `*` subscribers.
85
74
 
@@ -89,38 +78,6 @@ the workflow path.
89
78
 
90
79
  ## Dispatch
91
80
 
92
- Lifecycle dispatch lives in `src/context/hook-lifecycle.ts`. The
93
- workflow runtime entry point (`execution/workflow-steps.ts`) calls
94
- `runHookLifecycleStep` once per turn, **inside** the active ALS scope
95
- so hook code can read and write context like every other authored
96
- function. `runHookLifecycleStep` wraps `dispatchHookLifecycle`, lowers
97
- a `turn-failed` outcome into a parking `StepResult` (`{ next: null }`
98
- for conversation mode, `{ next: { done: true, output } }` for task
99
- mode), and otherwise hands off to the supplied harness step callback
100
- with the (possibly augmented) input.
101
-
102
- Dispatch is gated on `getHarnessEmissionState(session).turnId === ""`
103
- so tool-loop continuations and runtime-action resumes (which are
104
- continuations of an existing turn) skip the lifecycle stack.
105
-
106
- Stages, in order:
107
-
108
- 1. **`lifecycle.session`** runs sequentially in registry order, but
109
- only when `ash.sessionPrepared` is unset. The dispatcher sets the
110
- flag **before** running the chain so a thrown hook does not retry on
111
- the next turn — `lifecycle.session` errors are terminal session
112
- failures (`session.failed`).
113
- 2. **`lifecycle.turn`** runs sequentially. A thrown hook is
114
- caught here and lowered into a recoverable `turn.failed` cascade
115
- (`session.started` once → `turn.started` → `message.received` →
116
- `step.failed` → `turn.failed` → `session.waiting`).
117
-
118
- If an event hook subscribed to one of the failure-cascade events
119
- itself throws while the dispatcher is emitting the recoverable
120
- `turn.failed`, the throw escalates and the runtime emits
121
- `session.failed` instead. This is the bounded second-order behavior
122
- when both a `lifecycle.turn` and a `turn.failed` event hook fail.
123
-
124
81
  ### Stream event dispatch (`handleEvent`)
125
82
 
126
83
  Every stream event passes through one `handleEvent` function
@@ -151,13 +108,6 @@ right before each model call. See the [event dispatch
151
108
  doc](../../research/active/unified-event-dispatch.md) for the full
152
109
  design.
153
110
 
154
- ## `SessionPreparedKey`
155
-
156
- Lives in `src/context/keys.ts`. JSON-safe boolean, no codec. Set
157
- **before** the `lifecycle.session` chain runs so a thrown hook leaves
158
- the flag set — the next turn does not retry. Compaction does not clear
159
- the flag.
160
-
161
111
  ## Subagent Isolation
162
112
 
163
113
  Subagent hooks are fully isolated by structural construction:
@@ -175,19 +125,13 @@ The `discover/discover-subagent.ts` path walks each local subagent's
175
125
 
176
126
  ## Error Containment
177
127
 
178
- | Stage | On throw |
179
- | ------------------- | -------------------------------------------------------------------------------------- |
180
- | `lifecycle.session` | Re-thrown by dispatcherterminal session failure (`session.failed`) |
181
- | `lifecycle.turn` | Caught by dispatcher → recoverable `turn.failed` cascade emitted |
182
- | Stream-event hooks | Propagated through the emit composer → existing harness error path emits `turn.failed` |
128
+ | Stage | On throw |
129
+ | ------------------ | -------------------------------------------------------------------------------------- |
130
+ | Stream-event hooks | Propagated through the emit composer existing harness error path emits `turn.failed` |
183
131
 
184
132
  ### Full per-turn execution order
185
133
 
186
134
  ```
187
- dispatchHookLifecycle():
188
- lifecycle.session hooks (once per session, side-effect-only)
189
- lifecycle.turn hooks (once per turn, side-effect-only)
190
-
191
135
  emitTurnPreamble() via handleEvent:
192
136
  handleEvent(session.started) → emit, hooks, dynamic tool resolvers
193
137
  handleEvent(turn.started) → emit, hooks, dynamic tool resolvers
@@ -213,9 +157,8 @@ emitStepActions() via handleEvent:
213
157
  (`src/runtime/hooks/registry.ts` ⇄ `runtime/hooks/registry.test.ts`,
214
158
  `src/compiler/normalize-hook.ts` ⇄ `compiler/normalize-hook.test.ts`,
215
159
  `src/runtime/resolve-hook.ts` ⇄ `runtime/resolve-hook.test.ts`).
216
- - Lifecycle dispatcher behavior is covered by
160
+ - Stream-event dispatcher behavior is covered by
217
161
  `src/context/hook-lifecycle.integration.test.ts` (synthetic context,
218
- session/turn lifecycle dispatch, recoverable turn-failed path,
219
162
  stream-event fan-out, error propagation).
220
163
  - Discovery integration is folded into
221
164
  `src/discover/agent.integration.test.ts` (recursive walk,
@@ -231,8 +174,6 @@ documented expectations:
231
174
 
232
175
  - Hook modules never import workflow primitives (`start`, `resumeHook`,
233
176
  `createHook`, `getWritable`).
234
- - Hook return shapes have no `state` field — durable state goes through
235
- `ctx`.
236
177
  - Subagent hooks never read parent ALS context.
237
178
 
238
179
  ## Related Pages
@@ -92,10 +92,9 @@ hooks never read parent ALS context.
92
92
  Enforcement: rule 27 in `scripts/guard-agents-rules.mjs` fails if a
93
93
  `state:` (or `readonly state:`, `state?:`) field is declared on any
94
94
  type in `packages/ash/src/public/definitions/hook.ts`. The lint guards
95
- maintainers from accidentally widening `LifecycleHookResult` (which
96
- today exposes only `modelContext`) into a parallel state channel. The
97
- TypeScript surface itself rejects authored hooks that return any field
98
- the type does not declare.
95
+ maintainers from accidentally widening hook types into a parallel state
96
+ channel. The TypeScript surface itself rejects authored hooks that
97
+ return any field the type does not declare.
99
98
 
100
99
  The complementary "subagent hooks never read parent ALS context"
101
100
  invariant is enforced structurally by the `runStep` ALS-scope split
@@ -46,9 +46,8 @@ step.completed → turn.completed → session.waiting / session.completed
46
46
  ```
47
47
 
48
48
  Failure variants: `step.failed`, `turn.failed`, `session.failed`.
49
- A thrown `lifecycle.session` hook produces a terminal `session.failed`;
50
- a thrown `lifecycle.turn` hook produces a recoverable `turn.failed`
51
- cascade. See [Hooks](./hooks.md).
49
+ A thrown stream-event hook propagates through the emit composer; the
50
+ existing harness error path emits `turn.failed`. See [Hooks](./hooks.md).
52
51
 
53
52
  `message.appended` and `reasoning.appended` carry both delta and cumulative text. `step.completed.data.finishReason` mirrors the model-step outcome (`tool-calls` means the turn continues).
54
53
 
@@ -1,28 +1,25 @@
1
1
  ---
2
2
  title: "Hooks"
3
- description: "Subscribe to lifecycle moments and runtime stream events from agent/hooks/."
3
+ description: "Subscribe to runtime stream events from agent/hooks/."
4
4
  url: /hooks
5
5
  ---
6
6
 
7
- Hooks are Ash's authored extension points for the per-turn lifecycle and
8
- the runtime event stream.
7
+ Hooks are Ash's authored extension points for the runtime event stream.
9
8
 
10
9
  This page is about `agent/hooks/*.ts` runtime hooks. For the React client hook,
11
10
  see [`useAshAgent`](../frontend/use-ash-agent.md).
12
11
 
13
- Use a hook when you want to run code **around** a turn (before the model
14
- runs, around stream events) without writing a tool, a context provider,
15
- or a channel adapter handler.
12
+ Use a hook when you want to observe stream events (audit, metrics,
13
+ alerting) without writing a tool, a context provider, or a channel
14
+ adapter handler.
16
15
 
17
- <CopyPrompt text="Add an Ash hook to the user's project. Ash hooks live under agent/hooks, use defineHook from experimental-ash/hooks, and can subscribe to lifecycle.session, lifecycle.turn, or stream events. Use lifecycle hooks when the model needs ephemeral modelContext before the next model call; use events handlers for observe-only side effects after events are durably recorded. Inspect existing hooks and agent/lib, choose the smallest hook file and path-derived slug, use defineState only when durable shared state is needed, wrap best-effort side effects so they do not fail sessions accidentally, verify the relevant lifecycle or stream event behavior, and do not commit unless the user asks.">
16
+ <CopyPrompt text="Add an Ash hook to the user's project. Ash hooks live under agent/hooks, use defineHook from experimental-ash/hooks, and can subscribe to stream events. Use event handlers for observe-only side effects after events are durably recorded. Inspect existing hooks and agent/lib, choose the smallest hook file and path-derived slug, use defineState only when durable shared state is needed, wrap best-effort side effects so they do not fail sessions accidentally, verify the relevant stream event behavior, and do not commit unless the user asks.">
18
17
  Add an Ash hook to the user's project. Ash hooks live under agent/hooks, use defineHook from
19
- experimental-ash/hooks, and can subscribe to lifecycle.session, lifecycle.turn, or stream events.
20
- Use lifecycle hooks when the model needs ephemeral modelContext before the next model call; use
21
- events handlers for observe-only side effects after events are durably recorded. Inspect existing
22
- hooks and agent/lib, choose the smallest hook file and path-derived slug, use defineState only
23
- when durable shared state is needed, wrap best-effort side effects so they do not fail sessions
24
- accidentally, verify the relevant lifecycle or stream event behavior, and do not commit unless the
25
- user asks.
18
+ experimental-ash/hooks, and can subscribe to stream events. Use event handlers for observe-only
19
+ side effects after events are durably recorded. Inspect existing hooks and agent/lib, choose the
20
+ smallest hook file and path-derived slug, use defineState only when durable shared state is
21
+ needed, wrap best-effort side effects so they do not fail sessions accidentally, verify the
22
+ relevant stream event behavior, and do not commit unless the user asks.
26
23
  </CopyPrompt>
27
24
 
28
25
  ## The Main API
@@ -52,16 +49,27 @@ The slug is the path-relative basename: `agent/hooks/audit.ts` → `"audit"`,
52
49
 
53
50
  ## Shape
54
51
 
55
- A hook file declares two kinds of subscribers under named maps:
52
+ A hook file declares stream-event subscribers under the `events` map:
56
53
 
57
- - `lifecycle: { session?, turn? }` — runs **before** the harness step.
58
- Side-effect-only.
59
- - `events: { ... }` — runs **after** Ash has accepted a stream event
60
- and written it to the durable stream. Observe-only.
54
+ ```ts
55
+ defineHook({
56
+ events: {
57
+ "session.started"(event, ctx) {
58
+ /* ... */
59
+ },
60
+ "turn.completed"(event, ctx) {
61
+ /* ... */
62
+ },
63
+ "*"(event, ctx) {
64
+ /* wildcard — fires for every event */
65
+ },
66
+ },
67
+ });
68
+ ```
61
69
 
62
- The split is structural so the contract is explicit at a glance: code
63
- under `lifecycle:` may change what the model sees on the next call;
64
- code under `events:` cannot.
70
+ Handlers are observe-only they cannot inject model context. To
71
+ contribute runtime model messages, use `defineDynamic` +
72
+ `defineInstructions` in `agent/instructions/`.
65
73
 
66
74
  Every handler receives the same `HookContext`:
67
75
 
@@ -110,48 +118,6 @@ callId }` with `output` typed as the tool's `TOutput`. For connections
110
118
  it includes `{ output, toolName, connectionToolName, callId }` with
111
119
  `output` as `unknown`.
112
120
 
113
- ## Lifecycle
114
-
115
- Lifecycle hooks share one signature:
116
-
117
- ```ts
118
- type LifecycleHook = (ctx: HookContext) => void | Promise<void>;
119
- ```
120
-
121
- Session and turn metadata are available on `ctx.session`. For example,
122
- `ctx.session.turn.sequence` gives the current turn sequence number.
123
-
124
- `lifecycle.session` runs **once per durable session**, before the first
125
- model turn. `lifecycle.turn` runs **once per fresh delivery** — tool-loop
126
- continuations and HITL resumes do not re-fire it.
127
-
128
- Hooks are side-effect-only — they cannot inject model context. To
129
- contribute runtime model messages, use `defineDynamic` +
130
- `defineInstructions` in `agent/instructions/`.
131
-
132
- ### Seed durable context
133
-
134
- ```ts
135
- // agent/hooks/load-profile.ts
136
- import { defineState } from "experimental-ash/context";
137
- import { defineHook } from "experimental-ash/hooks";
138
- import { fetchGithubProfile, type GithubProfile } from "../lib/github";
139
-
140
- const githubProfile = defineState<GithubProfile | null>("myapp.githubProfile", () => null);
141
-
142
- export default defineHook({
143
- lifecycle: {
144
- async session(ctx) {
145
- const profile = await fetchGithubProfile(ctx.session.id);
146
- githubProfile.update(() => profile);
147
- },
148
- },
149
- });
150
- ```
151
-
152
- The profile is now visible to every tool, provider, and subsequent hook
153
- through `githubProfile.get()`.
154
-
155
121
  ## Stream Events
156
122
 
157
123
  Side-effect-only handlers for accepted runtime events. Subscribe by
@@ -190,11 +156,9 @@ Hooks always run **after** the event is durably recorded — if a hook throws, t
190
156
 
191
157
  ## Errors
192
158
 
193
- | Stage | On throw |
194
- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
195
- | `lifecycle.session` | Terminal session failure (`session.failed`). The flag is set before the chain runs, so the next turn does not retry. |
196
- | `lifecycle.turn` | Recoverable turn failure (`turn.failed`). Session keeps living. |
197
- | `events.*` | Propagates through the emit composer; the existing harness error path emits `turn.failed`. If the hook subscribed to a failure-cascade event also throws, the runtime escalates to `session.failed`. |
159
+ | Stage | On throw |
160
+ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
161
+ | `events.*` | Propagates through the emit composer; the existing harness error path emits `turn.failed`. If the hook subscribed to a failure-cascade event also throws, the runtime escalates to `session.failed`. |
198
162
 
199
163
  If you want belt-and-suspenders semantics inside a hook, wrap the body
200
164
  in `try`/`catch` — Ash treats a thrown hook as a real failure.
@@ -208,13 +172,12 @@ context.
208
172
 
209
173
  ## When to use a hook vs a tool vs a provider
210
174
 
211
- | Need | Use |
212
- | ---------------------------------------------------- | -------------------------------------------- |
213
- | Run before a turn (seed context, inject system text) | `lifecycle.session` / `lifecycle.turn` |
214
- | Observe runtime events (audit, metrics, alerting) | `events.<type>` (or channel adapter handler) |
215
- | Provide structured input to the model on demand | a tool |
216
- | Make a value available across the entire step | a context provider |
217
- | Subscribe to platform-specific events | a channel adapter handler |
175
+ | Need | Use |
176
+ | ------------------------------------------------- | -------------------------------------------- |
177
+ | Observe runtime events (audit, metrics, alerting) | `events.<type>` (or channel adapter handler) |
178
+ | Provide structured input to the model on demand | a tool |
179
+ | Make a value available across the entire step | a context provider |
180
+ | Subscribe to platform-specific events | a channel adapter handler |
218
181
 
219
182
  Stream-event hooks and channel adapter event handlers are structurally
220
183
  identical — choose the channel adapter handler when you are authoring
@@ -149,8 +149,7 @@ Use:
149
149
  - `instructions.md` (or `instructions.ts`) for always-on identity and behavior
150
150
  - `skills/` for optional procedures the model should load on demand
151
151
  - `tools/` for typed business logic and API calls
152
- - `hooks/` for lifecycle (`lifecycle.session`, `lifecycle.turn`) and
153
- stream-event subscribers
152
+ - `hooks/` for stream-event subscribers
154
153
  - `connections/` for external services exposed via MCP (model discovers and calls their tools)
155
154
  - `sandbox/` for sandbox overrides and seeded workspace files
156
155
  - `channels/` for HTTP or messaging ingress and delivery
@@ -112,7 +112,7 @@ Channel and Slack types exported from `experimental-ash/channels/slack`:
112
112
  `since: (message) => boolean` for a custom boundary.
113
113
  - `SlackInteractionAction` - action type for `onInteraction`
114
114
  - `SlackMentionResult` / `SlackInboundResult` - return type of `onAppMention` / `onDirectMessage`
115
- (`{ auth, modelContext? } | null`)
115
+ (`{ auth, context? } | null`)
116
116
  - `defaultSlackAuth(message, ctx)` - default Slack actor-to-session-auth projection
117
117
  - `Card`, `Button`, `Actions`, `Section`, `Modal`, `Table`, etc. - card builders re-exported for
118
118
  rendering Slack messages
@@ -412,8 +412,8 @@ await args.receive(slack, {
412
412
  ```
413
413
 
414
414
  For inbound mentions in an existing Slack thread, `onAppMention` and
415
- `onDirectMessage` may return `modelContext` to inject fetched thread
416
- history into the next model call without persisting it. See
415
+ `onDirectMessage` may return `context` to inject thread history as
416
+ user messages in session history. See
417
417
  [Slack thread context](/docs/channels/slack#thread-context).
418
418
 
419
419
  `threadTs` and `initialMessage` are mutually exclusive: pass `threadTs` to join an existing
@@ -188,8 +188,8 @@ export default slackChannel({
188
188
  - **`onAppMention(ctx, message)`** -- decides whether to dispatch a turn for an inbound
189
189
  `app_mention`, with what `auth` context, and runs any pre-dispatch side effects (typing
190
190
  indicators, logging, feature-flag lookups). Return `{ auth }` to dispatch or `null` to silently
191
- drop the mention. Return `{ auth, modelContext }` to add ephemeral messages to the next model
192
- call without writing them to durable session history. May be sync or async; the framework awaits
191
+ drop the mention. Return `{ auth, context }` to append context strings as user messages in
192
+ session history before the delivery message. May be sync or async; the framework awaits
193
193
  the result before dispatching. Thrown errors are caught, logged, and drop the mention -- wrap
194
194
  best-effort side effects in `try/catch` if you want them to be non-fatal. The default
195
195
  `onAppMention` derives a workspace-scoped auth from the Slack actor and posts a `"Thinking…"`
@@ -212,19 +212,18 @@ run inside the workflow context, not on the inbound webhook side.
212
212
 
213
213
  When the bot is mentioned in an existing Slack thread, the triggering mention is delivered to the
214
214
  agent by default, but prior thread replies are not injected automatically. Fetch thread history in
215
- `onAppMention` or `onDirectMessage` and return `modelContext` when the agent should see that
216
- background on the next model call.
215
+ `onAppMention` or `onDirectMessage` and return `context` when the agent should see that
216
+ background.
217
217
 
218
218
  Use `since: "last-agent-reply"` for repeated tags in the same thread. It returns only messages
219
219
  after the agent's last Slack reply and before the current mention, so the injected context stays
220
- small and can be modeled as a `role: "user"` message without changing the system prompt.
220
+ small.
221
221
 
222
222
  ```ts
223
223
  import {
224
224
  defaultSlackAuth,
225
225
  loadThreadContextMessages,
226
226
  slackChannel,
227
- type ModelMessage,
228
227
  } from "experimental-ash/channels/slack";
229
228
 
230
229
  export default slackChannel({
@@ -242,23 +241,19 @@ export default slackChannel({
242
241
  .map((entry) => `${entry.isMe ? "you" : (entry.user ?? "user")}: ${entry.markdown}`)
243
242
  .join("\n");
244
243
 
245
- const modelContext: ModelMessage[] = [
246
- {
247
- role: "user",
248
- content:
249
- "Recent Slack thread messages since your last reply, oldest first. " +
250
- "Use them as background context for the current mention.\n\n" +
251
- transcript,
252
- },
244
+ const context = [
245
+ "Recent Slack thread messages since your last reply, oldest first. " +
246
+ "Use them as background context for the current mention.\n\n" +
247
+ transcript,
253
248
  ];
254
249
 
255
- return { auth, modelContext };
250
+ return { auth, context };
256
251
  },
257
252
  });
258
253
  ```
259
254
 
260
- `modelContext` is one-shot context: it is used for the model call that dispatches this inbound
261
- message and is never persisted to `session.history`.
255
+ `context` strings are appended as user messages to `session.history` before the delivery message.
256
+ They persist across the session and are visible to the model on all subsequent turns.
262
257
 
263
258
  ### Direct Messages
264
259
 
@@ -193,26 +193,22 @@ const agent = useAshAgent({
193
193
  });
194
194
  ```
195
195
 
196
- `clientContext` is rendered as ephemeral model context for the next turn only.
197
- It is never persisted to durable message history.
196
+ `clientContext` is appended as user messages to session history before
197
+ the delivery message. It persists across the session and is visible
198
+ to the model on all subsequent turns (until compaction summarizes it).
198
199
 
199
- Use authored lifecycle hooks for server-side enrichment:
200
+ Use dynamic instructions for server-side system prompt enrichment:
200
201
 
201
202
  ```ts
202
- // agent/hooks/profile.ts
203
- import { defineHook } from "experimental-ash/hooks";
204
-
205
- export default defineHook({
206
- lifecycle: {
207
- async session(_input, ctx) {
208
- return {
209
- modelContext: [
210
- {
211
- role: "system",
212
- content: `Session ${ctx.session.sessionId} has server-side profile context.`,
213
- },
214
- ],
215
- };
203
+ // agent/instructions/profile.ts
204
+ import { defineDynamic, defineInstructions } from "experimental-ash/instructions";
205
+
206
+ export default defineDynamic({
207
+ events: {
208
+ async "session.started"(_event, ctx) {
209
+ return defineInstructions({
210
+ markdown: `Session ${ctx.session.id} has server-side profile context.`,
211
+ });
216
212
  },
217
213
  },
218
214
  });
@@ -1 +1 @@
1
- import{createLogger}from"#internal/logging.js";const log=createLogger(`channel.adapter`);function defaultDeliverResult(e){let{inputResponses:t,message:n,modelContext:r,outputSchema:i}=e;if(!(n===void 0&&!t?.length&&!r?.length&&i===void 0))return{inputResponses:t,message:n,modelContext:r,outputSchema:i}}function getAdapterKind(e){return e.kind}async function callAdapterEventHandler(e,n,r){let i=e[n.type];if(i===void 0)return n;try{await i(`data`in n?n.data:void 0,r)}catch(r){log.error(`adapter event handler threw — event swallowed`,{adapterKind:getAdapterKind(e),eventType:n.type,error:r})}return n}export{callAdapterEventHandler,defaultDeliverResult,getAdapterKind};
1
+ import{createLogger}from"#internal/logging.js";const log=createLogger(`channel.adapter`);function defaultDeliverResult(e){if(e.message!==void 0)return{inputResponses:e.inputResponses,message:e.message,context:e.context,outputSchema:e.outputSchema};if(e.inputResponses!==void 0&&e.inputResponses.length>0)return{inputResponses:e.inputResponses,context:e.context,outputSchema:e.outputSchema};if(e.context!==void 0&&e.context.length>0)return{context:e.context,outputSchema:e.outputSchema};if(e.outputSchema!==void 0)return{outputSchema:e.outputSchema}}function getAdapterKind(e){return e.kind}async function callAdapterEventHandler(e,t,n){let r=e[t.type];if(r===void 0)return t;try{await r(`data`in t?t.data:void 0,n)}catch(n){log.error(`adapter event handler threw — event swallowed`,{adapterKind:getAdapterKind(e),eventType:t.type,error:n})}return t}export{callAdapterEventHandler,defaultDeliverResult,getAdapterKind};
@@ -1,4 +1,4 @@
1
- import type { ModelMessage, UserContent } from "ai";
1
+ import type { UserContent } from "ai";
2
2
  import type { CrossChannelReceiveFn } from "#channel/cross-channel-receive.js";
3
3
  import type { SessionAuthContext, SessionCallback } from "#channel/types.js";
4
4
  import type { InputResponse } from "#runtime/input/types.js";
@@ -24,13 +24,11 @@ export interface SendPayload {
24
24
  readonly message?: string | UserContent;
25
25
  readonly inputResponses?: readonly InputResponse[];
26
26
  /**
27
- * Ephemeral messages contributed by the channel, appended to the
28
- * dispatched turn's first model call via `StepInput.modelContext`.
29
- * Never persisted to durable session history. Use `role: "system"`
30
- * for background context that should land before the user message;
31
- * other roles land after the user message.
27
+ * Context strings contributed by the channel. Each entry is appended
28
+ * as a `role: "user"` message to `session.history` before the
29
+ * delivery message, persisted across the session.
32
30
  */
33
- readonly modelContext?: readonly ModelMessage[];
31
+ readonly context?: readonly string[];
34
32
  /**
35
33
  * Run-scoped JSON schema the turn's result must match. Orthogonal to
36
34
  * {@link BaseSendOptions.mode}: a schema is enforced in either mode. Mode
@@ -1 +1 @@
1
- import{createSession}from"#channel/session.js";import{createLogger}from"#internal/logging.js";import{isRuntimeNoActiveSessionError}from"#execution/runtime-errors.js";import{serializeUrlFilePart}from"#internal/attachments/url-refs.js";const log=createLogger(`channel.send`);function createSendFn(t,r,i){return async(a,o)=>{let s=o.auth,c=o.callback,l=o.mode??`conversation`,u=o.state,d=o.continuationToken,f=`${i}:${d}`,{message:p,inputResponses:m,modelContext:h,outputSchema:g}=normalizeSendInput(a),_=serializeUrlFilePartsInMessage(p);try{let{sessionId:n}=await t.deliver({auth:s,continuationToken:f,payload:{inputResponses:m,message:_,modelContext:h,outputSchema:g}});return createSession(n,d,t)}catch(e){isRuntimeNoActiveSessionError(e)||log.warn(`deliver failed, falling back to starting a new session`,{continuationToken:f})}if(m&&m.length>0)throw Error(`Cannot deliver inputResponses — the target session was not found via continuation token.`);let v=u?{...r,state:{...r.state,...u}}:r;return createSession((await t.run({adapter:v,auth:s,capabilities:l===`conversation`?{requestInput:!0}:void 0,channelName:i,callback:c,continuationToken:f,input:{message:_??``,modelContext:h,outputSchema:g},mode:l})).sessionId,d,t)}}function serializeUrlFilePartsInMessage(e){if(e===void 0||typeof e==`string`)return e;let t=!1,n=e.map(e=>e.type===`file`&&e.data instanceof URL&&e.data.protocol!==`data:`?(t=!0,{...e,data:serializeUrlFilePart(e.data)}):e);return t?n:e}function normalizeSendInput(e){return typeof e==`string`||Array.isArray(e)?{message:e}:e}export{createSendFn};
1
+ import{createSession}from"#channel/session.js";import{createLogger}from"#internal/logging.js";import{isRuntimeNoActiveSessionError}from"#execution/runtime-errors.js";import{serializeUrlFilePart}from"#internal/attachments/url-refs.js";const log=createLogger(`channel.send`);function createSendFn(t,r,i){return async(a,o)=>{let s=o.auth,c=o.callback,l=o.mode??`conversation`,u=o.state,d=o.continuationToken,f=`${i}:${d}`,{message:p,inputResponses:m,context:h,outputSchema:g}=normalizeSendInput(a),_=serializeUrlFilePartsInMessage(p);try{let{sessionId:n}=await t.deliver({auth:s,continuationToken:f,payload:{inputResponses:m,message:_,context:h,outputSchema:g}});return createSession(n,d,t)}catch(e){isRuntimeNoActiveSessionError(e)||log.warn(`deliver failed, falling back to starting a new session`,{continuationToken:f})}if(m&&m.length>0)throw Error(`Cannot deliver inputResponses — the target session was not found via continuation token.`);let v=u?{...r,state:{...r.state,...u}}:r;return createSession((await t.run({adapter:v,auth:s,capabilities:l===`conversation`?{requestInput:!0}:void 0,channelName:i,callback:c,continuationToken:f,input:{message:_??``,context:h,outputSchema:g},mode:l})).sessionId,d,t)}}function serializeUrlFilePartsInMessage(e){if(e===void 0||typeof e==`string`)return e;let t=!1,n=e.map(e=>e.type===`file`&&e.data instanceof URL&&e.data.protocol!==`data:`?(t=!0,{...e,data:serializeUrlFilePart(e.data)}):e);return t?n:e}function normalizeSendInput(e){return typeof e==`string`||Array.isArray(e)?{message:e}:e}export{createSendFn};