@stratusagent/agents 0.11.3 → 0.11.5

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 (53) hide show
  1. package/dist/define.d.ts +22 -0
  2. package/dist/define.d.ts.map +1 -0
  3. package/dist/define.js +64 -0
  4. package/dist/define.js.map +1 -0
  5. package/dist/frontmatter.d.ts +65 -0
  6. package/dist/frontmatter.d.ts.map +1 -0
  7. package/dist/frontmatter.js +260 -0
  8. package/dist/frontmatter.js.map +1 -0
  9. package/dist/identity.d.ts +139 -0
  10. package/dist/identity.d.ts.map +1 -0
  11. package/dist/identity.js +259 -0
  12. package/dist/identity.js.map +1 -0
  13. package/dist/index.d.ts +11 -643
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +16 -1516
  16. package/dist/index.js.map +1 -1
  17. package/dist/router.d.ts +18 -0
  18. package/dist/router.d.ts.map +1 -0
  19. package/dist/router.js +44 -0
  20. package/dist/router.js.map +1 -0
  21. package/dist/schedules.d.ts +163 -0
  22. package/dist/schedules.d.ts.map +1 -0
  23. package/dist/schedules.js +215 -0
  24. package/dist/schedules.js.map +1 -0
  25. package/dist/skills.d.ts +135 -0
  26. package/dist/skills.d.ts.map +1 -0
  27. package/dist/skills.js +167 -0
  28. package/dist/skills.js.map +1 -0
  29. package/dist/soul.d.ts +44 -0
  30. package/dist/soul.d.ts.map +1 -0
  31. package/dist/soul.js +98 -0
  32. package/dist/soul.js.map +1 -0
  33. package/dist/tools/delegate.d.ts +93 -0
  34. package/dist/tools/delegate.d.ts.map +1 -0
  35. package/dist/tools/delegate.js +223 -0
  36. package/dist/tools/delegate.js.map +1 -0
  37. package/dist/tools/gateway-only.d.ts +19 -0
  38. package/dist/tools/gateway-only.d.ts.map +1 -0
  39. package/dist/tools/gateway-only.js +29 -0
  40. package/dist/tools/gateway-only.js.map +1 -0
  41. package/dist/tools/memory.d.ts +51 -0
  42. package/dist/tools/memory.d.ts.map +1 -0
  43. package/dist/tools/memory.js +306 -0
  44. package/dist/tools/memory.js.map +1 -0
  45. package/dist/tools/message.d.ts +26 -0
  46. package/dist/tools/message.d.ts.map +1 -0
  47. package/dist/tools/message.js +46 -0
  48. package/dist/tools/message.js.map +1 -0
  49. package/dist/tools/schedule.d.ts +20 -0
  50. package/dist/tools/schedule.d.ts.map +1 -0
  51. package/dist/tools/schedule.js +146 -0
  52. package/dist/tools/schedule.js.map +1 -0
  53. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,1517 +1,17 @@
1
- import { isListensMode, LISTENS_MODES, AgentRegistry, isTaintedTrust, leastTrusted, memoryEntryTrust, SENDER_TRUST_METADATA_KEY, sessionTaintedBy, sessionTrustOf, sessionWriteTrust, } from '@stratusagent/core';
2
- // First names only — agents should feel like a teammate you call by name.
3
- // Uniqueness comes from the generated id suffix, never from the name.
4
- const AGENT_NAMES = [
5
- 'Ada', 'Amara', 'Arlo', 'Asha', 'August', 'Beatrix', 'Caleb', 'Camila',
6
- 'Dara', 'Devon', 'Eleni', 'Elio', 'Esme', 'Felix', 'Freya', 'Gideon',
7
- 'Hana', 'Hugo', 'Imani', 'Ines', 'Jasper', 'Juno', 'Kai', 'Kira',
8
- 'Leandro', 'Lucia', 'Mabel', 'Mateo', 'Nadia', 'Nico', 'Odessa', 'Otis',
9
- 'Priya', 'Quinn', 'Rafael', 'Romy', 'Sana', 'Silas', 'Tamsin', 'Theo',
10
- 'Uma', 'Vera', 'Wesley', 'Xiomara', 'Yusuf', 'Zadie',
11
- 'Alba', 'Bruno', 'Celia', 'Dashiell', 'Edie', 'Ferris', 'Greta', 'Hollis',
12
- 'Ida', 'Jules', 'Koa', 'Lior', 'Marisol', 'Nyla', 'Oren', 'Paloma',
13
- 'Reza', 'Sunny', 'Tobias', 'Vada', 'Wren', 'Yara', 'Zeke',
14
- ];
15
- /**
16
- * The one house style every Stratus agent avatar is drawn in. Surfaces
17
- * (CLI, web, macOS) render this style; agents differ by their name-derived
18
- * hue and palette, so the team looks cohesive while each member is
19
- * recognizable.
20
- */
21
- export const AVATAR_STYLE = 'stratus';
22
- /** Deterministic 32-bit hash so the same seed always yields the same identity. */
23
- const hashSeed = (seed) => {
24
- let hash = 2166136261;
25
- for (let index = 0; index < seed.length; index += 1) {
26
- hash ^= seed.charCodeAt(index);
27
- hash = Math.imul(hash, 16777619);
28
- }
29
- return hash >>> 0;
30
- };
31
- const hslToHex = (hue, saturation, lightness) => {
32
- const s = saturation / 100;
33
- const l = lightness / 100;
34
- const k = (n) => (n + hue / 30) % 12;
35
- const a = s * Math.min(l, 1 - l);
36
- const channel = (n) => {
37
- const value = l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
38
- return Math.round(value * 255).toString(16).padStart(2, '0');
39
- };
40
- return `#${channel(0)}${channel(8)}${channel(4)}`;
41
- };
42
- /**
43
- * Generate a human-ish first name. Deterministic when a seed is supplied;
44
- * random otherwise.
45
- */
46
- export const generateAgentName = (seed) => {
47
- const value = seed !== undefined
48
- ? hashSeed(seed)
49
- : Math.floor(Math.random() * 0xffffffff);
50
- return AGENT_NAMES[value % AGENT_NAMES.length] ?? 'Quinn';
51
- };
52
- /**
53
- * Derive an avatar theme from a name: a stable hue and a small palette in
54
- * the shared Stratus house style. Consumers (CLI, web, macOS) draw the
55
- * actual image from this so the agent looks the same on every surface.
56
- */
57
- export const generateAvatarTheme = (name) => {
58
- const value = hashSeed(name);
59
- const hue = value % 360;
60
- return {
61
- seed: name,
62
- hue,
63
- palette: [
64
- hslToHex(hue, 70, 55),
65
- hslToHex((hue + 30) % 360, 65, 70),
66
- hslToHex((hue + 180) % 360, 60, 45),
67
- ],
68
- style: AVATAR_STYLE,
69
- };
70
- };
71
- const slugify = (name) => name
72
- .toLowerCase()
73
- .replace(/[^a-z0-9]+/g, '-')
74
- .replace(/^-+|-+$/g, '') || 'agent';
75
- /**
76
- * The shape an id is *minted* in: lowercase alphanumerics and internal
77
- * hyphens, starting with an alphanumeric. Everything `slugify` produces
78
- * matches it, so every id this module derives does.
79
- *
80
- * Not what validation enforces. A hand-written `id:` predates any shape
81
- * rule and `Ava_1` was as valid as `ava` — see `isValidAgentId`.
82
- */
83
- export const AGENT_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
84
- /**
85
- * How long an id we *build* is allowed to get. A construction bound, not a
86
- * validation rule — `isValidAgentId` deliberately does not check it.
87
- *
88
- * Every id derived from a long name predates this bound, and enforcing it
89
- * retroactively drops working agents off the roster, while trimming them
90
- * silently re-keys their sessions, memory, and credentials to an id nobody
91
- * has ever used. So the bound applies where an id is minted fresh and
92
- * nothing is keyed to it yet, and nowhere else. File systems still have
93
- * limits, and an id long enough to hit them fails at the join with a name
94
- * that says so.
95
- */
96
- export const MAX_AGENT_ID_LENGTH = 64;
97
- /**
98
- * What an id may never be, whatever else it is: anything that stops being
99
- * a single, addressable path segment. A separator escapes the directory,
100
- * a leading dot hides the file or walks up out of it (`..`), and a control
101
- * character is not typeable back.
102
- */
103
- const PATH_UNSAFE_ID = /[/\\]|[\u0000-\u001f\u007f]/;
104
- /**
105
- * The safety half of every id rule here, shared because agent ids and
106
- * session ids answer it identically and a second copy would drift from the
107
- * first the next time one of them learned something.
108
- *
109
- * Safety only: what an id may never be. Shape — the slug pattern, a length
110
- * bound — is per-kind and stays with the kind, because the reasons differ.
111
- */
112
- const isAddressableId = (id) => id.length > 0
113
- // Invisible leading or trailing space cannot be typed back reliably, so
114
- // an id that is not its own trimmed self is a mistake, not a legacy.
115
- && id === id.trim()
116
- && !id.startsWith('.')
117
- && !PATH_UNSAFE_ID.test(id)
118
- // An id keys plain objects too — `credentials.channels.slack[id]` among
119
- // them — where an inherited name is not a free slot. `toString` reads as
120
- // already connected with nothing stored, and `__proto__` assigns through
121
- // to the prototype, so the write lands nowhere and `JSON.stringify` drops
122
- // it. `in {}` names exactly that set, and names it by the property it
123
- // has rather than by a list to keep in step.
124
- && !(id in {});
125
- /**
126
- * Whether `id` is safe to key an agent's resources and paths by.
127
- *
128
- * Ids are not labels. They key sessions, memory, credentials, Slack channel
129
- * tokens, and every per-agent path on disk — and an explicit frontmatter
130
- * `id` is untrusted input: a soul file travels with a repository, and in
131
- * the hosted profile it comes from a tenant. `id: ../../escape` reaches
132
- * every one of those joins intact, so it is stopped once here rather than
133
- * defended at each of them.
134
- *
135
- * **Unsafe is the rule, not un-sluglike.** Nothing before this checked an
136
- * explicit id at all, so `Ava_1`, `team.alpha`, and `AVA` are all out there
137
- * keying real sessions and real credentials. Holding them to the shape ids
138
- * are minted in would not make anything safer — none of them can leave
139
- * their directory — and `loadRosterSouls` degrades a soul that will not
140
- * parse to a warning, so the only thing it would accomplish is dropping
141
- * those agents off the roster on upgrade, quietly, while their data stays
142
- * behind under the old id. Reserving the slug shape for ids nobody has
143
- * chosen yet costs nothing; imposing it on ids people are already using
144
- * costs them their agent.
145
- *
146
- * Rejected, never sanitized: rewriting `../../escape` into `escape` hands
147
- * back an agent nobody asked for, keyed to resources nobody named.
148
- */
149
- export const isValidAgentId = (id) => isAddressableId(id);
150
- /**
151
- * How much of a new session id is the *client's* to spend, on top of the
152
- * agent id it addresses.
153
- *
154
- * Every id in circulation is a colon-joined address that embeds the agent
155
- * id — `web:<agentId>:<uuid>` from the dashboard, a channel key of
156
- * `<channel>:<agentId>:<team>:<conversation>:<thread>`, with a bare UUID
157
- * from the CLI the one exception. So a flat cap is the wrong shape: agent
158
- * ids are deliberately unbounded (see {@link isValidAgentId}), and a flat
159
- * 200 refused every dashboard conversation for an agent whose id ran past
160
- * 159 characters — an agent that stays on the roster and simply stops being
161
- * chattable, which is the quiet-breakage failure the unbounded rule exists
162
- * to avoid.
163
- *
164
- * Budgeting the composition instead keeps the bound meaningful without
165
- * capping the agent id through the back door. It cannot be gamed to widen
166
- * itself: the allowance comes from an agent id already checked against the
167
- * roster, not from the caller's own string.
168
- */
169
- export const MAX_SESSION_ID_LENGTH = 200;
170
- /**
171
- * Ids that are a client's bug rendered as text, not an address anyone chose.
172
- *
173
- * Each is what JavaScript prints when an id was never computed — an
174
- * unresolved variable, a null field, arithmetic that went sideways, an
175
- * object concatenated into a template. The dashboard shipped the first one:
176
- * `/sessions/undefined/messages`, accepted, and a durable conversation
177
- * literally named `undefined`.
178
- *
179
- * A blocklist rather than a pattern, because these are safe, well-formed
180
- * strings — nothing about their *shape* is wrong, which is exactly why no
181
- * shape rule catches them. Kept to the strings the language itself
182
- * produces: guessing at what else looks like a mistake would start refusing
183
- * ids people meant.
184
- */
185
- const STRINGIFIED_NOTHING = new Set(['undefined', 'null', 'NaN', '[object Object]']);
186
- /**
187
- * Whether `id` may open a *new* conversation.
188
- *
189
- * Session ids are client-minted and a `POST` to an unknown one creates it,
190
- * so this is the only thing standing between an HTTP caller and a durable
191
- * row. `undefined`, `null`, and a bare space have all reached the session
192
- * table through that door — the dashboard shipped the first of them.
193
- *
194
- * **Checked where an id is minted, never where one is used.** An id already
195
- * in the store addresses a real conversation, whatever shape it is, and
196
- * re-validating on read would lock someone out of their own history to
197
- * enforce a rule that postdates it. Same reasoning as
198
- * {@link MAX_AGENT_ID_LENGTH}, which is why the length bound lives here and
199
- * not in {@link isValidAgentId}.
200
- *
201
- * `agentId` is the agent the id addresses, when the caller knows it — every
202
- * client convention embeds it, so its length is part of the budget rather
203
- * than something the bound gets to cap. The allowance applies only to an id
204
- * that actually contains it, since those are the characters being paid for;
205
- * a bare UUID and an unrelated string are both held to
206
- * {@link MAX_SESSION_ID_LENGTH} alone, as they are when `agentId` is
207
- * omitted.
208
- *
209
- * Shape is deliberately not enforced. Every id above is a legitimate
210
- * address, and no pattern admitting all of them excludes `undefined` — so
211
- * the safety rule, a bound, and {@link STRINGIFIED_NOTHING} are what catch
212
- * the real mistakes. Rejected, never sanitized: trimming an id hands the
213
- * caller back a conversation at an address it never asked for.
214
- */
215
- export const isValidSessionId = (id, agentId) => isAddressableId(id)
216
- // The allowance is for an id that actually *spends* those characters on
217
- // the agent id. Granting it merely because the request names a long-id
218
- // agent would let that agent's existence buy an unrelated caller 300 more
219
- // characters of junk — the durable-garbage case the bound is here to stop.
220
- && id.length <= MAX_SESSION_ID_LENGTH
221
- + (agentId !== undefined && id.includes(agentId) ? agentId.length : 0)
222
- && !STRINGIFIED_NOTHING.has(id);
223
- /**
224
- * A freshly minted id, bounded — `base` trimmed so that appending `suffix`
225
- * still fits, with any hyphen left dangling by the trim removed.
226
- *
227
- * Only for ids nothing is keyed to yet: a generated agent's
228
- * name-plus-suffix, and `agent new` retrying a filename collision. Both mint
229
- * an id in the same breath as the agent, so trimming takes nothing away.
230
- * Shared rather than re-derived so the two do not each own half of the bound
231
- * and drift. An id derived from a name someone chose does *not* come through
232
- * here — that name may already have an agent behind it.
233
- */
234
- export const agentIdWithSuffix = (base, suffix) => {
235
- const tail = suffix ? `-${suffix}` : '';
236
- const room = Math.max(1, MAX_AGENT_ID_LENGTH - tail.length);
237
- return `${base.slice(0, room).replace(/-+$/, '') || 'agent'}${tail}`;
238
- };
239
- // Ids for generated names carry a short unique suffix: the name pool is
240
- // small, and two agents that draw the same name must still be two people —
241
- // memory and access scopes are keyed by id. Deterministic when seeded.
242
- const generatedIdSuffix = (seed) => {
243
- const value = seed !== undefined
244
- ? hashSeed(`${seed}:id`)
245
- : Math.floor(Math.random() * 0xffffffff);
246
- return value.toString(36).padStart(4, '0').slice(0, 4);
247
- };
248
- /**
249
- * One-call agent creation. Everything is optional: with no input you get a
250
- * fresh identity with a human-ish name and a matching avatar theme.
251
- * Explicitly named agents keep a clean, predictable slug id.
252
- */
253
- export const defineAgent = (input = {}) => {
254
- const nameWasGenerated = input.name === undefined;
255
- const name = input.name ?? generateAgentName(input.seed);
256
- // Only an explicit id is checked: a derived one comes out of slugify,
257
- // which cannot produce anything unsafe.
258
- if (input.id !== undefined && !isValidAgentId(input.id)) {
259
- throw new Error(`Invalid agent id: ${JSON.stringify(input.id)}. An id becomes a path segment, so it may not start with `
260
- + 'a dot or contain a slash, a backslash, a control character, or leading or trailing whitespace — '
261
- + 'it keys files and credentials, not just labels.');
262
- }
263
- // A chosen name's slug is used whole. It is not this function's to
264
- // shorten: the same name has resolved to the same id in every release,
265
- // and two long names that differ only past the bound are two agents,
266
- // not one roster-refusing collision.
267
- const derived = nameWasGenerated
268
- ? agentIdWithSuffix(slugify(name), generatedIdSuffix(input.seed))
269
- : slugify(name);
270
- return {
271
- // Derived ids are safe by construction with one exception: `constructor`
272
- // is a perfectly ordinary slug and an Object.prototype key, so the name
273
- // "Constructor" reaches a rule that only ran on explicit ids. Checked
274
- // rather than special-cased, so what this returns is *an id that
275
- // validates* — not one that passes the cases anyone thought of. The
276
- // suffix is seeded by the slug, so the name still answers the same way
277
- // every time.
278
- id: input.id ?? (isValidAgentId(derived)
279
- ? derived
280
- : agentIdWithSuffix(derived, generatedIdSuffix(derived))),
281
- name,
282
- ...(input.instructions ? { instructions: input.instructions } : {}),
283
- avatar: input.avatar ?? generateAvatarTheme(name),
284
- ...(input.tools ? { tools: input.tools } : {}),
285
- ...(input.skills ? { skills: input.skills } : {}),
286
- ...(input.credentials ? { credentials: input.credentials } : {}),
287
- ...(input.listens ? { listens: input.listens } : {}),
288
- };
289
- };
290
- const SOUL_SCALAR_KEYS = ['name', 'id', 'provider', 'model', 'listens'];
291
- const SOUL_LIST_KEYS = ['tools', 'skills', 'credentials'];
292
- const unquote = (value) => {
293
- const trimmed = value.trim();
294
- if (trimmed.length >= 2 &&
295
- ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
296
- (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
297
- return trimmed.slice(1, -1);
298
- }
299
- return trimmed;
300
- };
301
- // A deliberately tiny frontmatter dialect: `key: value` scalars and block
302
- // lists of `- item` lines. Enough for souls and skills, no YAML dependency.
303
- // Tolerant mode (see FrontmatterShape) additionally reads the block-scalar
304
- // forms (`description: >-` and friends) and plain multi-line scalars, and
305
- // skips unknown keys with their nested blocks.
306
- const parseFrontmatterLines = (lines, shape) => {
307
- const scalars = {};
308
- const lists = {};
309
- const maps = {};
310
- const unknownKeys = [];
311
- const tolerant = shape.tolerant ?? false;
312
- let currentList;
313
- let currentMap;
314
- // Tolerant-mode line context: an unknown key whose indented block is
315
- // being skipped, or a known scalar whose value continues on indented
316
- // lines (a YAML block scalar, or a plain scalar that wraps). Inside
317
- // either, blank lines and lines starting with # are content or skipped
318
- // block — never the comments the top level treats them as — so both are
319
- // handled before the comment filter below.
320
- let skippingUnknownBlock = false;
321
- let continuation;
322
- const isIndented = (line) => /^\s/.test(line);
323
- for (const rawLine of lines) {
324
- const line = rawLine.trimEnd();
325
- const blank = line.trim().length === 0;
326
- if (tolerant && skippingUnknownBlock && (blank || isIndented(line))) {
327
- continue;
328
- }
329
- if (tolerant && continuation) {
330
- if (blank) {
331
- // A blank line is a line break in a literal block and a paragraph
332
- // break in a folded or plain one — never the end of the value.
333
- if (continuation.literal) {
334
- const existing = scalars[continuation.key] ?? '';
335
- if (existing.length > 0) {
336
- scalars[continuation.key] = `${existing}\n`;
337
- }
338
- }
339
- else {
340
- continuation.pendingBreak = true;
341
- }
342
- continue;
343
- }
344
- if (isIndented(line)) {
345
- const existing = scalars[continuation.key] ?? '';
346
- const separator = continuation.literal || continuation.pendingBreak ? '\n' : ' ';
347
- continuation.pendingBreak = false;
348
- // The raw content, not unquote(): inside a block scalar a quote or
349
- // a # is text, and YAML agrees.
350
- const piece = line.trim();
351
- scalars[continuation.key] = existing.length > 0 ? `${existing}${separator}${piece}` : piece;
352
- continue;
353
- }
354
- // A non-indented line ends the value; fall through to read it.
355
- }
356
- if (blank || line.trim().startsWith('#')) {
357
- continue;
358
- }
359
- if (currentMap && isIndented(line)) {
360
- // One level of `key: value` pairs, every value a string — the
361
- // spec's shape for `metadata`. Anything deeper is not a string
362
- // value, and refusing it beats reading the wrong half of it.
363
- // Any string is a key — quoted (`"vendor/key": v`), or bare up to
364
- // the first colon — since the spec types the map as string to
365
- // string and says nothing about what a key looks like.
366
- const pair = /^\s+(?:"([^"]*)"|'([^']*)'|([^\s"'#][^:]*?))\s*:\s*(.*)$/.exec(line);
367
- const mapKey = pair?.[1] ?? pair?.[2] ?? pair?.[3]?.trim();
368
- if (!pair || mapKey === undefined || mapKey.length === 0 || pair[4] === undefined || unquote(pair[4]).length === 0) {
369
- throw new Error(`${capitalize(shape.kind)} frontmatter metadata entries must be "key: value" strings, one per line: "${line.trim()}"`);
370
- }
371
- currentMap[mapKey] = unquote(pair[4]);
372
- continue;
373
- }
374
- const listItem = /^\s+-\s*(.*)$/.exec(line);
375
- if (listItem) {
376
- if (!currentList) {
377
- throw new Error(`${capitalize(shape.kind)} frontmatter has a list item outside a list: "${line.trim()}"`);
378
- }
379
- const item = unquote(listItem[1] ?? '');
380
- if (item.length > 0) {
381
- currentList.push(item);
382
- }
383
- continue;
384
- }
385
- const entry = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line);
386
- if (!entry) {
387
- throw new Error(`${capitalize(shape.kind)} frontmatter line is not "key: value": "${line.trim()}"`);
388
- }
389
- const key = entry[1] ?? '';
390
- const value = entry[2] ?? '';
391
- currentList = undefined;
392
- currentMap = undefined;
393
- skippingUnknownBlock = false;
394
- continuation = undefined;
395
- if (tolerant && shape.mapKeys?.includes(key)) {
396
- const inline = value.trim();
397
- if (inline.length > 0 && inline !== '{}') {
398
- throw new Error(`${capitalize(shape.kind)} frontmatter "${key}" must be a block of indented "key: value" lines: "${inline}"`);
399
- }
400
- const map = {};
401
- maps[key] = map;
402
- currentMap = map;
403
- continue;
404
- }
405
- if (shape.listKeys.includes(key)) {
406
- const list = [];
407
- lists[key] = list;
408
- const inline = value.trim();
409
- if (inline.length > 0) {
410
- // Inline form: tools: [a, b]
411
- const match = /^\[(.*)\]$/.exec(inline);
412
- if (!match) {
413
- throw new Error(`${capitalize(shape.kind)} frontmatter list "${key}" must be a block list or [a, b]: "${inline}"`);
414
- }
415
- for (const item of (match[1] ?? '').split(',')) {
416
- const cleaned = unquote(item);
417
- if (cleaned.length > 0) {
418
- list.push(cleaned);
419
- }
420
- }
421
- }
422
- else {
423
- currentList = list;
424
- }
425
- continue;
426
- }
427
- if (!shape.scalarKeys.includes(key)) {
428
- if (tolerant) {
429
- // The key and anything nested under it belong to some other host.
430
- // Remembered, not refused: a validator reports them.
431
- unknownKeys.push(key);
432
- skippingUnknownBlock = true;
433
- continue;
434
- }
435
- throw new Error(`Unknown ${shape.kind} frontmatter key: "${key}". Supported keys: ${[...shape.scalarKeys, ...shape.listKeys].join(', ')}.`);
436
- }
437
- const inline = value.trim();
438
- // YAML block-scalar headers: `>` folds continuation lines with spaces,
439
- // `|` keeps their line breaks; either may carry an indentation digit
440
- // and a chomping sign in either order, and a trailing comment. Only
441
- // meaningful in tolerant mode — the strict dialect never wrote them.
442
- if (tolerant && /^[>|](?:[1-9][+-]?|[+-][1-9]?)?(?:\s+#.*)?$/.test(inline)) {
443
- scalars[key] = '';
444
- continuation = { key, literal: inline.startsWith('|'), pendingBreak: false };
445
- continue;
446
- }
447
- const scalar = unquote(value);
448
- if (scalar.length === 0) {
449
- if (tolerant) {
450
- // May be a plain scalar continuing on indented lines; empty stays
451
- // empty and is dropped below if nothing follows.
452
- scalars[key] = '';
453
- continuation = { key, literal: false, pendingBreak: false };
454
- continue;
455
- }
456
- throw new Error(`${capitalize(shape.kind)} frontmatter key "${key}" has no value.`);
457
- }
458
- scalars[key] = scalar;
459
- if (tolerant) {
460
- continuation = { key, literal: false, pendingBreak: false };
461
- }
462
- }
463
- // A tolerant block scalar that never got a body reads as absent, not as
464
- // an empty string that would satisfy a required-field check.
465
- for (const [key, value] of Object.entries(scalars)) {
466
- if (value !== undefined && value.length === 0) {
467
- delete scalars[key];
468
- }
469
- }
470
- return { scalars, lists, maps, unknownKeys };
471
- };
472
- const capitalize = (word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`;
473
- /**
474
- * Split a markdown document into its `---`-fenced frontmatter lines (if
475
- * any) and the body after them. Shared by souls and skills — one reading
476
- * of what the fences mean, wherever the dialect appears.
477
- */
478
- const extractFrontmatter = (source, kind) => {
479
- const normalized = source.replace(/\r\n/g, '\n');
480
- const opener = /^---[ \t]*\n/.exec(normalized);
481
- if (!opener) {
482
- return { lines: undefined, body: normalized };
483
- }
484
- const closer = /\n---[ \t]*(\n|$)/.exec(normalized.slice(opener[0].length - 1));
485
- if (!closer || closer.index === undefined) {
486
- throw new Error(`${capitalize(kind)} frontmatter opened with --- but never closed.`);
487
- }
488
- const frontmatterEnd = opener[0].length - 1 + closer.index;
489
- return {
490
- lines: normalized.slice(opener[0].length, frontmatterEnd).split('\n'),
491
- body: normalized.slice(frontmatterEnd + closer[0].length),
492
- };
493
- };
494
- /**
495
- * Parse a soul file (markdown with optional frontmatter) into an agent
496
- * definition. The body becomes the agent's instructions; a missing name is
497
- * generated, so the smallest possible soul is just prose.
498
- */
499
- export const parseSoul = (source, options = {}) => {
500
- const { lines, body } = extractFrontmatter(source, 'soul');
501
- const { scalars, lists } = lines
502
- ? parseFrontmatterLines(lines, { kind: 'soul', scalarKeys: SOUL_SCALAR_KEYS, listKeys: SOUL_LIST_KEYS })
503
- : { scalars: {}, lists: {}, maps: {}, unknownKeys: [] };
504
- const instructions = body.trim();
505
- // Strict, like every soul key: a misspelled mode would otherwise load as
506
- // the default, and an agent that answers every reply when it was meant
507
- // to judge is the failure this file is read to prevent.
508
- if (scalars.listens !== undefined && !isListensMode(scalars.listens)) {
509
- throw new Error(`Soul frontmatter listens: ${JSON.stringify(scalars.listens)} is not a listening mode; use one of ${LISTENS_MODES.join(', ')}.`);
510
- }
511
- const agent = defineAgent({
512
- ...(scalars.name ? { name: scalars.name } : {}),
513
- ...(scalars.id ? { id: scalars.id } : {}),
514
- ...(instructions ? { instructions } : {}),
515
- ...(lists.tools ? { tools: lists.tools } : {}),
516
- ...(lists.skills ? { skills: lists.skills } : {}),
517
- ...(lists.credentials ? { credentials: lists.credentials } : {}),
518
- ...(isListensMode(scalars.listens) ? { listens: scalars.listens } : {}),
519
- ...(options.seed !== undefined ? { seed: options.seed } : {}),
520
- });
521
- return {
522
- agent,
523
- ...(scalars.provider ? { provider: scalars.provider } : {}),
524
- ...(scalars.model ? { model: scalars.model } : {}),
525
- };
526
- };
527
- /**
528
- * Render an agent definition as a soul file, ready to save and edit. The
529
- * inverse of parseSoul for round-tripping `stratus agent new` output.
530
- */
531
- export const formatSoul = (soul) => {
532
- const lines = ['---', `name: ${soul.agent.name}`, `id: ${soul.agent.id}`];
533
- if (soul.provider) {
534
- lines.push(`provider: ${soul.provider}`);
535
- }
536
- if (soul.model) {
537
- lines.push(`model: ${soul.model}`);
538
- }
539
- if (soul.agent.listens) {
540
- lines.push(`listens: ${soul.agent.listens}`);
541
- }
542
- for (const [key, values] of [
543
- ['tools', soul.agent.tools],
544
- ['skills', soul.agent.skills],
545
- ['credentials', soul.agent.credentials],
546
- ]) {
547
- if (values && values.length > 0) {
548
- lines.push(`${key}:`);
549
- for (const value of values) {
550
- lines.push(` - ${value}`);
551
- }
552
- }
553
- }
554
- lines.push('---', '');
555
- lines.push(soul.agent.instructions ?? 'Describe who this agent is and how they talk.');
556
- lines.push('');
557
- return lines.join('\n');
558
- };
559
- // ---- skills ----------------------------------------------------------------
560
- /**
561
- * The shape a skill id is written in — the Agent Skills spec's rule for
562
- * `name`, which is also the directory name: lowercase letters and digits
563
- * in hyphen-separated runs (`web-research`), at most 64 characters, no
564
- * leading, trailing, or doubled hyphen. One pattern for the manifest's
565
- * declared ids, the operator directory's folder names, and install-time
566
- * validation — two copies would drift into two answers to what a valid
567
- * id is.
568
- */
569
- export const SKILL_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
570
- /** The spec's ceiling on `name`, and so on a skill id. */
571
- export const SKILL_ID_MAX_LENGTH = 64;
572
- export const isValidSkillId = (id) => id.length <= SKILL_ID_MAX_LENGTH && SKILL_ID_PATTERN.test(id);
573
- // The rule before the spec's: any lowercase kebab-ish run, doubled and
574
- // trailing hyphens and all, no length cap. What a directory or manifest
575
- // id that loaded yesterday was checked against.
576
- const LEGACY_SKILL_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
577
- /**
578
- * Whether an id that is already on this machine — a directory under
579
- * `~/.stratus/skills/`, a plugin manifest's declared id — is served.
580
- * Wider than `isValidSkillId` on purpose: the spec's rule arrived after
581
- * some of these were written, and tightening the *loader* would silently
582
- * drop an enabled procedure from an agent on upgrade. Installing and
583
- * validating use the strict rule; loading uses this one, and warns.
584
- */
585
- export const isLoadableSkillId = (id) => LEGACY_SKILL_ID_PATTERN.test(id);
586
- /** What a skill id refusal says, everywhere one is refused. */
587
- export const SKILL_ID_RULE = 'Skill ids are lowercase letters, digits, and single hyphens (web-research), at most 64 characters.';
588
- /** The spec's ceiling on `description`. */
589
- export const SKILL_DESCRIPTION_MAX_LENGTH = 1024;
590
- /** The spec's ceiling on `compatibility`. */
591
- export const SKILL_COMPATIBILITY_MAX_LENGTH = 500;
592
- // The frontmatter keys the Agent Skills spec defines. Everything else at
593
- // the top level belongs to some other host — read past, and reported by
594
- // `validateSkillDocument`. `allowed-tools` is read so it is not reported,
595
- // and then deliberately unused: it is another host's pre-approval list,
596
- // and here the two gates (a trusted config, a soul's allowlist) are the
597
- // only things that grant a tool.
598
- const SKILL_SPEC_KEYS = ['name', 'description', 'license', 'compatibility', 'metadata', 'allowed-tools'];
599
- // `version` and `requires` at the top level are the pre-spec Stratus form;
600
- // the spec keeps host extensions under `metadata:`, and that is where a
601
- // skill written today puts them. The old form still reads, so an installed
602
- // skill keeps working — and validation says which form it found.
603
- const SKILL_SCALAR_KEYS = ['name', 'description', 'license', 'compatibility', 'allowed-tools', 'version'];
604
- const SKILL_LIST_KEYS = ['requires'];
605
- const SKILL_MAP_KEYS = ['metadata'];
606
- const SKILL_LEGACY_KEYS = ['version', 'requires'];
607
- /**
608
- * Parse a `SKILL.md` — the same frontmatter dialect souls use (`key: value`
609
- * scalars, block lists), plus the spec's `metadata:` map. The body is the
610
- * procedure itself, markdown, untouched.
611
- *
612
- * `description` is required: it is the only thing the model sees before
613
- * deciding to load the body, and a skill without one is unreachable by the
614
- * mechanism that makes skills cheap. Everything the spec constrains beyond
615
- * presence — the name rule, the length ceilings — is
616
- * `validateSkillDocument`'s, so that loading what is already installed
617
- * stays lenient while installing is strict.
618
- */
619
- export const parseSkillDocument = (source) => {
620
- const { lines, body } = extractFrontmatter(source, 'skill');
621
- if (!lines) {
622
- throw new Error('Skill file has no frontmatter. A SKILL.md starts with --- and needs at least a description.');
623
- }
624
- const { scalars, lists, maps, unknownKeys } = parseFrontmatterLines(lines, {
625
- kind: 'skill',
626
- scalarKeys: SKILL_SCALAR_KEYS,
627
- listKeys: SKILL_LIST_KEYS,
628
- mapKeys: SKILL_MAP_KEYS,
629
- // Skills travel the wider agent-skills ecosystem (skills.sh and the
630
- // registries behind it), whose frontmatter carries fields other hosts
631
- // own. Those are metadata to skip, not defects to refuse — see
632
- // FrontmatterShape.tolerant, and note souls do NOT set this.
633
- tolerant: true,
634
- });
635
- const description = scalars.description;
636
- if (!description) {
637
- throw new Error('Skill frontmatter has no "description". The description is what an agent routes on — say when to reach for this skill.');
638
- }
639
- const metadata = maps.metadata;
640
- const version = metadata?.version ?? scalars.version;
641
- const requires = metadata?.requires !== undefined
642
- ? metadata.requires.split(/\s+/).filter((entry) => entry.length > 0)
643
- : lists.requires;
644
- // The legacy keys are read, and still not the spec's: a validator names
645
- // them so a skill written here can be moved to the form that ports.
646
- const legacy = SKILL_LEGACY_KEYS.filter((key) => (key === 'requires' ? lists.requires : scalars[key]) !== undefined);
647
- return {
648
- ...(scalars.name ? { name: scalars.name } : {}),
649
- description,
650
- ...(scalars.license ? { license: scalars.license } : {}),
651
- ...(scalars.compatibility ? { compatibility: scalars.compatibility } : {}),
652
- ...(metadata ? { metadata } : {}),
653
- ...(version ? { version } : {}),
654
- ...(requires && requires.length > 0 ? { requires } : {}),
655
- unknownKeys: [...legacy, ...unknownKeys.filter((key) => !SKILL_SPEC_KEYS.includes(key))],
656
- body: body.trim(),
657
- };
658
- };
659
- const characterCount = (text) => Array.from(text).length;
660
- /**
661
- * Check a parsed `SKILL.md` against the Agent Skills spec — the checks
662
- * `skills-ref validate` runs, so a skill that passes here passes there:
663
- * `name` present, shaped like an id, equal to the directory name;
664
- * `description` and `compatibility` under their ceilings. Errors are what
665
- * refuses an install. Top-level keys the spec does not define are
666
- * warnings, not errors: the reference validator refuses them, but the
667
- * ecosystem's skills carry other hosts' fields routinely, and refusing a
668
- * skill over a field that changes nothing here fails the whole point of
669
- * conforming. The legacy Stratus keys get their own warning, naming the
670
- * `metadata:` form that ports.
671
- *
672
- * Loading never calls this — an installed skill keeps loading whatever
673
- * the spec says today. Installing always does.
674
- */
675
- export const validateSkillDocument = (document, options = {}) => {
676
- const errors = [];
677
- const warnings = [];
678
- if (document.name === undefined) {
679
- const suggestion = options.suggestedName ?? options.directoryName;
680
- errors.push(`frontmatter has no "name". The Agent Skills spec requires one, equal to the directory name${suggestion !== undefined ? ` — add: name: ${suggestion}` : ''}.`);
681
- }
682
- else if (!isValidSkillId(document.name)) {
683
- errors.push(`name ${JSON.stringify(document.name)} is not a skill id. ${SKILL_ID_RULE}`);
684
- }
685
- else if (options.directoryName !== undefined && document.name !== options.directoryName) {
686
- errors.push(`name ${JSON.stringify(document.name)} does not match the directory name ${JSON.stringify(options.directoryName)}. The spec requires the two to agree — rename one.`);
687
- }
688
- // Characters, as the spec and its Python reference count them — code
689
- // points, not the UTF-16 units `.length` reports, which would count an
690
- // emoji twice and refuse a description the reference validator passes.
691
- const descriptionLength = characterCount(document.description);
692
- if (descriptionLength > SKILL_DESCRIPTION_MAX_LENGTH) {
693
- errors.push(`description is ${descriptionLength} characters, past the spec's ceiling of ${SKILL_DESCRIPTION_MAX_LENGTH}. Say when to reach for the skill, and move the rest into the body.`);
694
- }
695
- const compatibilityLength = document.compatibility !== undefined ? characterCount(document.compatibility) : 0;
696
- if (compatibilityLength > SKILL_COMPATIBILITY_MAX_LENGTH) {
697
- errors.push(`compatibility is ${compatibilityLength} characters, past the spec's ceiling of ${SKILL_COMPATIBILITY_MAX_LENGTH}.`);
698
- }
699
- const legacy = document.unknownKeys.filter((key) => SKILL_LEGACY_KEYS.includes(key));
700
- const foreign = document.unknownKeys.filter((key) => !SKILL_LEGACY_KEYS.includes(key));
701
- for (const key of legacy) {
702
- warnings.push(key === 'requires'
703
- ? 'top-level "requires" is the pre-spec Stratus form, and the reference validator refuses it. Write it as metadata.requires, one space-separated string: "browser.* fs.read".'
704
- : 'top-level "version" is the pre-spec Stratus form, and the reference validator refuses it. Write it as metadata.version.');
705
- }
706
- if (foreign.length > 0) {
707
- warnings.push(`frontmatter key${foreign.length > 1 ? 's' : ''} outside the Agent Skills spec: ${foreign.map((key) => JSON.stringify(key)).join(', ')}. Another host's; ignored here, and the reference validator refuses ${foreign.length > 1 ? 'them' : 'it'}.`);
708
- }
709
- return { errors, warnings };
710
- };
711
- /**
712
- * A `Skill` whose body stays on disk until somebody asks. The identity
713
- * comes from the load-time parse (which is also what validated the file);
714
- * `load()` re-reads rather than closing over the body, so an
715
- * enabled-but-unused skill costs its description line and nothing else —
716
- * in the prompt and in memory.
717
- */
718
- export const createLazySkill = ({ id, document, read }) => ({
719
- id,
720
- name: document.name ?? id,
721
- description: document.description,
722
- ...(document.requires ? { requires: document.requires } : {}),
723
- load: async () => parseSkillDocument(await read()).body,
724
- });
725
- export const MEMORY_TOOL_NAME = 'memory.remember';
726
- /**
727
- * A tool that lets an agent write to its own long-term memory. Entries are
728
- * keyed by the session's agent id, so what one agent learns stays exclusively
729
- * that agent's knowledge — and follows it to every channel and thread.
730
- */
731
- export const createRememberTool = (store) => ({
732
- name: MEMORY_TOOL_NAME,
733
- description: 'Save a fact to your long-term memory so you can recall it in future conversations on any channel.',
734
- // The agent's own memory, keyed to the agent and read by nobody else.
735
- risk: 'safe',
736
- parameters: {
737
- type: 'object',
738
- properties: {
739
- fact: { type: 'string', description: 'The fact to remember, phrased so it makes sense later without context.' },
740
- },
741
- required: ['fact'],
742
- },
743
- async execute(input, session) {
744
- const fact = typeof input.fact === 'string' ? input.fact.trim() : '';
745
- if (!fact) {
746
- throw new Error('memory.remember requires a non-empty "fact" string.');
747
- }
748
- // Per session, not per fact: the runner knows this session has seen
749
- // untrusted content and cannot know which words of the fact came from
750
- // it, so the write carries the least trusted label the session holds.
751
- // Coarse and over-marking — the safe direction.
752
- const taintedBy = sessionTaintedBy(session);
753
- const origin = { sessionId: session.id, ...(taintedBy !== undefined ? { taintedBy } : {}) };
754
- const entry = await store.append(session.agent.id, fact, { sessionId: session.id }, {
755
- trust: sessionWriteTrust(session),
756
- origin,
757
- });
758
- return { remembered: true, id: entry.id, trust: memoryEntryTrust(entry) };
759
- },
760
- });
761
- export const RECALL_TOOL_NAME = 'memory.recall';
762
- /**
763
- * Searches the agent's own long-term memory. `safe` by the same rule as
764
- * `memory.remember`: reading what this agent already knows. The query is
765
- * literal text — the store does whatever escaping its backend needs, so no
766
- * query a model writes is ever a syntax error; matching nothing is a normal
767
- * result, because an agent that has learned nothing yet is the ordinary
768
- * starting state, not a broken tool.
769
- */
770
- export const createRecallTool = (store) => ({
771
- name: RECALL_TOOL_NAME,
772
- description: 'Search your long-term memory for facts you have remembered. Only your most recent memories appear in your prompt automatically — use this to find everything older.',
773
- risk: 'safe',
774
- parameters: {
775
- type: 'object',
776
- properties: {
777
- query: { type: 'string', description: 'What to look for, as plain words. Every word must appear in a fact for it to match.' },
778
- limit: { type: 'number', description: 'Maximum facts to return. Optional; the store bounds it either way.' },
779
- },
780
- required: ['query'],
781
- },
782
- async execute(input, session, context) {
783
- if (typeof input.query !== 'string') {
784
- throw new Error('memory.recall requires a "query" string.');
785
- }
786
- const limit = typeof input.limit === 'number' ? input.limit : undefined;
787
- const result = await store.search(session.agent.id, input.query, limit);
788
- // What enters the context taints the session, recalled as much as
789
- // injected: an `external` entry surfacing here is a stranger's text
790
- // in the prompt, and marking the call is what stops the store from
791
- // laundering its own contents through a fresh session.
792
- const lowest = leastTrusted(...result.entries.map(memoryEntryTrust));
793
- if (isTaintedTrust(lowest)) {
794
- context?.markTrust?.(lowest);
795
- }
796
- return {
797
- results: result.entries.map((entry) => ({
798
- id: entry.id,
799
- content: entry.content,
800
- createdAt: entry.createdAt,
801
- trust: memoryEntryTrust(entry),
802
- })),
803
- truncated: result.truncated,
804
- };
805
- },
806
- });
807
- export const FORGET_TOOL_NAME = 'memory.forget';
808
- /**
809
- * Retires one of the agent's own memories, by id. Tombstoned rather than
810
- * deleted — which is what makes `safe` defensible for it: the entry stops
811
- * being live but does not stop existing, so an operator can still see what
812
- * an agent chose to drop.
813
- */
814
- export const createForgetTool = (store) => ({
815
- name: FORGET_TOOL_NAME,
816
- description: 'Forget one of your remembered facts by its id (from memory.recall or memory.remember). It stops informing future conversations.',
817
- risk: 'safe',
818
- parameters: {
819
- type: 'object',
820
- properties: {
821
- id: { type: 'string', description: 'The id of the memory entry to forget.' },
822
- },
823
- required: ['id'],
824
- },
825
- async execute(input, session) {
826
- const id = typeof input.id === 'string' ? input.id.trim() : '';
827
- if (!id) {
828
- throw new Error('memory.forget requires a non-empty "id" string.');
829
- }
830
- const forgotten = await store.forget(session.agent.id, id);
831
- if (!forgotten) {
832
- throw new Error(`No live memory entry with id ${id} belongs to this agent — nothing was forgotten.`);
833
- }
834
- return { forgotten: true, id };
835
- },
836
- });
837
- export const DELEGATE_TOOL_NAME = 'agent.delegate';
838
- /**
839
- * Metadata a delegated sub-session carries: which agent delegated, and the
840
- * session at the root of the chain. Written by `agent.delegate` and read by
841
- * the gateway's restart sweep, which is why the keys are named here rather
842
- * than spelled twice — a sub-session's parent is a turn in some other
843
- * process's memory, and after a restart nothing can consume its reply.
844
- */
845
- export const DELEGATED_BY_METADATA_KEY = 'delegatedBy';
846
- export const ROOT_SESSION_ID_METADATA_KEY = 'rootSessionId';
847
- export const DELEGATION_DEPTH_METADATA_KEY = 'delegationDepth';
848
- /**
849
- * The segment `agent.delegate` mints into every sub-session's id —
850
- * `<parent>:delegate:<agent>:<depth>:<suffix>`. The second half of what
851
- * identifies a sub-session, beside the metadata above: the gateway refuses
852
- * the metadata keys at its public door, but rows written before it did
853
- * cannot be told apart by their metadata alone, and a caller who wrote
854
- * `delegatedBy` onto a session of its own would not also have minted an
855
- * id in this shape.
856
- */
857
- export const DELEGATED_SESSION_ID_MARKER = ':delegate:';
858
- /**
859
- * Whether this session is a delegated sub-session — one whose reply is
860
- * consumed by a parent turn's `agent.delegate` call and by nothing else.
861
- * Both halves are required: the metadata `agent.delegate` writes, and the
862
- * id shape it mints.
863
- */
864
- export const isDelegatedSession = (session) => typeof session.metadata?.[DELEGATED_BY_METADATA_KEY] === 'string'
865
- && session.id.includes(DELEGATED_SESSION_ID_MARKER);
866
- /**
867
- * The id of the session whose `agent.delegate` call started this one, read
868
- * off the id shape above — the text before the last marker, since a nested
869
- * sub-session embeds its parent's id whole. Undefined for an id that was
870
- * not minted that way.
871
- */
872
- export const delegatingSessionIdOf = (sessionId) => {
873
- const at = sessionId.lastIndexOf(DELEGATED_SESSION_ID_MARKER);
874
- return at > 0 ? sessionId.slice(0, at) : undefined;
875
- };
876
- /**
877
- * The id of the `agent.delegate` call in `parent` that is still awaiting
878
- * `child`, or undefined when none is.
879
- *
880
- * The one thing about a delegation nothing outside the runner can forge:
881
- * a caller can name a session in the sub-session id shape and write the
882
- * metadata keys, but only the runner writes assistant tool calls, and only
883
- * a delegation in flight leaves one with no result behind it. Matched to
884
- * the child and not merely counted, because a parent can be inside one
885
- * delegation while an earlier sub-session of its — continued from outside
886
- * under a version that kept the markers — is parked on something of its
887
- * own. The call names its target and its prompt, and `agent.delegate`
888
- * made the prompt the child's first user message. Read against the
889
- * transcript order so a re-used call id cannot make a later call look
890
- * answered.
891
- */
892
- export const outstandingDelegationFor = (parent, child) => {
893
- const firstMessage = child.messages.find((message) => message.role === 'user')?.content;
894
- if (firstMessage === undefined) {
895
- return undefined;
896
- }
897
- const answered = new Map();
898
- for (const message of parent.messages) {
899
- if (message.role === 'tool' && message.toolResult) {
900
- answered.set(message.toolResult.callId, (answered.get(message.toolResult.callId) ?? 0) + 1);
901
- }
902
- }
903
- const seen = new Map();
904
- for (const message of parent.messages) {
905
- if (message.role !== 'assistant' || !message.toolCalls) {
906
- continue;
907
- }
908
- for (const call of message.toolCalls) {
909
- const occurrence = (seen.get(call.id) ?? 0) + 1;
910
- seen.set(call.id, occurrence);
911
- if (call.toolName !== DELEGATE_TOOL_NAME || occurrence <= (answered.get(call.id) ?? 0)) {
912
- continue;
913
- }
914
- const target = typeof call.input.agent === 'string' ? call.input.agent : '';
915
- const prompt = typeof call.input.prompt === 'string' ? call.input.prompt.trim() : '';
916
- if ((target === child.agent.id || target === child.agent.name) && prompt === firstMessage) {
917
- return call.id;
918
- }
919
- }
920
- }
921
- return undefined;
922
- };
923
- /**
924
- * `metadata` with the delegation markers removed — what a sub-session
925
- * carries once it has been addressed from outside. The delegation ended
926
- * with the turn that awaited it; a conversation somebody continues on the
927
- * same id afterwards is an ordinary one, and must not be closed as an
928
- * orphan by a later restart.
929
- */
930
- export const withoutDelegation = (metadata) => {
931
- const { [DELEGATED_BY_METADATA_KEY]: _delegatedBy, [ROOT_SESSION_ID_METADATA_KEY]: _root, [DELEGATION_DEPTH_METADATA_KEY]: _depth, ...rest } = metadata;
932
- return rest;
933
- };
934
- const DEFAULT_MAX_DELEGATION_DEPTH = 3;
935
- /**
936
- * The orchestrator primitive: a tool that runs another agent in its own
937
- * sub-session (with that agent's memory, tool allowlist, and credentials)
938
- * and returns its final reply as the tool result.
939
- */
940
- export const createDelegateTool = ({ registry, runner, dispatch, maxDepth = DEFAULT_MAX_DELEGATION_DEPTH, }) => {
941
- const runDelegated = dispatch ?? ((input) => runner.run(input));
942
- // Sub-session ids must be unique even when one orchestrator delegates to
943
- // the same target repeatedly, and must stay unique across tool rebuilds
944
- // sharing one SessionStore — so a counter alone is not enough.
945
- let delegationCount = 0;
946
- const uniqueSuffix = () => `${delegationCount}-${Math.random().toString(36).slice(2, 10)}`;
947
- return {
948
- name: DELEGATE_TOOL_NAME,
949
- description: 'Delegate a task to another agent by name and get their reply back.',
950
- // Safe, and the call is arguable enough to record why. Delegation spends
951
- // provider tokens and starts work as another agent, which reads like
952
- // `gated` — but the money argument proves too much: the turn that decides
953
- // to delegate was itself an unapproved provider call, so gating on spend
954
- // gates the conversation. What delegation does NOT do is act outside
955
- // Stratus. It stays in the fleet, the delegate's own tool calls face the
956
- // policy again under the delegate's allowlist, and maxDepth bounds the
957
- // chain.
958
- //
959
- // The practical half: `gated` here means a headless daemon refuses every
960
- // delegation, and headless is what every installed service runs. That
961
- // would remove the orchestrator pattern from the product until remote
962
- // approval exists — a feature removal wearing a safety hat, with no way
963
- // for an operator to say yes. Revisit when a human can actually be asked.
964
- risk: 'safe',
965
- parameters: {
966
- type: 'object',
967
- properties: {
968
- agent: { type: 'string', description: 'Name or id of the agent to delegate to.' },
969
- prompt: { type: 'string', description: 'The task or question for that agent.' },
970
- },
971
- required: ['agent', 'prompt'],
972
- },
973
- async execute(input, session, context) {
974
- const targetRef = typeof input.agent === 'string' ? input.agent : '';
975
- const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
976
- if (!targetRef || !prompt) {
977
- throw new Error('agent.delegate requires "agent" and "prompt" strings.');
978
- }
979
- const depth = typeof session.metadata?.[DELEGATION_DEPTH_METADATA_KEY] === 'number'
980
- ? session.metadata[DELEGATION_DEPTH_METADATA_KEY]
981
- : 0;
982
- if (depth >= maxDepth) {
983
- throw new Error(`Delegation depth limit reached (${maxDepth}).`);
984
- }
985
- const target = registry.get(targetRef) ?? registry.getByName(targetRef);
986
- if (!target) {
987
- throw new Error(`Agent not found: ${targetRef}`);
988
- }
989
- if (target.id === session.agent.id) {
990
- throw new Error('An agent cannot delegate to itself.');
991
- }
992
- delegationCount += 1;
993
- let result;
994
- try {
995
- result = await runDelegated({
996
- sessionId: `${session.id}${DELEGATED_SESSION_ID_MARKER}${target.id}:${depth + 1}:${uniqueSuffix()}`,
997
- agent: target,
998
- userMessage: prompt,
999
- metadata: {
1000
- [DELEGATION_DEPTH_METADATA_KEY]: depth + 1,
1001
- [DELEGATED_BY_METADATA_KEY]: session.agent.id,
1002
- [ROOT_SESSION_ID_METADATA_KEY]: typeof session.metadata?.[ROOT_SESSION_ID_METADATA_KEY] === 'string'
1003
- ? session.metadata[ROOT_SESSION_ID_METADATA_KEY]
1004
- : session.id,
1005
- // Outbound: the prompt is the parent's text, and the parent is its
1006
- // "sender" — a tainted parent must not hand attacker text to a
1007
- // teammate whose session has seen nothing. At most `agent`: the
1008
- // prompt is something an agent wrote, whoever it was talking to.
1009
- [SENDER_TRUST_METADATA_KEY]: sessionWriteTrust(session),
1010
- },
1011
- // A cancelled parent turn cancels the delegated run with it —
1012
- // otherwise the parent cannot settle until the target gives up.
1013
- ...(context?.signal ? { signal: context.signal } : {}),
1014
- });
1015
- }
1016
- catch (error) {
1017
- // A failed run has no session to read a label from here, and its
1018
- // error text is the target's — a provider quoting the request it
1019
- // refused, which is the target's prompt and injected memory, whatever
1020
- // label those carried. The parent sees that text as a tool error, so
1021
- // the call is marked at the label for provenance nobody can vouch
1022
- // for before it is rethrown. The parent's own cancellation is the
1023
- // exception: that error is this process's sentence, not the target's.
1024
- if (!context?.signal?.aborted) {
1025
- context?.markTrust?.('unknown');
1026
- }
1027
- throw error;
1028
- }
1029
- // Inbound: the reply carries the target session's label, whatever it
1030
- // is — not only `external`. A target whose own injected memory was
1031
- // `unknown` replies from content the parent never saw and cannot
1032
- // assess; the parent takes the lower of its own label and this one,
1033
- // by the same ordering as everything else.
1034
- const targetTrust = sessionTrustOf(result);
1035
- context?.markTrust?.(targetTrust);
1036
- const reply = [...result.messages]
1037
- .reverse()
1038
- .find((message) => message.role === 'assistant' && message.content.length > 0);
1039
- return {
1040
- agent: target.name,
1041
- reply: reply?.content ?? '(no reply)',
1042
- sessionId: result.id,
1043
- trust: targetTrust,
1044
- };
1045
- },
1046
- };
1047
- };
1048
- /**
1049
- * Route inbound work (a channel name, a message, a thread key) to an agent.
1050
- * The Slack transport will feed channel/mention strings through this so the
1051
- * same person consistently answers in the same places.
1052
- */
1053
- export const createAgentRouter = (rules, fallback) => {
1054
- // Global/sticky regexes mutate lastIndex on test(). Clone them (so the
1055
- // caller's regex is never touched) keeping all flags — sticky stays
1056
- // anchored — and reset lastIndex before every test for stable results.
1057
- const normalized = rules.map((rule) => ({
1058
- ...rule,
1059
- match: rule.match instanceof RegExp
1060
- ? new RegExp(rule.match.source, rule.match.flags)
1061
- : rule.match,
1062
- }));
1063
- return {
1064
- route(input) {
1065
- for (const rule of normalized) {
1066
- let matched;
1067
- if (rule.match instanceof RegExp) {
1068
- rule.match.lastIndex = 0;
1069
- matched = rule.match.test(input);
1070
- }
1071
- else {
1072
- matched = rule.match(input);
1073
- }
1074
- if (matched) {
1075
- return rule.agent;
1076
- }
1077
- }
1078
- return fallback;
1079
- },
1080
- };
1081
- };
1082
- /** Register a set of agents and get the registry back. */
1083
- export const createAgentTeam = (agents) => {
1084
- const registry = new AgentRegistry();
1085
- for (const agent of agents) {
1086
- registry.register(agent);
1087
- }
1088
- return registry;
1089
- };
1090
- /**
1091
- * The one string form of a destination, `<channel>:<native id>`, used
1092
- * everywhere a destination is *compared*: `Tool.destinationFor`, the
1093
- * permission policy's pre-authorization check, and the schedule row it is
1094
- * checked against. One canonicalization, or two writers of `slack:C01` and
1095
- * `SLACK:C01` would disagree about the same channel.
1096
- */
1097
- export const canonicalDestination = (destination) => `${destination.channel.trim().toLowerCase()}:${destination.to.trim()}`;
1098
- /** `every` strings: a positive integer and one unit — `90s`, `10m`, `2h`, `1d`. */
1099
- export const parseInterval = (text) => {
1100
- const match = /^(\d+)(s|m|h|d)$/.exec(text.trim());
1101
- if (!match) {
1102
- return undefined;
1103
- }
1104
- const amount = Number(match[1]);
1105
- if (!Number.isSafeInteger(amount) || amount <= 0) {
1106
- return undefined;
1107
- }
1108
- const unit = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[match[2]];
1109
- return amount * unit;
1110
- };
1111
- const parseCronField = (field, min, max, label) => {
1112
- const values = new Set();
1113
- for (const part of field.split(',')) {
1114
- const stepMatch = /^([^/]+)(?:\/(\d+))?$/.exec(part);
1115
- if (!stepMatch || part.length === 0) {
1116
- throw new Error(`Invalid cron ${label} field: ${field}`);
1117
- }
1118
- const [, rangeText, stepText] = stepMatch;
1119
- const step = stepText === undefined ? 1 : Number(stepText);
1120
- if (!Number.isSafeInteger(step) || step < 1) {
1121
- throw new Error(`Invalid cron ${label} step: ${part}`);
1122
- }
1123
- let low;
1124
- let high;
1125
- if (rangeText === '*') {
1126
- low = min;
1127
- high = max;
1128
- }
1129
- else {
1130
- const rangeMatch = /^(\d+)(?:-(\d+))?$/.exec(rangeText);
1131
- if (!rangeMatch) {
1132
- throw new Error(`Invalid cron ${label} field: ${field} (numbers only — names are not supported)`);
1133
- }
1134
- low = Number(rangeMatch[1]);
1135
- high = rangeMatch[2] === undefined ? low : Number(rangeMatch[2]);
1136
- // A bare value with a step (`5/15`) reads to the end of the range,
1137
- // matching vixie cron.
1138
- if (rangeMatch[2] === undefined && stepText !== undefined) {
1139
- high = max;
1140
- }
1141
- }
1142
- if (low < min || high > max || low > high) {
1143
- throw new Error(`Cron ${label} value out of range ${min}-${max}: ${part}`);
1144
- }
1145
- for (let value = low; value <= high; value += step) {
1146
- values.add(value);
1147
- }
1148
- }
1149
- return values;
1150
- };
1151
- /**
1152
- * Five fields — minute, hour, day-of-month, month, day-of-week — with `*`,
1153
- * lists, ranges, and steps. Numbers only; day-of-week 0-7 with both 0 and 7
1154
- * as Sunday. Evaluated in the daemon's local time, which is what "every
1155
- * morning at 7" means to the person whose machine it is.
1156
- */
1157
- export const parseCronExpression = (expression) => {
1158
- const fields = expression.trim().split(/\s+/);
1159
- if (fields.length !== 5) {
1160
- throw new Error(`A cron expression has five fields (minute hour day-of-month month day-of-week); got ${fields.length} in "${expression}".`);
1161
- }
1162
- const [minute, hour, dom, month, dow] = fields;
1163
- const daysOfWeek = parseCronField(dow, 0, 7, 'day-of-week');
1164
- if (daysOfWeek.has(7)) {
1165
- daysOfWeek.delete(7);
1166
- daysOfWeek.add(0);
1167
- }
1168
- return {
1169
- minutes: parseCronField(minute, 0, 59, 'minute'),
1170
- hours: parseCronField(hour, 0, 23, 'hour'),
1171
- daysOfMonth: parseCronField(dom, 1, 31, 'day-of-month'),
1172
- months: parseCronField(month, 1, 12, 'month'),
1173
- daysOfWeek,
1174
- domRestricted: dom !== '*',
1175
- dowRestricted: dow !== '*',
1176
- };
1177
- };
1178
- /** How far a cron search looks before concluding the expression never fires. */
1179
- const CRON_SEARCH_DAYS = 366 * 4;
1180
- const nextCronFire = (fields, after) => {
1181
- // Strictly after `after`, at minute granularity.
1182
- const start = new Date(after.getTime());
1183
- start.setSeconds(0, 0);
1184
- start.setMinutes(start.getMinutes() + 1);
1185
- const dayMatches = (date) => {
1186
- if (!fields.months.has(date.getMonth() + 1)) {
1187
- return false;
1188
- }
1189
- const domMatch = fields.daysOfMonth.has(date.getDate());
1190
- const dowMatch = fields.daysOfWeek.has(date.getDay());
1191
- if (fields.domRestricted && fields.dowRestricted) {
1192
- return domMatch || dowMatch;
1193
- }
1194
- return fields.domRestricted ? domMatch : fields.dowRestricted ? dowMatch : true;
1195
- };
1196
- const hours = [...fields.hours].sort((a, b) => a - b);
1197
- const minutes = [...fields.minutes].sort((a, b) => a - b);
1198
- const day = new Date(start.getFullYear(), start.getMonth(), start.getDate());
1199
- for (let offset = 0; offset < CRON_SEARCH_DAYS; offset += 1) {
1200
- if (dayMatches(day)) {
1201
- for (const hour of hours) {
1202
- for (const minute of minutes) {
1203
- const candidate = new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour, minute);
1204
- if (candidate >= start && candidate > after) {
1205
- return candidate;
1206
- }
1207
- }
1208
- }
1209
- }
1210
- day.setDate(day.getDate() + 1);
1211
- }
1212
- // Reachable: `0 0 31 2 *` matches no real date. Creation treats this as
1213
- // a refusal, not a schedule that quietly never runs.
1214
- return undefined;
1215
- };
1216
- /**
1217
- * The slot after `after`, or undefined when there is none — a one-shot
1218
- * already past, or a cron expression that matches no real date. One
1219
- * implementation, because the scheduler advancing a fired slot and the
1220
- * creation path computing the first one must never disagree.
1221
- */
1222
- export const nextFireAfter = (cadence, after) => {
1223
- switch (cadence.kind) {
1224
- case 'every':
1225
- return new Date(after.getTime() + cadence.intervalMs);
1226
- case 'cron':
1227
- return nextCronFire(parseCronExpression(cadence.expression), after);
1228
- case 'at': {
1229
- const at = new Date(cadence.at);
1230
- if (Number.isNaN(at.getTime())) {
1231
- return undefined;
1232
- }
1233
- return at.getTime() > after.getTime() ? at : undefined;
1234
- }
1235
- default:
1236
- return undefined;
1237
- }
1238
- };
1239
- /** One line for a human: `every 30m`, `cron 0 7 * * *`, `once at 2026-…`. */
1240
- export const describeCadence = (cadence) => {
1241
- switch (cadence.kind) {
1242
- case 'every': {
1243
- const units = [[86_400_000, 'd'], [3_600_000, 'h'], [60_000, 'm'], [1_000, 's']];
1244
- for (const [ms, suffix] of units) {
1245
- if (cadence.intervalMs % ms === 0) {
1246
- return `every ${cadence.intervalMs / ms}${suffix}`;
1247
- }
1248
- }
1249
- return `every ${cadence.intervalMs}ms`;
1250
- }
1251
- case 'cron':
1252
- return `cron ${cadence.expression}`;
1253
- case 'at':
1254
- return `once at ${cadence.at}`;
1255
- default:
1256
- return 'unknown';
1257
- }
1258
- };
1259
- /**
1260
- * Metadata a firing's session carries. The schedule id is what the
1261
- * permission carve-out resolves the approved destination from, and
1262
- * `scheduled: true` is what lets a renderer say a turn arrived from a
1263
- * schedule rather than a person.
1264
- */
1265
- export const SCHEDULE_ID_METADATA_KEY = 'scheduleId';
1266
- export const SCHEDULED_TURN_METADATA_KEY = 'scheduled';
1267
- // ---- schedule tools ---------------------------------------------------------
1268
- export const SCHEDULE_EVERY_TOOL_NAME = 'schedule.every';
1269
- export const SCHEDULE_AT_TOOL_NAME = 'schedule.at';
1270
- export const SCHEDULE_LIST_TOOL_NAME = 'schedule.list';
1271
- export const SCHEDULE_CANCEL_TOOL_NAME = 'schedule.cancel';
1272
- export const MESSAGE_SEND_TOOL_NAME = 'message.send';
1273
- /**
1274
- * The tools only a running gateway registers. Each needs the dispatcher,
1275
- * the store, or the channels, and nothing else in the tree has them — so a
1276
- * soul naming one is correct while a host without them cannot call it,
1277
- * which is a different thing from a name that does not exist. `stratus
1278
- * run` reads this to say which it is rather than reporting a daemon soul's
1279
- * `schedule.*` as a typo.
1280
- *
1281
- * It lives here, with the names, rather than in `@stratusagent/gateway`
1282
- * where the registrations are: the CLI imports the gateway only through
1283
- * `await import`, because `bin.ts` has to load the CLI to print the Node
1284
- * version message and the gateway pulls in `node:sqlite` at module scope.
1285
- * A static import for a list of strings would have made the floor guard
1286
- * fail with the exact builtin-module error it exists to replace. The
1287
- * gateway's own test pins this set against what it actually registers,
1288
- * which holds the two together better than proximity would.
1289
- */
1290
- export const GATEWAY_ONLY_TOOL_NAMES = [
1291
- SCHEDULE_EVERY_TOOL_NAME,
1292
- SCHEDULE_AT_TOOL_NAME,
1293
- SCHEDULE_LIST_TOOL_NAME,
1294
- SCHEDULE_CANCEL_TOOL_NAME,
1295
- MESSAGE_SEND_TOOL_NAME,
1296
- DELEGATE_TOOL_NAME,
1297
- ];
1298
- const parseDestinationInput = (raw) => {
1299
- if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
1300
- return undefined;
1301
- }
1302
- const channel = raw.channel;
1303
- const to = raw.to;
1304
- if (typeof channel !== 'string' || channel.trim().length === 0
1305
- || typeof to !== 'string' || to.trim().length === 0) {
1306
- return undefined;
1307
- }
1308
- return { channel: channel.trim().toLowerCase(), to: to.trim() };
1309
- };
1310
- const DESTINATION_PARAMETER = {
1311
- type: 'object',
1312
- description: 'Where firings report. Omit for a schedule that does not speak — its sends will need approval like any other. The channel kind plus the channel-native conversation id (for Slack, a channel id like C0123456789 that your app is a member of).',
1313
- properties: {
1314
- channel: { type: 'string', description: "Channel kind, e.g. 'slack'." },
1315
- to: { type: 'string', description: 'Channel-native conversation id.' },
1316
- },
1317
- required: ['channel', 'to'],
1318
- };
1319
- /**
1320
- * One schedule as a surface shows it — the CLI table, the control API's
1321
- * listing, and the tool results all render this one projection, so the
1322
- * operator's view and the agent's cannot drift.
1323
- */
1324
- export const describeSchedule = (record) => ({
1325
- id: record.id,
1326
- agentId: record.agentId,
1327
- cadence: describeCadence(record.cadence),
1328
- prompt: record.prompt,
1329
- trust: record.trust ?? 'unknown',
1330
- ...(record.destination ? { destination: canonicalDestination(record.destination) } : {}),
1331
- ...(record.nextFireAt ? { nextFireAt: record.nextFireAt } : {}),
1332
- ...(record.lastFiredAt ? { lastFiredAt: record.lastFiredAt } : {}),
1333
- createdAt: record.createdAt,
1334
- });
1335
- /**
1336
- * The four `schedule.*` tools over one handle.
1337
- *
1338
- * Risk is split per tool, not per toolset: creating a schedule spends
1339
- * future money unattended and (with a destination) mints a standing
1340
- * permission to speak, so `schedule.every` and `schedule.at` are `gated` —
1341
- * the approval of THAT call is the human decision the whole step leans on.
1342
- * `schedule.list` is a read, and `schedule.cancel` only ever narrows
1343
- * authority — it is the reversal the risk note in the spec names — so both
1344
- * are `safe`: a headless agent that set a bad schedule must be able to
1345
- * undo it without waiting for the human whose absence is the problem.
1346
- */
1347
- export const createScheduleTools = (scheduler) => {
1348
- const create = async (session, cadence, input) => {
1349
- const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
1350
- if (!prompt) {
1351
- throw new Error('A schedule needs a non-empty "prompt" — the instruction each firing runs.');
1352
- }
1353
- if (input.destination !== undefined && parseDestinationInput(input.destination) === undefined) {
1354
- throw new Error('"destination" must be { channel, to } with non-empty strings, or omitted.');
1355
- }
1356
- const destination = parseDestinationInput(input.destination);
1357
- const record = await scheduler.create({
1358
- agentId: session.agent.id,
1359
- cadence,
1360
- prompt,
1361
- ...(destination ? { destination } : {}),
1362
- createdBy: session.id,
1363
- trust: sessionWriteTrust(session),
1364
- });
1365
- return { created: true, schedule: describeSchedule(record) };
1366
- };
1367
- return [
1368
- {
1369
- name: SCHEDULE_EVERY_TOOL_NAME,
1370
- description: 'Set a recurring schedule for yourself: an interval ("30m", "1d") or a five-field cron expression, the prompt each firing runs, and optionally the destination your reports are pre-authorized to post to. Creating a schedule needs human approval once; its firings then run unattended.',
1371
- risk: 'gated',
1372
- parameters: {
1373
- type: 'object',
1374
- properties: {
1375
- every: { type: 'string', description: 'Interval like "90s", "30m", "2h", "1d". Exactly one of "every" or "cron".' },
1376
- cron: { type: 'string', description: 'Five-field cron expression (minute hour day-of-month month day-of-week), local time. Exactly one of "every" or "cron".' },
1377
- prompt: { type: 'string', description: 'The instruction each firing runs, phrased to stand alone.' },
1378
- destination: DESTINATION_PARAMETER,
1379
- },
1380
- required: ['prompt'],
1381
- },
1382
- async execute(input, session) {
1383
- const every = typeof input.every === 'string' ? input.every.trim() : '';
1384
- const cron = typeof input.cron === 'string' ? input.cron.trim() : '';
1385
- if ((every === '') === (cron === '')) {
1386
- throw new Error('Pass exactly one of "every" (an interval) or "cron" (a cron expression).');
1387
- }
1388
- if (every) {
1389
- const intervalMs = parseInterval(every);
1390
- if (intervalMs === undefined) {
1391
- throw new Error(`Not an interval: "${every}". Use a positive integer and one unit — "90s", "30m", "2h", "1d".`);
1392
- }
1393
- return create(session, { kind: 'every', intervalMs }, input);
1394
- }
1395
- parseCronExpression(cron); // Refuse a bad expression here, with its own message.
1396
- return create(session, { kind: 'cron', expression: cron }, input);
1397
- },
1398
- },
1399
- {
1400
- name: SCHEDULE_AT_TOOL_NAME,
1401
- description: 'Schedule a one-shot run of a prompt at a future time (ISO-8601). Needs human approval once; the firing then runs unattended.',
1402
- risk: 'gated',
1403
- parameters: {
1404
- type: 'object',
1405
- properties: {
1406
- at: { type: 'string', description: 'When to fire, ISO-8601 (e.g. 2026-09-01T07:00:00). Must be in the future.' },
1407
- prompt: { type: 'string', description: 'The instruction the firing runs, phrased to stand alone.' },
1408
- destination: DESTINATION_PARAMETER,
1409
- },
1410
- required: ['at', 'prompt'],
1411
- },
1412
- async execute(input, session) {
1413
- const at = typeof input.at === 'string' ? input.at.trim() : '';
1414
- const parsed = new Date(at);
1415
- if (!at || Number.isNaN(parsed.getTime())) {
1416
- throw new Error(`Not a timestamp: "${at}". Use ISO-8601, e.g. 2026-09-01T07:00:00.`);
1417
- }
1418
- if (parsed.getTime() <= Date.now()) {
1419
- throw new Error(`${at} is in the past — a one-shot schedule must name a future time.`);
1420
- }
1421
- return create(session, { kind: 'at', at: parsed.toISOString() }, input);
1422
- },
1423
- },
1424
- {
1425
- name: SCHEDULE_LIST_TOOL_NAME,
1426
- description: 'List your own schedules: cadence, prompt, destination, and when each fires next.',
1427
- // A read of state this agent itself created.
1428
- risk: 'safe',
1429
- parameters: { type: 'object', properties: {} },
1430
- async execute(_input, session, context) {
1431
- const records = await scheduler.list(session.agent.id);
1432
- // Each prompt was written by the session that set the schedule, at
1433
- // that session's label — and a schedule from before labels existed
1434
- // has none. Listing puts those prompts in front of the model, so the
1435
- // call is marked at the lowest of them, as `memory.recall` marks a
1436
- // hit: a fresh session must not restate a tainted prompt as its own.
1437
- const lowest = leastTrusted(...records.map((record) => record.trust ?? 'unknown'));
1438
- if (records.length > 0 && isTaintedTrust(lowest)) {
1439
- context?.markTrust?.(lowest);
1440
- }
1441
- return { schedules: records.map(describeSchedule) };
1442
- },
1443
- },
1444
- {
1445
- name: SCHEDULE_CANCEL_TOOL_NAME,
1446
- description: 'Cancel one of your schedules by id. Cancelling also revokes the pre-authorized destination that was approved with it.',
1447
- // Cancel only ever NARROWS authority — it destroys a human-minted
1448
- // grant — and it is the reversal that keeps schedule creation short
1449
- // of `dangerous`. Gating it would leave a headless agent unable to
1450
- // undo its own schedule for want of the human whose absence is the
1451
- // point of headless.
1452
- risk: 'safe',
1453
- parameters: {
1454
- type: 'object',
1455
- properties: {
1456
- id: { type: 'string', description: 'The schedule id, from schedule.list or the creation result.' },
1457
- },
1458
- required: ['id'],
1459
- },
1460
- async execute(input, session) {
1461
- const id = typeof input.id === 'string' ? input.id.trim() : '';
1462
- if (!id) {
1463
- throw new Error('schedule.cancel requires a non-empty "id".');
1464
- }
1465
- const cancelled = await scheduler.cancel(session.agent.id, id);
1466
- if (!cancelled) {
1467
- throw new Error(`No schedule of yours has id ${id}. schedule.list shows what exists.`);
1468
- }
1469
- return { cancelled: true, id };
1470
- },
1471
- },
1472
- ];
1473
- };
1474
- /**
1475
- * Post to a channel or DM outside the current conversation — what makes a
1476
- * scheduled turn observable.
1477
- *
1478
- * `gated`, and deliberately not `safe` the way `agent.delegate` is: the
1479
- * delegate stays inside the fleet, under every per-agent allowlist and the
1480
- * same approval policy, while this speaks to people who did not ask. The
1481
- * unattended path is the schedule carve-out — `destinationFor` names where
1482
- * this call would speak, and the policy allows it exactly when the firing's
1483
- * schedule was approved with that destination.
1484
- */
1485
- export const createMessageSendTool = (send) => ({
1486
- name: MESSAGE_SEND_TOOL_NAME,
1487
- description: 'Send a message to a channel or DM you are not currently talking in. Scheduled turns may post to their schedule\'s approved destination without asking; anywhere else needs approval.',
1488
- risk: 'gated',
1489
- parameters: {
1490
- type: 'object',
1491
- properties: {
1492
- destination: {
1493
- ...DESTINATION_PARAMETER,
1494
- description: 'Where to post: the channel kind plus the channel-native conversation id.',
1495
- },
1496
- text: { type: 'string', description: 'The message text.' },
1497
- },
1498
- required: ['destination', 'text'],
1499
- },
1500
- destinationFor(input) {
1501
- const destination = parseDestinationInput(input.destination);
1502
- return destination ? canonicalDestination(destination) : undefined;
1503
- },
1504
- async execute(input, session) {
1505
- const destination = parseDestinationInput(input.destination);
1506
- if (!destination) {
1507
- throw new Error('message.send requires "destination" as { channel, to } with non-empty strings.');
1508
- }
1509
- const text = typeof input.text === 'string' ? input.text.trim() : '';
1510
- if (!text) {
1511
- throw new Error('message.send requires a non-empty "text".');
1512
- }
1513
- await send({ agentId: session.agent.id, destination, text });
1514
- return { sent: true, destination: canonicalDestination(destination) };
1515
- },
1516
- });
1
+ // The package's one public surface: every export below is re-exported by
2
+ // name from the module that owns it. What an agent *is* — its identity,
3
+ // its soul file, its skills — sits at the top level; `tools/` holds the
4
+ // tool factories an agent is given (memory, delegation, schedules,
5
+ // message.send), each with the tool names it owns.
6
+ export { AVATAR_STYLE, generateAgentName, generateAvatarTheme, AGENT_ID_PATTERN, MAX_AGENT_ID_LENGTH, isValidAgentId, isValidDelegateEntry, MAX_SESSION_ID_LENGTH, isValidSessionId, agentIdWithSuffix, } from "./identity.js";
7
+ export { defineAgent } from "./define.js";
8
+ export { isDelegateAllowed, parseSoul, formatSoul, } from "./soul.js";
9
+ export { SKILL_ID_PATTERN, SKILL_ID_MAX_LENGTH, isValidSkillId, isLoadableSkillId, SKILL_ID_RULE, SKILL_DESCRIPTION_MAX_LENGTH, SKILL_COMPATIBILITY_MAX_LENGTH, parseSkillDocument, validateSkillDocument, createLazySkill, } from "./skills.js";
10
+ export { canonicalDestination, parseInterval, parseCronExpression, nextFireAfter, describeCadence, SCHEDULE_ID_METADATA_KEY, SCHEDULED_TURN_METADATA_KEY, describeSchedule, } from "./schedules.js";
11
+ export { createAgentRouter, createAgentTeam, } from "./router.js";
12
+ export { MEMORY_TOOL_NAME, createRememberTool, RECALL_TOOL_NAME, createRecallTool, FORGET_TOOL_NAME, createForgetTool, PIN_TOOL_NAME, createPinTool, } from "./tools/memory.js";
13
+ export { DELEGATE_TOOL_NAME, DELEGATED_BY_METADATA_KEY, ROOT_SESSION_ID_METADATA_KEY, DELEGATION_DEPTH_METADATA_KEY, DELEGATED_SESSION_ID_MARKER, isDelegatedSession, delegatingSessionIdOf, outstandingDelegationFor, withoutDelegation, createDelegateTool, } from "./tools/delegate.js";
14
+ export { SCHEDULE_EVERY_TOOL_NAME, SCHEDULE_AT_TOOL_NAME, SCHEDULE_LIST_TOOL_NAME, SCHEDULE_CANCEL_TOOL_NAME, createScheduleTools, } from "./tools/schedule.js";
15
+ export { MESSAGE_SEND_TOOL_NAME, createMessageSendTool, } from "./tools/message.js";
16
+ export { GATEWAY_ONLY_TOOL_NAMES } from "./tools/gateway-only.js";
1517
17
  //# sourceMappingURL=index.js.map