experimental-ash 0.16.3 → 0.18.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 +76 -0
- package/dist/docs/internals/schedule.md +37 -26
- package/dist/docs/public/channels/README.md +71 -0
- package/dist/docs/public/schedules.md +77 -49
- package/dist/src/channel/cross-channel-receive.d.ts +61 -0
- package/dist/src/channel/cross-channel-receive.js +50 -0
- package/dist/src/channel/receive-args.d.ts +17 -0
- package/dist/src/channel/receive-args.js +1 -0
- package/dist/src/channel/routes.d.ts +9 -0
- package/dist/src/channel/schedule.d.ts +45 -32
- package/dist/src/channel/schedule.js +56 -50
- package/dist/src/chunks/authored-module-loader-XcFLnl49.js +2 -0
- package/dist/src/chunks/{dev-authored-source-watcher-Tu9dhx5K.js → dev-authored-source-watcher-CG6kri3T.js} +1 -1
- package/dist/src/chunks/{host-C83crl7k.js → host-CIU0NATc.js} +6 -6
- package/dist/src/chunks/paths-CvbqpwTh.js +88 -0
- package/dist/src/chunks/{prewarm-CdxOi2uE.js → prewarm-C_Vd0JR7.js} +2 -2
- package/dist/src/cli/commands/info.js +1 -1
- package/dist/src/cli/run.js +1 -1
- package/dist/src/compiled/.vendor-stamp.json +1 -1
- package/dist/src/compiled/@vercel/sandbox/index.d.ts +8 -1
- package/dist/src/compiler/manifest.d.ts +6 -24
- package/dist/src/compiler/manifest.js +2 -8
- package/dist/src/compiler/normalize-channel.d.ts +0 -8
- package/dist/src/compiler/normalize-channel.js +0 -27
- package/dist/src/compiler/normalize-manifest.js +2 -10
- package/dist/src/compiler/normalize-schedule.d.ts +6 -12
- package/dist/src/compiler/normalize-schedule.js +9 -32
- package/dist/src/evals/cli/eval.js +1 -1
- package/dist/src/evals/runner/discover.js +1 -1
- package/dist/src/execution/sandbox/bindings/vercel.d.ts +2 -2
- package/dist/src/execution/sandbox/bindings/vercel.js +8 -1
- package/dist/src/internal/application/package.js +1 -1
- package/dist/src/internal/authored-definition/core.d.ts +3 -2
- package/dist/src/internal/authored-definition/core.js +20 -10
- package/dist/src/internal/authored-module-loader.d.ts +0 -6
- package/dist/src/internal/authored-module-loader.js +11 -72
- package/dist/src/internal/nitro/routes/agent-info/build-agent-info-response.js +3 -1
- package/dist/src/internal/nitro/routes/channel-dispatch.js +3 -0
- package/dist/src/internal/nitro/routes/runtime-stack.d.ts +0 -11
- package/dist/src/internal/nitro/routes/runtime-stack.js +0 -25
- package/dist/src/internal/nitro/routes/schedule-task.d.ts +3 -3
- package/dist/src/internal/nitro/routes/schedule-task.js +41 -11
- package/dist/src/public/channels/slack/index.d.ts +1 -1
- package/dist/src/public/channels/slack/slackChannel.d.ts +20 -1
- package/dist/src/public/channels/slack/slackChannel.js +25 -3
- package/dist/src/public/channels/twilio/twilioChannel.d.ts +2 -1
- package/dist/src/public/definitions/sandbox.d.ts +3 -3
- package/dist/src/public/definitions/schedule.d.ts +47 -50
- package/dist/src/public/definitions/schedule.js +10 -25
- package/dist/src/public/helpers/markdown.d.ts +6 -6
- package/dist/src/public/helpers/markdown.js +8 -8
- package/dist/src/public/sandbox/backends/vercel.d.ts +5 -5
- package/dist/src/public/sandbox/backends/vercel.js +3 -3
- package/dist/src/public/sandbox/index.d.ts +2 -2
- package/dist/src/public/sandbox/vercel-sandbox.d.ts +13 -0
- package/dist/src/public/schedules/index.d.ts +1 -1
- package/dist/src/public/schedules/index.js +1 -1
- package/dist/src/runtime/resolve-channel.js +1 -0
- package/dist/src/runtime/schedules/resolve-schedule.js +5 -5
- package/dist/src/runtime/types.d.ts +15 -10
- package/dist/src/shared/sandbox-backend.d.ts +7 -7
- package/dist/src/shared/sandbox-definition.d.ts +7 -12
- package/package.json +1 -1
- package/dist/src/chunks/authored-module-loader-Pt_g8xX2.js +0 -3
- package/dist/src/chunks/paths-KCBJzXn2.js +0 -88
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,81 @@
|
|
|
1
1
|
# experimental-ash
|
|
2
2
|
|
|
3
|
+
## 0.18.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7304b4d: Replace the schedule `channel: receive(slack, args)` declarative form with
|
|
8
|
+
a `run` handler that receives the same `args.receive(channel, …)` route
|
|
9
|
+
handlers got in the previous release. Schedules now author one of:
|
|
10
|
+
|
|
11
|
+
- `markdown: "..."` — fire-and-forget agent invocation (channel-less,
|
|
12
|
+
output discarded). Markdown frontmatter form (`.md`) maps here.
|
|
13
|
+
- `async run({ receive, waitUntil, appAuth }) { ... }` — full handler.
|
|
14
|
+
`receive(channel, ...)` is the same cross-channel surface route
|
|
15
|
+
handlers use; `waitUntil(promise)` extends the cron task lifetime.
|
|
16
|
+
|
|
17
|
+
Migration:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// before
|
|
21
|
+
export default defineSchedule({
|
|
22
|
+
cron: "0 9 * * 1-5",
|
|
23
|
+
markdown: "Post the digest.",
|
|
24
|
+
channel: receive(slack, { channelId: "C0123ABC" }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// after
|
|
28
|
+
export default defineSchedule({
|
|
29
|
+
cron: "0 9 * * 1-5",
|
|
30
|
+
async run({ receive, waitUntil, appAuth }) {
|
|
31
|
+
waitUntil(
|
|
32
|
+
receive(slack, {
|
|
33
|
+
message: "Post the digest.",
|
|
34
|
+
args: { channelId: "C0123ABC" },
|
|
35
|
+
auth: appAuth,
|
|
36
|
+
})
|
|
37
|
+
);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Pure-markdown schedules (both `.md` form and `.ts` form with `markdown`)
|
|
43
|
+
are unchanged.
|
|
44
|
+
|
|
45
|
+
Removed: the `receive(channel, args)` schedule helper, the
|
|
46
|
+
`ChannelReceiveRef` type, the `ChannelRouteIdentityMap` compile-time
|
|
47
|
+
plumbing, the `ash-channel-identity` Rolldown plugin, and the
|
|
48
|
+
`channel`/`schedule.channel` manifest entry. Authored schedule
|
|
49
|
+
identity is now derived entirely at runtime from the channel value the
|
|
50
|
+
handler imports, matching how `args.receive(channel, …)` already works
|
|
51
|
+
in route handlers.
|
|
52
|
+
|
|
53
|
+
## 0.17.0
|
|
54
|
+
|
|
55
|
+
### Minor Changes
|
|
56
|
+
|
|
57
|
+
- eaa612b: Add `args.receive(channel, …)` to route handlers for cross-channel
|
|
58
|
+
hand-off. A route handler can now start a session on a different
|
|
59
|
+
channel — e.g. an inbound HTTP webhook on one channel hands the
|
|
60
|
+
conversation off to Slack — by calling
|
|
61
|
+
`args.receive(targetChannel, { message, args, auth })`. The target
|
|
62
|
+
channel's authored `receive(input, { send })` hook owns the
|
|
63
|
+
continuation-token format and initial state; `auth` flows through to
|
|
64
|
+
`session.initiatorAuth`.
|
|
65
|
+
|
|
66
|
+
`slackChannel().receive` accepts an optional `initialPost` so callers
|
|
67
|
+
can post an anchor card before the agent runs; the agent's first turn
|
|
68
|
+
threads under that card. `threadTs` and `initialPost` are mutually
|
|
69
|
+
exclusive.
|
|
70
|
+
|
|
71
|
+
The schedule dispatcher's receive invocation now shares its
|
|
72
|
+
implementation with the route-handler path so both call sites stay
|
|
73
|
+
byte-identical.
|
|
74
|
+
|
|
75
|
+
### Patch Changes
|
|
76
|
+
|
|
77
|
+
- 38fd773: fix(ash): fix broken sandbox `bootstrap()` to be aligned with `onSession()` but apply snapshot-wide
|
|
78
|
+
|
|
3
79
|
## 0.16.3
|
|
4
80
|
|
|
5
81
|
### Patch Changes
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
# Schedules
|
|
2
2
|
|
|
3
3
|
Schedules let an authored agent run on a cron cadence. The runtime owns the
|
|
4
|
-
dispatch contract;
|
|
4
|
+
dispatch contract; channels (when an author hands work off to one) own
|
|
5
|
+
delivery.
|
|
5
6
|
|
|
6
7
|
## Authoring shape
|
|
7
8
|
|
|
8
9
|
Each schedule is a single file under `schedules/`:
|
|
9
10
|
|
|
10
11
|
- `schedules/<name>.{ts,cts,mts,js,cjs,mjs}` — TypeScript module with a default
|
|
11
|
-
export of `defineSchedule({ cron, markdown
|
|
12
|
+
export of `defineSchedule({ cron, markdown? | run? })`. Exactly one of
|
|
13
|
+
`markdown` (fire-and-forget) or `run` (handler) is provided.
|
|
12
14
|
- `schedules/<name>.md` — markdown with frontmatter (`cron:`) and the body as
|
|
13
|
-
the prompt.
|
|
15
|
+
the prompt. Always fire-and-forget; markdown form cannot declare `run`.
|
|
14
16
|
|
|
15
17
|
Recursive nesting is supported. The schedule name is derived from the relative
|
|
16
18
|
path under `schedules/` minus the file extension
|
|
@@ -28,40 +30,45 @@ to the unified `discoverNamedSourceDirectory` walker with
|
|
|
28
30
|
hydrates each `ScheduleSourceRef` into a `CompiledScheduleDefinition`:
|
|
29
31
|
|
|
30
32
|
- TypeScript modules go through `loadModuleBackedDefinition` and
|
|
31
|
-
`normalizeScheduleDefinition` (rejects unknown keys
|
|
33
|
+
`normalizeScheduleDefinition` (rejects unknown keys; enforces the
|
|
34
|
+
`markdown` / `run` exclusivity rule).
|
|
32
35
|
- Markdown sources have already been lowered at discovery time; the compiler
|
|
33
36
|
re-runs `normalizeScheduleDefinition` for type safety.
|
|
34
|
-
-
|
|
35
|
-
|
|
36
|
-
loaded
|
|
37
|
-
|
|
38
|
-
- The compiled `markdown` field is `definition.markdown.trim()`.
|
|
37
|
+
- The compiled entry carries `hasRun: boolean` plus an optional `markdown`
|
|
38
|
+
string. The schedule's TypeScript module (for `hasRun` schedules) is
|
|
39
|
+
loaded at dispatch time via the standard module map; the compiler does
|
|
40
|
+
not bake the handler into the manifest.
|
|
39
41
|
- The schedule name is derived from the logical path
|
|
40
42
|
(`stripLogicalPathExtension(...).replace(/^schedules\//, "")`).
|
|
41
43
|
|
|
42
44
|
## Runtime
|
|
43
45
|
|
|
44
|
-
`ScheduleDispatcher.trigger` (`packages/ash/src/channel/schedule.ts`)
|
|
45
|
-
|
|
46
|
+
`ScheduleDispatcher.trigger` (`packages/ash/src/channel/schedule.ts`) builds
|
|
47
|
+
`ScheduleHandlerArgs` (a subset of `RouteHandlerArgs`) against the
|
|
48
|
+
request-scoped channel bundle and routes on the schedule shape:
|
|
46
49
|
|
|
47
|
-
- **
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
- **Handler (`run`):** invoke `definition.run(args)`. The handler decides
|
|
51
|
+
control flow; `args.receive(channel, …)` hands the work off to a channel,
|
|
52
|
+
`args.waitUntil(promise)` keeps the Nitro task open until the promise
|
|
53
|
+
settles. `appAuth` is the framework app principal pre-built for
|
|
54
|
+
convenience.
|
|
55
|
+
- **Markdown:** the dispatcher synthesizes a channel-less run by calling
|
|
56
|
+
`runtime.run({ adapter: SCHEDULE_ADAPTER, mode: "task", auth: APP_AUTH,
|
|
57
|
+
input: { message: markdown } })`. Output is discarded; the agent can still
|
|
58
|
+
call tools, log, or hit backends.
|
|
54
59
|
|
|
55
60
|
Both branches use the framework app principal: `principalId: "ash:app"`,
|
|
56
61
|
`principalType: "runtime"`.
|
|
57
62
|
|
|
58
|
-
|
|
63
|
+
`SCHEDULE_ADAPTER` is registered in `FRAMEWORK_ADAPTERS`
|
|
64
|
+
(`runtime/channels/registry.ts`) so the adapter registry can rehydrate
|
|
65
|
+
`{ kind: "schedule" }` at every workflow step boundary.
|
|
59
66
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
67
|
+
## Why markdown form omits `run`
|
|
68
|
+
|
|
69
|
+
Markdown frontmatter has no JavaScript scope; declaring a handler requires
|
|
70
|
+
TypeScript anyway. Markdown stays the path for fire-and-forget prompts;
|
|
71
|
+
`.ts` schedules cover everything else.
|
|
65
72
|
|
|
66
73
|
## Nitro task wiring
|
|
67
74
|
|
|
@@ -73,7 +80,8 @@ event name plus a baked-in artifacts config.
|
|
|
73
80
|
|
|
74
81
|
## Tests
|
|
75
82
|
|
|
76
|
-
- `src/channel/schedule.test.ts` — dispatcher behavior
|
|
83
|
+
- `src/channel/schedule.test.ts` — dispatcher behavior for both handler and
|
|
84
|
+
markdown branches plus the `waitUntil` accumulator.
|
|
77
85
|
- `src/discover/agent.integration.test.ts` — discovery shapes including
|
|
78
86
|
recursive nesting, markdown form, .md/.ts collision, legacy migration
|
|
79
87
|
diagnostic, and unsupported leaves.
|
|
@@ -83,6 +91,9 @@ event name plus a baked-in artifacts config.
|
|
|
83
91
|
— runtime resolution and Nitro task registration with mixed module/markdown
|
|
84
92
|
schedules.
|
|
85
93
|
- `test/scenarios/compile-agent.scenario.test.ts` — end-to-end compile with
|
|
86
|
-
module + markdown schedules
|
|
94
|
+
module + markdown schedules in both forms.
|
|
95
|
+
- `test/scenarios/schedule-trigger.scenario.test.ts` — markdown schedule
|
|
96
|
+
end-to-end through the real dispatcher, including the SCHEDULE_ADAPTER
|
|
97
|
+
step-boundary rehydration.
|
|
87
98
|
- `test/scenarios/runtime-loaders.scenario.test.ts` — runtime artifact
|
|
88
99
|
loaders round-trip.
|
|
@@ -253,6 +253,77 @@ resolver when the allowed phone numbers come from dynamic state. Use `allowFrom:
|
|
|
253
253
|
|
|
254
254
|
See [Twilio channel setup](./twilio.md) for webhook URLs, environment variables, and overrides.
|
|
255
255
|
|
|
256
|
+
## Cross-Channel Hand-off
|
|
257
|
+
|
|
258
|
+
Route handlers can start a session on a different channel via `args.receive(channel, ...)`.
|
|
259
|
+
Use this when an inbound request on one channel should pivot the conversation onto another
|
|
260
|
+
-- for example, an incident webhook hits an HTTP route and the operator interacts in Slack.
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
import { defineChannel, POST } from "experimental-ash/channels";
|
|
264
|
+
import slack from "./slack.js";
|
|
265
|
+
|
|
266
|
+
export default defineChannel({
|
|
267
|
+
routes: [
|
|
268
|
+
POST("/incident", async (req, args) => {
|
|
269
|
+
const incident = await req.json();
|
|
270
|
+
args.waitUntil(
|
|
271
|
+
args.receive(slack, {
|
|
272
|
+
message: `Investigate ${incident.reference}: ${incident.title}`,
|
|
273
|
+
args: { channelId: "C0123ABC" },
|
|
274
|
+
auth: {
|
|
275
|
+
authenticator: "incidentio",
|
|
276
|
+
principalType: "service",
|
|
277
|
+
principalId: incident.actor.id,
|
|
278
|
+
attributes: { reference: incident.reference, severity: incident.severity },
|
|
279
|
+
},
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
return new Response("ok");
|
|
283
|
+
}),
|
|
284
|
+
],
|
|
285
|
+
});
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Semantics:
|
|
289
|
+
|
|
290
|
+
- The target channel's authored `receive(input, { send })` hook owns the continuation-token
|
|
291
|
+
format and the initial state. Callers supply only `{ message, args, auth }`.
|
|
292
|
+
- `auth` flows through to `session.initiatorAuth` so the target's event handlers and the
|
|
293
|
+
agent's tools can read who started the session.
|
|
294
|
+
- Calling `args.receive(...)` does **not** also start a session on the current channel. The
|
|
295
|
+
inbound channel's response is whatever the route handler returns explicitly (e.g.
|
|
296
|
+
`200 OK` to acknowledge the webhook).
|
|
297
|
+
- The first argument is the target channel module's default export -- import it directly
|
|
298
|
+
from `agent/channels/<name>.ts`. Identity is matched by reference.
|
|
299
|
+
|
|
300
|
+
Slack's `receive` accepts an optional `initialPost` so the target channel can post a card
|
|
301
|
+
before the agent runs; subsequent agent turns thread under that card.
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
import { Card, CardText } from "experimental-ash/channels/slack";
|
|
305
|
+
|
|
306
|
+
await args.receive(slack, {
|
|
307
|
+
message: "Begin investigation",
|
|
308
|
+
args: {
|
|
309
|
+
channelId: "C0123ABC",
|
|
310
|
+
initialPost: {
|
|
311
|
+
card: Card({ children: [CardText("Investigation Thread for INC-42")] }),
|
|
312
|
+
fallbackText: "Investigation Thread for INC-42",
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
auth,
|
|
316
|
+
});
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
`threadTs` and `initialPost` are mutually exclusive: pass `threadTs` to join an existing
|
|
320
|
+
thread, or `initialPost` to anchor a new one.
|
|
321
|
+
|
|
322
|
+
The card's `ts` becomes the session's continuation token, so any later `@mention` reply
|
|
323
|
+
inside that thread resumes the same session instead of starting a new one. Without
|
|
324
|
+
`initialPost`, programmatically-started Slack sessions have no thread to anchor under and
|
|
325
|
+
follow-up mentions land in a fresh session.
|
|
326
|
+
|
|
256
327
|
## File Uploads
|
|
257
328
|
|
|
258
329
|
`send()` accepts `string | UserContent`. To include file attachments,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "Schedules"
|
|
3
|
-
description: "Define recurring cron jobs that run an agent and optionally
|
|
3
|
+
description: "Define recurring cron jobs that run an agent and optionally hand off to a channel."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
Schedules let an agent initiate recurring work — digests, syncs, maintenance, heartbeat checks — on a cron cadence.
|
|
6
|
+
Schedules let an agent initiate recurring work — digests, syncs, maintenance, heartbeat checks — on a cron cadence. Each schedule is either a fire-and-forget markdown prompt or a TypeScript handler that decides what to do (typically by handing the work off to a channel).
|
|
7
7
|
|
|
8
8
|
Schedules live under `schedules/` at the root agent package.
|
|
9
9
|
|
|
@@ -11,27 +11,77 @@ Schedules live under `schedules/` at the root agent package.
|
|
|
11
11
|
|
|
12
12
|
- schedules are root-only — local subagents cannot declare `schedules/`
|
|
13
13
|
- a schedule is a single file: `<name>.ts` or `<name>.md`
|
|
14
|
-
-
|
|
15
|
-
-
|
|
14
|
+
- every schedule provides exactly one of `markdown` (fire-and-forget) or `run` (handler)
|
|
15
|
+
- markdown-form (`.md`) schedules cannot declare a handler — use `.ts` for that
|
|
16
16
|
|
|
17
17
|
## Authoring shapes
|
|
18
18
|
|
|
19
|
-
### TypeScript
|
|
19
|
+
### TypeScript handler (`run`)
|
|
20
|
+
|
|
21
|
+
Use a handler when the schedule should deliver to a channel, branch on conditions, or compute args at fire time. The handler receives a tight subset of `RouteHandlerArgs`:
|
|
20
22
|
|
|
21
23
|
```ts
|
|
22
24
|
// agent/schedules/daily-digest.ts
|
|
23
|
-
import { defineSchedule
|
|
25
|
+
import { defineSchedule } from "experimental-ash/schedules";
|
|
24
26
|
|
|
25
27
|
import slack from "../channels/slack.js";
|
|
26
28
|
|
|
27
29
|
export default defineSchedule({
|
|
28
30
|
cron: "0 9 * * 1-5",
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
async run({ receive, waitUntil, appAuth }) {
|
|
32
|
+
waitUntil(
|
|
33
|
+
receive(slack, {
|
|
34
|
+
message: "Summarize yesterday's activity and post the digest.",
|
|
35
|
+
args: { channelId: "C0123ABC" },
|
|
36
|
+
auth: appAuth,
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`ScheduleHandlerArgs`:
|
|
44
|
+
|
|
45
|
+
- `receive(channel, { message, args, auth })` — same contract as a route handler's [`args.receive`](./channels/README.md#cross-channel-hand-off). Hands the work off to a channel's authored `receive` hook.
|
|
46
|
+
- `waitUntil(promise)` — extends the cron task's lifetime past handler return so in-flight work settles before the Nitro task completes.
|
|
47
|
+
- `appAuth` — pre-built APP auth context (`{ authenticator: "app", principalId: "ash:app", principalType: "runtime" }`). Pass to `receive(..., { auth: appAuth })` for schedules that run on behalf of the agent itself.
|
|
48
|
+
|
|
49
|
+
#### Announce-then-deliver
|
|
50
|
+
|
|
51
|
+
A handler that hands off to Slack can post an anchor card before the agent runs so the thread shows "what this is about" up front; the agent's reply then lands threaded under that card. Same `initialPost` field [Slack's `receive`](./channels/README.md#cross-channel-hand-off) accepts from a route handler:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
// agent/schedules/deploy-digest.ts
|
|
55
|
+
import { defineSchedule } from "experimental-ash/schedules";
|
|
56
|
+
import { Card, CardText } from "experimental-ash/channels/slack";
|
|
57
|
+
|
|
58
|
+
import slack from "../channels/slack.js";
|
|
59
|
+
|
|
60
|
+
export default defineSchedule({
|
|
61
|
+
cron: "0 17 * * 1-5",
|
|
62
|
+
async run({ receive, waitUntil, appAuth }) {
|
|
63
|
+
waitUntil(
|
|
64
|
+
receive(slack, {
|
|
65
|
+
message: "Summarize today's production deploys.",
|
|
66
|
+
args: {
|
|
67
|
+
channelId: "C0123ABC",
|
|
68
|
+
initialPost: {
|
|
69
|
+
card: Card({ children: [CardText("Daily Deploy Digest")] }),
|
|
70
|
+
fallbackText: "Daily Deploy Digest",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
auth: appAuth,
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
},
|
|
31
77
|
});
|
|
32
78
|
```
|
|
33
79
|
|
|
34
|
-
|
|
80
|
+
`threadTs` and `initialPost` are mutually exclusive on Slack receive args. The anchor card's `ts` becomes the session's continuation token, so any later `@mention` reply in that thread resumes the same session — a digest that sparks a question can be answered in-thread without spinning up a new session.
|
|
81
|
+
|
|
82
|
+
### Markdown (`markdown`)
|
|
83
|
+
|
|
84
|
+
Fire-and-forget: the agent runs the prompt and the output is discarded. The agent is still free to call tools, write to backends, log, etc.
|
|
35
85
|
|
|
36
86
|
```ts
|
|
37
87
|
// agent/schedules/heartbeat.ts
|
|
@@ -43,7 +93,7 @@ export default defineSchedule({
|
|
|
43
93
|
});
|
|
44
94
|
```
|
|
45
95
|
|
|
46
|
-
|
|
96
|
+
Markdown frontmatter form:
|
|
47
97
|
|
|
48
98
|
```md
|
|
49
99
|
## <!-- agent/schedules/cleanup.md -->
|
|
@@ -53,7 +103,7 @@ export default defineSchedule({
|
|
|
53
103
|
Sweep stale workflow state.
|
|
54
104
|
```
|
|
55
105
|
|
|
56
|
-
The body is the prompt. The frontmatter accepts `cron` only
|
|
106
|
+
The body is the prompt. The frontmatter accepts `cron` only.
|
|
57
107
|
|
|
58
108
|
## Recursive nesting
|
|
59
109
|
|
|
@@ -73,62 +123,40 @@ agent/schedules/
|
|
|
73
123
|
```ts
|
|
74
124
|
interface ScheduleDefinition {
|
|
75
125
|
cron: string;
|
|
76
|
-
markdown
|
|
77
|
-
|
|
126
|
+
markdown?: string;
|
|
127
|
+
run?: (args: ScheduleHandlerArgs) => Promise<void> | void;
|
|
78
128
|
}
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
- `cron` (required) — a cron expression for when the schedule fires
|
|
82
|
-
- `markdown` (required) — the prompt the agent receives when the schedule runs
|
|
83
|
-
- `channel` (optional) — a typed channel reference produced by `receive(channel, args)`
|
|
84
|
-
|
|
85
|
-
The `markdown` field is the prompt body — the same convention used by `defineInstructions({ markdown })` and `defineSkill({ markdown })`.
|
|
86
|
-
|
|
87
|
-
## Channel targeting
|
|
88
|
-
|
|
89
|
-
When a schedule should deliver its output through a channel, use the `receive(channel, args)` helper from `experimental-ash/schedules`:
|
|
90
|
-
|
|
91
|
-
```ts
|
|
92
|
-
import { receive } from "experimental-ash/schedules";
|
|
93
|
-
import slack from "../channels/slack.js";
|
|
94
129
|
|
|
95
|
-
|
|
130
|
+
interface ScheduleHandlerArgs {
|
|
131
|
+
receive: CrossChannelReceiveFn;
|
|
132
|
+
waitUntil: (task: Promise<unknown>) => void;
|
|
133
|
+
appAuth: SessionAuthContext;
|
|
134
|
+
}
|
|
96
135
|
```
|
|
97
136
|
|
|
98
|
-
|
|
137
|
+
Exactly one of `markdown` or `run` must be provided. `defineSchedule` is a pass-through that exists to attach TypeScript types — it does not validate at authoring time; the compiler enforces the exclusivity rule.
|
|
99
138
|
|
|
100
139
|
## Runtime behavior
|
|
101
140
|
|
|
102
141
|
When a schedule fires, the dispatcher takes one of two paths:
|
|
103
142
|
|
|
104
|
-
###
|
|
143
|
+
### Handler form (`run`)
|
|
105
144
|
|
|
106
|
-
1. The dispatcher
|
|
107
|
-
2. It
|
|
108
|
-
3. The
|
|
145
|
+
1. The dispatcher loads the schedule's compiled module and reads `definition.run`.
|
|
146
|
+
2. It builds `ScheduleHandlerArgs` against the request-scoped channel bundle and invokes `run(args)`.
|
|
147
|
+
3. The handler decides everything: which channels to target, what auth to use, whether to short-circuit on holidays, etc.
|
|
148
|
+
4. Any promises the handler passes to `waitUntil` are awaited before the Nitro task settles.
|
|
109
149
|
|
|
110
|
-
###
|
|
150
|
+
### Markdown form
|
|
111
151
|
|
|
112
|
-
1. The dispatcher starts a session through the runtime directly with
|
|
152
|
+
1. The dispatcher starts a session through the runtime directly with the framework-owned `SCHEDULE_ADAPTER`.
|
|
113
153
|
2. The agent runs in **task mode** with the app principal.
|
|
114
|
-
3. Output is discarded
|
|
154
|
+
3. Output is discarded.
|
|
115
155
|
|
|
116
156
|
In both cases, the schedule session uses the **app principal**: `getSession().auth.current` and `getSession().auth.initiator` are set to `"ash:app"` with `principalType: "runtime"`.
|
|
117
157
|
|
|
118
158
|
The session goes through the same durable runtime engine as any other Ash session.
|
|
119
159
|
|
|
120
|
-
## Migrating from the legacy directory form
|
|
121
|
-
|
|
122
|
-
The directory form (`schedules/<name>/schedule.ts` + `schedules/<name>/prompt.md`) is no longer supported. To migrate, collapse each directory into a single file:
|
|
123
|
-
|
|
124
|
-
```text
|
|
125
|
-
agent/schedules/daily-digest/schedule.ts → agent/schedules/daily-digest.ts
|
|
126
|
-
agent/schedules/daily-digest/prompt.md → inlined as `markdown:` on
|
|
127
|
-
the `defineSchedule({...})` call
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
Discovery emits a clear migration diagnostic when it sees a legacy directory layout.
|
|
131
|
-
|
|
132
160
|
## When to use a schedule
|
|
133
161
|
|
|
134
162
|
Use a schedule when the agent should initiate work on its own cadence:
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { UserContent } from "ai";
|
|
2
|
+
import type { ChannelAdapter } from "#channel/adapter.js";
|
|
3
|
+
import type { CompiledChannel } from "#channel/compiled-channel.js";
|
|
4
|
+
import type { InferReceiveArgs } from "#channel/receive-args.js";
|
|
5
|
+
import type { Session } from "#channel/session.js";
|
|
6
|
+
import type { Runtime, SessionAuthContext } from "#channel/types.js";
|
|
7
|
+
/**
|
|
8
|
+
* Options accepted by {@link CrossChannelReceiveFn}. Mirrors the input
|
|
9
|
+
* argument of a channel's authored `receive(input, { send })` hook —
|
|
10
|
+
* the runtime constructs `send` internally so route-handler callers
|
|
11
|
+
* only supply the platform args, payload, and auth.
|
|
12
|
+
*/
|
|
13
|
+
export interface CrossChannelReceiveOptions<TArgs = Record<string, unknown>> {
|
|
14
|
+
readonly message: string | UserContent;
|
|
15
|
+
readonly args: TArgs;
|
|
16
|
+
readonly auth: SessionAuthContext | null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Starts a session on a different channel from inside a route handler.
|
|
20
|
+
* The target channel's authored `receive` hook owns continuation-token
|
|
21
|
+
* format and initial state; `auth` is forwarded verbatim and becomes
|
|
22
|
+
* `session.initiatorAuth`.
|
|
23
|
+
*/
|
|
24
|
+
export type CrossChannelReceiveFn = <TChannel>(channel: TChannel, options: CrossChannelReceiveOptions<InferReceiveArgs<TChannel>>) => Promise<Session>;
|
|
25
|
+
/**
|
|
26
|
+
* Channel record consumed by the receiver — keeps the public-facing
|
|
27
|
+
* `definition` reference so callers can identify a target by value
|
|
28
|
+
* (the same module-default they imported in their route file).
|
|
29
|
+
*/
|
|
30
|
+
export interface CrossChannelTarget {
|
|
31
|
+
readonly name: string;
|
|
32
|
+
readonly definition?: CompiledChannel;
|
|
33
|
+
readonly receive?: CompiledChannel["receive"];
|
|
34
|
+
readonly adapter?: ChannelAdapter;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Builds the `args.receive` closure used by every route handler. The
|
|
38
|
+
* closure resolves the target channel by reference identity against
|
|
39
|
+
* the request-scoped channel bundle, then delegates to the target's
|
|
40
|
+
* authored `receive` hook with a per-target `send` factory.
|
|
41
|
+
*/
|
|
42
|
+
export declare function createCrossChannelReceiveFn(runtime: Runtime, channels: readonly CrossChannelTarget[]): CrossChannelReceiveFn;
|
|
43
|
+
interface InvokeChannelReceiveInput {
|
|
44
|
+
readonly runtime: Runtime;
|
|
45
|
+
readonly target: Pick<CrossChannelTarget, "name" | "receive" | "adapter">;
|
|
46
|
+
readonly input: {
|
|
47
|
+
readonly message: string;
|
|
48
|
+
readonly args: Readonly<Record<string, unknown>>;
|
|
49
|
+
readonly auth: SessionAuthContext | null;
|
|
50
|
+
};
|
|
51
|
+
readonly describeMissingReceive: () => string;
|
|
52
|
+
readonly describeMissingAdapter: () => string;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Shared `receive(input, { send })` invocation used by both the route-
|
|
56
|
+
* handler cross-channel surface and the schedule dispatcher. Owns the
|
|
57
|
+
* receive/adapter precondition checks and the per-target `send`
|
|
58
|
+
* factory so both call sites stay byte-identical.
|
|
59
|
+
*/
|
|
60
|
+
export declare function invokeChannelReceive(args: InvokeChannelReceiveInput): Promise<Session>;
|
|
61
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createSendFn } from "#channel/send.js";
|
|
2
|
+
/**
|
|
3
|
+
* Builds the `args.receive` closure used by every route handler. The
|
|
4
|
+
* closure resolves the target channel by reference identity against
|
|
5
|
+
* the request-scoped channel bundle, then delegates to the target's
|
|
6
|
+
* authored `receive` hook with a per-target `send` factory.
|
|
7
|
+
*/
|
|
8
|
+
export function createCrossChannelReceiveFn(runtime, channels) {
|
|
9
|
+
return async (channel, options) => {
|
|
10
|
+
const target = resolveTargetByReference(channel, channels);
|
|
11
|
+
return await invokeChannelReceive({
|
|
12
|
+
runtime,
|
|
13
|
+
target,
|
|
14
|
+
input: {
|
|
15
|
+
message: options.message,
|
|
16
|
+
args: (options.args ?? {}),
|
|
17
|
+
auth: options.auth,
|
|
18
|
+
},
|
|
19
|
+
describeMissingReceive: () => `args.receive(): channel "${target.name}" does not implement receive(). ` +
|
|
20
|
+
`Declare a receive hook on the channel to accept cross-channel sessions.`,
|
|
21
|
+
describeMissingAdapter: () => `args.receive(): channel "${target.name}" has no adapter — cannot build send().`,
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Shared `receive(input, { send })` invocation used by both the route-
|
|
27
|
+
* handler cross-channel surface and the schedule dispatcher. Owns the
|
|
28
|
+
* receive/adapter precondition checks and the per-target `send`
|
|
29
|
+
* factory so both call sites stay byte-identical.
|
|
30
|
+
*/
|
|
31
|
+
export async function invokeChannelReceive(args) {
|
|
32
|
+
if (!args.target.receive) {
|
|
33
|
+
throw new Error(args.describeMissingReceive());
|
|
34
|
+
}
|
|
35
|
+
if (!args.target.adapter) {
|
|
36
|
+
throw new Error(args.describeMissingAdapter());
|
|
37
|
+
}
|
|
38
|
+
const send = createSendFn(args.runtime, args.target.adapter, args.target.name);
|
|
39
|
+
return await args.target.receive(args.input, { send });
|
|
40
|
+
}
|
|
41
|
+
function resolveTargetByReference(ref, channels) {
|
|
42
|
+
for (const channel of channels) {
|
|
43
|
+
if (channel.definition !== undefined && channel.definition === ref) {
|
|
44
|
+
return channel;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw new Error("args.receive(): the channel passed as the first argument is not registered " +
|
|
48
|
+
"in this agent's channels/. Import the channel module's default export from " +
|
|
49
|
+
"agent/channels/<name>.ts and pass that value.");
|
|
50
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
declare const receiveArgsMarker: unique symbol;
|
|
2
|
+
/**
|
|
3
|
+
* Structural marker attached by channel factories (e.g. `slackChannel`,
|
|
4
|
+
* `twilioChannel`) to advertise the args type their `receive()` accepts.
|
|
5
|
+
* `receive(channel, args)` helpers and the route-handler
|
|
6
|
+
* `args.receive(channel, ...)` use this marker to infer typed args
|
|
7
|
+
* from a plain channel import.
|
|
8
|
+
*/
|
|
9
|
+
export interface TypedReceiveRoute<TArgs = Record<string, unknown>> {
|
|
10
|
+
readonly [receiveArgsMarker]?: TArgs;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Extracts the receive-args type from a channel value, falling back to
|
|
14
|
+
* `Record<string, unknown>` when the channel does not advertise one.
|
|
15
|
+
*/
|
|
16
|
+
export type InferReceiveArgs<TChannel> = TChannel extends TypedReceiveRoute<infer TArgs> ? TArgs : Record<string, unknown>;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import type { UserContent } from "ai";
|
|
2
|
+
import type { CrossChannelReceiveFn } from "#channel/cross-channel-receive.js";
|
|
2
3
|
import type { SessionAuthContext } from "#channel/types.js";
|
|
3
4
|
import type { InputResponse } from "#runtime/input/types.js";
|
|
4
5
|
import type { Session } from "#channel/session.js";
|
|
5
6
|
export interface RouteHandlerArgs<TState = undefined> {
|
|
6
7
|
send: SendFn<TState>;
|
|
7
8
|
getSession: GetSessionFn;
|
|
9
|
+
/**
|
|
10
|
+
* Starts a session on a different channel. Use to hand off inbound
|
|
11
|
+
* work — e.g. an HTTP webhook receives a notification and pivots the
|
|
12
|
+
* conversation onto Slack. The target's authored `receive` hook owns
|
|
13
|
+
* continuation-token format and initial state; the caller supplies
|
|
14
|
+
* the payload, channel-specific args, and auth.
|
|
15
|
+
*/
|
|
16
|
+
receive: CrossChannelReceiveFn;
|
|
8
17
|
params: Readonly<Record<string, string>>;
|
|
9
18
|
waitUntil: (task: Promise<unknown>) => void;
|
|
10
19
|
requestIp: string | null;
|