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