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.
- package/CHANGELOG.md +36 -0
- package/dist/docs/internals/context.md +6 -12
- package/dist/docs/internals/hooks.md +15 -74
- package/dist/docs/internals/mechanical-invariants.md +3 -4
- package/dist/docs/internals/message-runtime.md +2 -3
- package/dist/docs/public/advanced/hooks.mdx +39 -76
- package/dist/docs/public/advanced/project-layout.md +1 -2
- package/dist/docs/public/advanced/typescript-api.md +1 -1
- package/dist/docs/public/channels/index.md +2 -2
- package/dist/docs/public/channels/slack.mdx +12 -17
- package/dist/docs/public/frontend/use-ash-agent.md +13 -17
- package/dist/src/channel/adapter.js +1 -1
- package/dist/src/channel/routes.d.ts +5 -7
- package/dist/src/channel/send.js +1 -1
- package/dist/src/channel/types.d.ts +3 -3
- package/dist/src/compiler/manifest.d.ts +1 -1
- package/dist/src/compiler/normalize-hook.d.ts +4 -4
- package/dist/src/context/build-dynamic-tools.d.ts +13 -0
- package/dist/src/context/build-dynamic-tools.js +1 -0
- package/dist/src/context/dynamic-instruction-lifecycle.d.ts +13 -13
- package/dist/src/context/dynamic-instruction-lifecycle.js +1 -1
- package/dist/src/context/dynamic-resolve-context.d.ts +12 -0
- package/dist/src/context/dynamic-resolve-context.js +1 -0
- package/dist/src/context/dynamic-skill-lifecycle.js +1 -1
- package/dist/src/context/dynamic-tool-lifecycle.d.ts +4 -10
- package/dist/src/context/dynamic-tool-lifecycle.js +1 -1
- package/dist/src/context/hook-lifecycle.d.ts +1 -46
- package/dist/src/context/hook-lifecycle.js +1 -1
- package/dist/src/context/keys.d.ts +30 -32
- package/dist/src/context/keys.js +1 -1
- package/dist/src/execution/create-session-step.d.ts +3 -4
- package/dist/src/execution/create-session-step.js +1 -1
- package/dist/src/execution/dispatch-runtime-actions-step.d.ts +2 -3
- package/dist/src/execution/dispatch-runtime-actions-step.js +1 -1
- package/dist/src/execution/durable-session-store.d.ts +24 -24
- package/dist/src/execution/durable-session-store.js +1 -1
- package/dist/src/execution/turn-workflow.d.ts +4 -5
- package/dist/src/execution/turn-workflow.js +1 -1
- package/dist/src/execution/workflow-entry.js +1 -1
- package/dist/src/execution/workflow-runtime.d.ts +1 -1
- package/dist/src/execution/workflow-steps.d.ts +1 -3
- package/dist/src/execution/workflow-steps.js +1 -1
- package/dist/src/harness/code-mode.js +1 -1
- package/dist/src/harness/compaction.js +1 -1
- package/dist/src/harness/messages.js +1 -1
- package/dist/src/harness/prompt-cache.d.ts +11 -1
- package/dist/src/harness/prompt-cache.js +1 -1
- package/dist/src/harness/step-hooks.js +1 -1
- package/dist/src/harness/tool-loop.js +1 -1
- package/dist/src/harness/types.d.ts +4 -5
- package/dist/src/internal/application/package.js +1 -1
- package/dist/src/packages/ash-scaffold/src/channels.js +1 -1
- package/dist/src/public/channels/ash.js +2 -2
- package/dist/src/public/channels/discord/discordChannel.d.ts +1 -2
- package/dist/src/public/channels/discord/discordChannel.js +1 -1
- package/dist/src/public/channels/discord/inbound.d.ts +0 -3
- package/dist/src/public/channels/discord/inbound.js +1 -1
- package/dist/src/public/channels/discord/index.d.ts +1 -1
- package/dist/src/public/channels/discord/index.js +1 -1
- package/dist/src/public/channels/slack/inbound.d.ts +4 -13
- package/dist/src/public/channels/slack/inbound.js +1 -1
- package/dist/src/public/channels/slack/slackChannel.d.ts +3 -7
- package/dist/src/public/channels/slack/slackChannel.js +1 -1
- package/dist/src/public/channels/teams/inbound.d.ts +0 -3
- package/dist/src/public/channels/teams/inbound.js +2 -2
- package/dist/src/public/channels/teams/index.d.ts +1 -1
- package/dist/src/public/channels/teams/index.js +1 -1
- package/dist/src/public/channels/teams/teamsChannel.d.ts +1 -2
- package/dist/src/public/channels/teams/teamsChannel.js +1 -1
- package/dist/src/public/channels/telegram/inbound.d.ts +0 -3
- package/dist/src/public/channels/telegram/inbound.js +1 -1
- package/dist/src/public/channels/telegram/index.d.ts +1 -1
- package/dist/src/public/channels/telegram/index.js +1 -1
- package/dist/src/public/channels/telegram/telegramChannel.d.ts +1 -2
- package/dist/src/public/channels/telegram/telegramChannel.js +1 -1
- package/dist/src/public/channels/twilio/inbound.d.ts +0 -3
- package/dist/src/public/channels/twilio/inbound.js +1 -1
- package/dist/src/public/channels/twilio/twilioChannel.js +1 -1
- package/dist/src/public/definitions/hook.d.ts +6 -22
- package/dist/src/public/definitions/instructions.d.ts +7 -12
- package/dist/src/public/definitions/skill.js +1 -1
- package/dist/src/public/hooks/index.d.ts +4 -5
- package/dist/src/runtime/graph.d.ts +2 -3
- package/dist/src/runtime/hooks/registry.d.ts +5 -20
- package/dist/src/runtime/hooks/registry.js +1 -1
- package/dist/src/runtime/resolve-hook.d.ts +2 -2
- package/dist/src/runtime/resolve-hook.js +1 -1
- package/dist/src/runtime/types.d.ts +3 -9
- package/dist/src/shared/dynamic-tool-definition.d.ts +20 -0
- package/dist/src/shared/dynamic-tool-definition.js +1 -1
- 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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
|
15
|
-
|
|
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 `
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
179
|
-
|
|
|
180
|
-
|
|
|
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
|
-
-
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
50
|
-
|
|
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
|
|
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
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
52
|
+
A hook file declares stream-event subscribers under the `events` map:
|
|
56
53
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
194
|
-
|
|
|
195
|
-
| `
|
|
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
|
|
212
|
-
|
|
|
213
|
-
|
|
|
214
|
-
|
|
|
215
|
-
|
|
|
216
|
-
|
|
|
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
|
|
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,
|
|
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 `
|
|
416
|
-
|
|
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,
|
|
192
|
-
|
|
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 `
|
|
216
|
-
background
|
|
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
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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,
|
|
250
|
+
return { auth, context };
|
|
256
251
|
},
|
|
257
252
|
});
|
|
258
253
|
```
|
|
259
254
|
|
|
260
|
-
`
|
|
261
|
-
|
|
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
|
|
197
|
-
It
|
|
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
|
|
200
|
+
Use dynamic instructions for server-side system prompt enrichment:
|
|
200
201
|
|
|
201
202
|
```ts
|
|
202
|
-
// agent/
|
|
203
|
-
import {
|
|
204
|
-
|
|
205
|
-
export default
|
|
206
|
-
|
|
207
|
-
async session(
|
|
208
|
-
return {
|
|
209
|
-
|
|
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){
|
|
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 {
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
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
|
package/dist/src/channel/send.js
CHANGED
|
@@ -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,
|
|
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};
|