loopengine 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/core/agent-config.d.ts +4 -0
- package/dist/core/model-calls/anthropic-model-call.d.ts +7 -0
- package/dist/core/model-calls/anthropic-model-call.js +34 -5
- package/dist/core/model-calls/openai-model-call.js +2 -1
- package/dist/core/run-agent.js +11 -2
- package/dist/core/scheduler.d.ts +29 -1
- package/dist/core/scheduler.js +28 -4
- package/dist/core/skillgarden/loader.d.ts +5 -1
- package/dist/core/skillgarden/loader.js +10 -3
- package/dist/core/skillgarden/substitute.d.ts +15 -1
- package/dist/core/skillgarden/substitute.js +28 -6
- package/dist/core/system-agents/templates.d.ts +5 -0
- package/dist/core/system-agents/workflow-planner.d.ts +26 -1
- package/dist/core/system-agents/workflow-planner.js +57 -5
- package/dist/core/system-tools/schedule.d.ts +5 -1
- package/dist/core/system-tools/schedule.js +45 -10
- package/dist/core/workflows.d.ts +52 -23
- package/dist/core/workflows.js +218 -27
- package/dist/index.d.ts +7 -4
- package/dist/index.js +7 -4
- package/dist/web/agent-file-admin.d.ts +2 -0
- package/dist/web/agent-file-admin.js +8 -6
- package/dist/web/agents-config-page.js +411 -24
- package/dist/web/skills-admin.d.ts +4 -8
- package/dist/web/skills-admin.js +8 -12
- package/dist/web/templates-admin.js +7 -2
- package/dist/web/workflow-admin.d.ts +43 -18
- package/dist/web/workflow-admin.js +117 -50
- package/dist/web/workflow-page.js +72 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -121,7 +121,7 @@ code changes or redeploy needed for most of it:
|
|
|
121
121
|
| **Tools** | Your hand-written tools, tools from integrations (e.g. [Composio](https://composio.dev)) and subagents, with whether each call runs on its own, asks first, or is denied. Build an HTTP tool or connect an integration without touching a file. |
|
|
122
122
|
| **Skills** | Create, edit, and delete `SKILL.md` files for this agent directly in the browser — write the body, preview the rendered markdown, save. |
|
|
123
123
|
| **Permissions** | Add, edit, and delete permission rules (actauth) — who, tool, decision — and choose what happens when no rule matches. |
|
|
124
|
-
| **Schedules** |
|
|
124
|
+
| **Schedules** | One of the agent's workflows (with values for its parameters), or a custom message, run on a timer — once at a set time, or every N minutes/hours/days. A workflow schedule starts each run with the workflow's start message and steps as they are then. Run now, pause, resume, cancel, see each run's result, and optionally send results through the agent's notifier. |
|
|
125
125
|
| **Environment** | Every setting an installed ability reads, with its project-wide value and this agent's own value side by side, and which one the agent actually uses. A secret is never shown again once set. |
|
|
126
126
|
|
|
127
127
|
Models and Integrations in the sidebar show which model providers have a
|
|
@@ -273,6 +273,10 @@ export type AgentModelConfig = {
|
|
|
273
273
|
model?: string;
|
|
274
274
|
apiKey?: string;
|
|
275
275
|
maxTokens?: number;
|
|
276
|
+
/** How much Claude thinks before answering; omitted, the model's own
|
|
277
|
+
* default. See AnthropicModelCallOptions.reasoningEffort
|
|
278
|
+
* (core/model-calls/anthropic-model-call.ts). */
|
|
279
|
+
reasoningEffort?: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
|
276
280
|
} | {
|
|
277
281
|
provider: 'openai';
|
|
278
282
|
model: string;
|
|
@@ -4,7 +4,14 @@ export interface AnthropicModelCallOptions {
|
|
|
4
4
|
/** Defaults to the ANTHROPIC_API_KEY env var, same as the SDK itself. */
|
|
5
5
|
apiKey?: string;
|
|
6
6
|
model?: string;
|
|
7
|
+
/** Defaults to 4096, or 16000 with a reasoning effort set: thinking
|
|
8
|
+
* counts toward it. */
|
|
7
9
|
maxTokens?: number;
|
|
10
|
+
/** How much Claude thinks before answering. 'none' turns thinking off;
|
|
11
|
+
* 'minimal' and 'low' to 'max' turn on adaptive thinking at that effort
|
|
12
|
+
* (Claude has no 'minimal', so it means 'low'). Omitted, the model's
|
|
13
|
+
* own default. */
|
|
14
|
+
reasoningEffort?: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
|
8
15
|
/** Inject a pre-configured client instead of building one from apiKey — e.g. to pass a custom `fetch` in tests. */
|
|
9
16
|
client?: Anthropic;
|
|
10
17
|
}
|
|
@@ -13,7 +13,24 @@
|
|
|
13
13
|
// Anthropic's own API expects a tool_use to be answered by a matching
|
|
14
14
|
// tool_result. This file's only job is translating between that shape and
|
|
15
15
|
// the SDK's own param/response types — see message-to-param below.
|
|
16
|
+
//
|
|
17
|
+
// With a reasoning effort set, Claude thinks before answering (adaptive
|
|
18
|
+
// thinking at that effort), and its thinking blocks come back signed. The
|
|
19
|
+
// API needs them sent back unchanged with the tool results they led to,
|
|
20
|
+
// so each rides in the conversation as an 'anthropic_thinking' block (the
|
|
21
|
+
// whole block as JSON in `data`), sent back only to Anthropic.
|
|
16
22
|
import Anthropic from '@anthropic-ai/sdk';
|
|
23
|
+
/** The thinking (or redacted thinking) block an 'anthropic_thinking'
|
|
24
|
+
* block carries, or undefined if its data isn't one. */
|
|
25
|
+
function thinkingBlockParam(block) {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(block.data ?? '');
|
|
28
|
+
return parsed && (parsed.type === 'thinking' || parsed.type === 'redacted_thinking') ? parsed : undefined;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
17
34
|
function toContentBlockParam(block) {
|
|
18
35
|
if (block.type === 'tool_use') {
|
|
19
36
|
return { type: 'tool_use', id: block.id, name: block.name, input: block.input ?? {} };
|
|
@@ -29,18 +46,27 @@ function toContentBlockParam(block) {
|
|
|
29
46
|
function toMessageParam(message) {
|
|
30
47
|
return {
|
|
31
48
|
role: message.role === 'assistant' ? 'assistant' : 'user',
|
|
32
|
-
//
|
|
33
|
-
// 'thinking', 'openai_reasoning')
|
|
34
|
-
// empty text blocks, which the API
|
|
49
|
+
// Claude's own signed thinking goes back as it came. Blocks only
|
|
50
|
+
// another provider can use (DeepSeek's 'thinking', 'openai_reasoning')
|
|
51
|
+
// are left out; as text they'd be empty text blocks, which the API
|
|
52
|
+
// rejects.
|
|
35
53
|
content: typeof message.content === 'string'
|
|
36
54
|
? message.content
|
|
37
|
-
: message.content.
|
|
55
|
+
: message.content.flatMap((block) => {
|
|
56
|
+
if (block.type === 'anthropic_thinking') {
|
|
57
|
+
const thinking = thinkingBlockParam(block);
|
|
58
|
+
return thinking ? [thinking] : [];
|
|
59
|
+
}
|
|
60
|
+
return ['text', 'tool_use', 'tool_result'].includes(block.type) ? [toContentBlockParam(block)] : [];
|
|
61
|
+
}),
|
|
38
62
|
};
|
|
39
63
|
}
|
|
40
64
|
export function createAnthropicModelCall(options = {}) {
|
|
41
65
|
const client = options.client ?? new Anthropic({ apiKey: options.apiKey });
|
|
42
66
|
const model = options.model ?? 'claude-sonnet-5';
|
|
43
|
-
const maxTokens = options.maxTokens ?? 4096;
|
|
67
|
+
const maxTokens = options.maxTokens ?? (options.reasoningEffort ? 16000 : 4096);
|
|
68
|
+
const effort = options.reasoningEffort === 'minimal' ? 'low' : options.reasoningEffort;
|
|
69
|
+
const reasoning = effort === undefined ? {} : effort === 'none' ? { thinking: { type: 'disabled' } } : { thinking: { type: 'adaptive' }, output_config: { effort } };
|
|
44
70
|
return async (messages, system, tools) => {
|
|
45
71
|
const response = await client.messages.create({
|
|
46
72
|
model,
|
|
@@ -54,10 +80,13 @@ export function createAnthropicModelCall(options = {}) {
|
|
|
54
80
|
input_schema: t.input_schema,
|
|
55
81
|
}))
|
|
56
82
|
: undefined,
|
|
83
|
+
...reasoning,
|
|
57
84
|
});
|
|
58
85
|
const content = response.content.map((block) => {
|
|
59
86
|
if (block.type === 'text')
|
|
60
87
|
return { type: 'text', text: block.text };
|
|
88
|
+
if (block.type === 'thinking' || block.type === 'redacted_thinking')
|
|
89
|
+
return { type: 'anthropic_thinking', data: JSON.stringify(block) };
|
|
61
90
|
if (block.type === 'tool_use') {
|
|
62
91
|
return { type: 'tool_use', id: block.id, name: block.name, input: block.input };
|
|
63
92
|
}
|
|
@@ -234,7 +234,8 @@ export function fromResponse(response) {
|
|
|
234
234
|
}
|
|
235
235
|
export function createOpenAIModelCall(options) {
|
|
236
236
|
const client = options.client ?? new OpenAI({ apiKey: options.apiKey });
|
|
237
|
-
|
|
237
|
+
// Reasoning counts toward max_output_tokens.
|
|
238
|
+
const maxTokens = options.maxTokens ?? (options.reasoningEffort && options.reasoningEffort !== 'none' ? 16000 : 4096);
|
|
238
239
|
return async (messages, system, tools) => {
|
|
239
240
|
const response = await client.responses.create({
|
|
240
241
|
model: options.model,
|
package/dist/core/run-agent.js
CHANGED
|
@@ -302,7 +302,7 @@ async function buildTurnContext(config, modelCall, options) {
|
|
|
302
302
|
// Bound to this agent: it can schedule, list and cancel only its own
|
|
303
303
|
// tasks (see core/system-tools/schedule.ts). AgentConfig.selfScheduling
|
|
304
304
|
// false leaves them out.
|
|
305
|
-
const scheduleTools = config.selfScheduling === false ? [] : createScheduleTools(config.name);
|
|
305
|
+
const scheduleTools = config.selfScheduling === false ? [] : createScheduleTools(config.name, { agentDir: agentDirFor(config) });
|
|
306
306
|
const tools = dedupeToolsByName([
|
|
307
307
|
...systemTools,
|
|
308
308
|
askUserTool,
|
|
@@ -423,6 +423,11 @@ async function buildTurnContext(config, modelCall, options) {
|
|
|
423
423
|
type: 'string',
|
|
424
424
|
description: 'Optional arguments for the skill body — substituted for $ARGUMENTS/$1/$2 placeholders.',
|
|
425
425
|
},
|
|
426
|
+
params: {
|
|
427
|
+
type: 'object',
|
|
428
|
+
description: 'Optional named values for a skill that takes them, e.g. { "order_number": "4821" }: each fills that $name in the skill body.',
|
|
429
|
+
additionalProperties: { type: 'string' },
|
|
430
|
+
},
|
|
426
431
|
},
|
|
427
432
|
required: ['skill'],
|
|
428
433
|
},
|
|
@@ -618,7 +623,11 @@ async function runLoop(ctx, messages, newMessages, starterMessage) {
|
|
|
618
623
|
// scheduling — but it's still a tool_use block the model emitted,
|
|
619
624
|
// so it still needs a tool_result to answer it, same as any other.
|
|
620
625
|
if (block.name === 'Skill' && skillGarden) {
|
|
621
|
-
const
|
|
626
|
+
const rawParams = block.input?.params;
|
|
627
|
+
const params = rawParams && typeof rawParams === 'object' && !Array.isArray(rawParams)
|
|
628
|
+
? Object.fromEntries(Object.entries(rawParams).filter(([, v]) => v !== undefined && v !== null).map(([k, v]) => [k, String(v)]))
|
|
629
|
+
: undefined;
|
|
630
|
+
const body = skillGarden.invoke(block.input.skill, block.input?.args, params);
|
|
622
631
|
log({ type: 'skill:loaded', skill: block.input.skill });
|
|
623
632
|
resultBlocks.push({ type: 'tool_result', tool_use_id: block.id, content: body });
|
|
624
633
|
continue;
|
package/dist/core/scheduler.d.ts
CHANGED
|
@@ -20,7 +20,15 @@ export interface ScheduledTask {
|
|
|
20
20
|
task_id: string;
|
|
21
21
|
task_name?: string;
|
|
22
22
|
agent: string;
|
|
23
|
+
/** What the agent receives each run. Empty for a workflow task, which
|
|
24
|
+
* sends its workflow's start message instead. */
|
|
23
25
|
message: string;
|
|
26
|
+
/** Set on a workflow task: each run starts this workflow of the
|
|
27
|
+
* agent's (core/workflows.ts) as it is at run time, so editing the
|
|
28
|
+
* workflow changes what the next run does. */
|
|
29
|
+
workflow_id?: string;
|
|
30
|
+
/** The workflow's parameters for every run of this task. */
|
|
31
|
+
params?: Record<string, string>;
|
|
24
32
|
schedule: Schedule;
|
|
25
33
|
status: ScheduledTaskStatus;
|
|
26
34
|
created_at: string;
|
|
@@ -74,9 +82,20 @@ export declare class FileSchedulerStore implements SchedulerStore {
|
|
|
74
82
|
* LOOPENGINE_SCHEDULER_DIR. */
|
|
75
83
|
export declare function getSchedulerStore(): SchedulerStore;
|
|
76
84
|
export declare function setSchedulerStore(next: SchedulerStore): void;
|
|
85
|
+
/** The message a workflow run starts with, worked out at run time;
|
|
86
|
+
* undefined when the agent has no such workflow (any more). It may throw
|
|
87
|
+
* when the task's params no longer fit the workflow. Supplied by the
|
|
88
|
+
* server, which knows where agents' workflows are kept. */
|
|
89
|
+
export type ResolveWorkflowMessage = (agent: string, workflowId: string, params: Record<string, string>) => string | undefined | Promise<string | undefined>;
|
|
77
90
|
export interface CreateScheduledTaskInput {
|
|
78
91
|
agent: string;
|
|
79
|
-
message
|
|
92
|
+
/** A custom message, or… */
|
|
93
|
+
message?: string;
|
|
94
|
+
/** …one of the agent's workflows, by id. Exactly one of the two. The
|
|
95
|
+
* caller checks the workflow exists and the params fit it; the
|
|
96
|
+
* scheduler only stores them. */
|
|
97
|
+
workflow_id?: string;
|
|
98
|
+
params?: Record<string, string>;
|
|
80
99
|
/** `start_at` (with `every` only): when the first run is, and so the
|
|
81
100
|
* time of day / day of week every later run keeps, e.g. every "1d"
|
|
82
101
|
* from "2026-11-02T09:00:00+08:00" runs daily at 9:00. A start in the
|
|
@@ -105,6 +124,9 @@ export declare function getScheduledTask(taskId: string): Promise<ScheduledTask
|
|
|
105
124
|
* approval isn't retracted. */
|
|
106
125
|
export declare function cancelScheduledTask(taskId: string): Promise<ScheduledTask>;
|
|
107
126
|
export declare function pauseScheduledTask(taskId: string): Promise<ScheduledTask>;
|
|
127
|
+
/** Turns sending each run's result through the agent's notifier on or
|
|
128
|
+
* off, from the next run. */
|
|
129
|
+
export declare function setScheduledTaskNotify(taskId: string, notify: boolean): Promise<ScheduledTask>;
|
|
108
130
|
export declare function resumeScheduledTask(taskId: string, nowMs?: number): Promise<ScheduledTask>;
|
|
109
131
|
/** Fires the task on the next tick, without changing its schedule. A
|
|
110
132
|
* finished one-off task runs once more. */
|
|
@@ -135,12 +157,14 @@ export type FireTask = (task: ScheduledTask, sessionId: string) => Promise<{
|
|
|
135
157
|
}>;
|
|
136
158
|
export declare class Scheduler {
|
|
137
159
|
private readonly fire;
|
|
160
|
+
private readonly resolveWorkflowMessage?;
|
|
138
161
|
private readonly tickMs;
|
|
139
162
|
private readonly onSettled?;
|
|
140
163
|
private readonly inFlight;
|
|
141
164
|
private timer?;
|
|
142
165
|
constructor(options: {
|
|
143
166
|
fire: FireTask;
|
|
167
|
+
resolveWorkflowMessage?: ResolveWorkflowMessage;
|
|
144
168
|
tickMs?: number;
|
|
145
169
|
onSettled?: (task: ScheduledTask, run: ScheduledTaskRun) => void;
|
|
146
170
|
});
|
|
@@ -154,6 +178,10 @@ export declare class Scheduler {
|
|
|
154
178
|
* request calls it directly). */
|
|
155
179
|
tick(nowMs?: number): Promise<number>;
|
|
156
180
|
private run;
|
|
181
|
+
/** The task as it fires: a workflow task gets its workflow's start
|
|
182
|
+
* message as it is now. A workflow that's gone fails the run, rather
|
|
183
|
+
* than sending a stale or empty message. */
|
|
184
|
+
private withMessage;
|
|
157
185
|
/** A paused run resumed after a decision (adapters/http.ts calls this
|
|
158
186
|
* from the resolve path): settle it, or record the next decision it's
|
|
159
187
|
* now waiting on. */
|
package/dist/core/scheduler.js
CHANGED
|
@@ -141,8 +141,12 @@ export function setSchedulerStore(next) {
|
|
|
141
141
|
export async function createScheduledTask(input, nowMs = Date.now()) {
|
|
142
142
|
if (!input.agent)
|
|
143
143
|
throw new ScheduleError('agent is required');
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
const message = typeof input.message === 'string' ? input.message : '';
|
|
145
|
+
const workflowId = typeof input.workflow_id === 'string' ? input.workflow_id.trim() : '';
|
|
146
|
+
if (message.trim() && workflowId)
|
|
147
|
+
throw new ScheduleError('give either a message or a workflow_id, not both');
|
|
148
|
+
if (!message.trim() && !workflowId)
|
|
149
|
+
throw new ScheduleError('message or workflow_id is required');
|
|
146
150
|
let schedule;
|
|
147
151
|
let nextRunMs;
|
|
148
152
|
const timeZone = workspaceTimeZone();
|
|
@@ -197,7 +201,9 @@ export async function createScheduledTask(input, nowMs = Date.now()) {
|
|
|
197
201
|
task_id: randomUUID(),
|
|
198
202
|
task_name: input.task_name?.trim() || undefined,
|
|
199
203
|
agent: input.agent,
|
|
200
|
-
message:
|
|
204
|
+
message: workflowId ? '' : message,
|
|
205
|
+
...(workflowId ? { workflow_id: workflowId } : {}),
|
|
206
|
+
...(workflowId && input.params && Object.keys(input.params).length ? { params: input.params } : {}),
|
|
201
207
|
schedule,
|
|
202
208
|
status: 'scheduled',
|
|
203
209
|
created_at: new Date(nowMs).toISOString(),
|
|
@@ -228,6 +234,11 @@ export async function cancelScheduledTask(taskId) {
|
|
|
228
234
|
export async function pauseScheduledTask(taskId) {
|
|
229
235
|
return mustExist(await getSchedulerStore().update(taskId, (t) => (t && t.status === 'scheduled' ? { ...t, status: 'paused', next_run_at: undefined } : t)), taskId);
|
|
230
236
|
}
|
|
237
|
+
/** Turns sending each run's result through the agent's notifier on or
|
|
238
|
+
* off, from the next run. */
|
|
239
|
+
export async function setScheduledTaskNotify(taskId, notify) {
|
|
240
|
+
return mustExist(await getSchedulerStore().update(taskId, (t) => t && { ...t, notify: notify || undefined }), taskId);
|
|
241
|
+
}
|
|
231
242
|
export async function resumeScheduledTask(taskId, nowMs = Date.now()) {
|
|
232
243
|
return mustExist(await getSchedulerStore().update(taskId, (t) => {
|
|
233
244
|
if (!t || t.status !== 'paused')
|
|
@@ -343,12 +354,14 @@ async function importLegacyAbilityTasks() {
|
|
|
343
354
|
}
|
|
344
355
|
export class Scheduler {
|
|
345
356
|
fire;
|
|
357
|
+
resolveWorkflowMessage;
|
|
346
358
|
tickMs;
|
|
347
359
|
onSettled;
|
|
348
360
|
inFlight = new Set();
|
|
349
361
|
timer;
|
|
350
362
|
constructor(options) {
|
|
351
363
|
this.fire = options.fire;
|
|
364
|
+
this.resolveWorkflowMessage = options.resolveWorkflowMessage;
|
|
352
365
|
this.tickMs = options.tickMs ?? (Number(process.env.LOOPENGINE_SCHEDULER_TICK_MS) || 10_000);
|
|
353
366
|
this.onSettled = options.onSettled;
|
|
354
367
|
}
|
|
@@ -392,7 +405,7 @@ export class Scheduler {
|
|
|
392
405
|
return;
|
|
393
406
|
const sessionId = `sched-${taskId.slice(0, 8)}-${claimed.run_count + 1}-${Date.now().toString(36)}`;
|
|
394
407
|
try {
|
|
395
|
-
const outcome = await this.fire(claimed, sessionId);
|
|
408
|
+
const outcome = await this.fire(await this.withMessage(claimed), sessionId);
|
|
396
409
|
if (outcome.kind === 'pending') {
|
|
397
410
|
await getSchedulerStore().update(taskId, (t) => t && {
|
|
398
411
|
...t,
|
|
@@ -410,6 +423,17 @@ export class Scheduler {
|
|
|
410
423
|
await this.settle(taskId, ranAt, { status: 'error', error: err instanceof Error ? err.message : String(err), session_id: sessionId });
|
|
411
424
|
}
|
|
412
425
|
}
|
|
426
|
+
/** The task as it fires: a workflow task gets its workflow's start
|
|
427
|
+
* message as it is now. A workflow that's gone fails the run, rather
|
|
428
|
+
* than sending a stale or empty message. */
|
|
429
|
+
async withMessage(task) {
|
|
430
|
+
if (!task.workflow_id)
|
|
431
|
+
return task;
|
|
432
|
+
const message = this.resolveWorkflowMessage ? await this.resolveWorkflowMessage(task.agent, task.workflow_id, task.params ?? {}) : undefined;
|
|
433
|
+
if (!message?.trim())
|
|
434
|
+
throw new ScheduleError(`The workflow "${task.workflow_id}" is no longer on ${task.agent}; cancel this schedule or schedule another workflow.`);
|
|
435
|
+
return { ...task, message };
|
|
436
|
+
}
|
|
413
437
|
/** A paused run resumed after a decision (adapters/http.ts calls this
|
|
414
438
|
* from the resolve path): settle it, or record the next decision it's
|
|
415
439
|
* now waiting on. */
|
|
@@ -23,7 +23,11 @@ export declare class SkillGarden {
|
|
|
23
23
|
load(name: string): LoadedSkill;
|
|
24
24
|
/** Load + substitute placeholders, ready to inject into a running
|
|
25
25
|
* conversation. */
|
|
26
|
-
|
|
26
|
+
/** A skill's body for the model, with `params` filled into its
|
|
27
|
+
* `$name` placeholders (those its front matter declares under
|
|
28
|
+
* `parameters` become empty when not given) and `args` into
|
|
29
|
+
* `$ARGUMENTS`/`$1`… */
|
|
30
|
+
invoke(name: string, args?: string, params?: Record<string, string>): string;
|
|
27
31
|
/** Filters index entries down to skills eligible given files touched
|
|
28
32
|
* so far in the session. Skills with no `paths` restriction are always
|
|
29
33
|
* eligible. */
|
|
@@ -3,7 +3,7 @@ import { matchesActivationPaths } from './activation.js';
|
|
|
3
3
|
import { buildBudgetedIndex, estimateTokens } from './budget.js';
|
|
4
4
|
import { discoverSkillFiles } from './discovery.js';
|
|
5
5
|
import { parseSkillFile } from './frontmatter.js';
|
|
6
|
-
import { substituteArguments } from './substitute.js';
|
|
6
|
+
import { substituteArguments, substituteParameters } from './substitute.js';
|
|
7
7
|
/** Phase 1: scan + parse frontmatter only, budget-capped — this is what
|
|
8
8
|
* stays loaded in context at all times. Phase 2 (`load`/`invoke`) reads
|
|
9
9
|
* the full body lazily, only for a skill actually being invoked. */
|
|
@@ -52,9 +52,16 @@ export class SkillGarden {
|
|
|
52
52
|
}
|
|
53
53
|
/** Load + substitute placeholders, ready to inject into a running
|
|
54
54
|
* conversation. */
|
|
55
|
-
|
|
55
|
+
/** A skill's body for the model, with `params` filled into its
|
|
56
|
+
* `$name` placeholders (those its front matter declares under
|
|
57
|
+
* `parameters` become empty when not given) and `args` into
|
|
58
|
+
* `$ARGUMENTS`/`$1`… */
|
|
59
|
+
invoke(name, args, params) {
|
|
56
60
|
const skill = this.load(name);
|
|
57
|
-
|
|
61
|
+
const declaredSpec = skill.frontmatter.parameters;
|
|
62
|
+
const declared = declaredSpec && typeof declaredSpec === 'object' && !Array.isArray(declaredSpec) ? Object.keys(declaredSpec) : [];
|
|
63
|
+
const named = params || declared.length ? substituteParameters(skill.body, params ?? {}, { declared }) : skill.body;
|
|
64
|
+
return substituteArguments(named, args);
|
|
58
65
|
}
|
|
59
66
|
/** Filters index entries down to skills eligible given files touched
|
|
60
67
|
* so far in the session. Skills with no `paths` restriction are always
|
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
/** Replaces `$ARGUMENTS` with the raw args string, and `$1`/`$2`/... with
|
|
2
2
|
* whitespace-split positional args — the same placeholder convention
|
|
3
|
-
* skill authors already expect from Claude Code's own Skill tool.
|
|
3
|
+
* skill authors already expect from Claude Code's own Skill tool.
|
|
4
|
+
*
|
|
5
|
+
* A `$N` is filled only when that argument was given: with two args, `$1`
|
|
6
|
+
* and `$2` are, while `$3` and a dollar amount like `$50` stay as
|
|
7
|
+
* written; with none, nothing positional changes. `$$1` is a literal
|
|
8
|
+
* "$1", for a skill that takes args and mentions one. One pass, so an
|
|
9
|
+
* argument's own text is never filled in again. */
|
|
4
10
|
export declare function substituteArguments(body: string, args?: string): string;
|
|
11
|
+
/** Replaces `$name` placeholders (lowercase, e.g. `$order_number`) with
|
|
12
|
+
* named parameters. A name in `declared` that wasn't given becomes empty
|
|
13
|
+
* text; any other `$word` is left as it is, so `$ARGUMENTS`, `$1` and
|
|
14
|
+
* dollar amounts are untouched. Names it fills are added to `used`. */
|
|
15
|
+
export declare function substituteParameters(body: string, params: Record<string, string>, options?: {
|
|
16
|
+
declared?: string[];
|
|
17
|
+
used?: Set<string>;
|
|
18
|
+
}): string;
|
|
@@ -1,13 +1,35 @@
|
|
|
1
1
|
/** Replaces `$ARGUMENTS` with the raw args string, and `$1`/`$2`/... with
|
|
2
2
|
* whitespace-split positional args — the same placeholder convention
|
|
3
|
-
* skill authors already expect from Claude Code's own Skill tool.
|
|
3
|
+
* skill authors already expect from Claude Code's own Skill tool.
|
|
4
|
+
*
|
|
5
|
+
* A `$N` is filled only when that argument was given: with two args, `$1`
|
|
6
|
+
* and `$2` are, while `$3` and a dollar amount like `$50` stay as
|
|
7
|
+
* written; with none, nothing positional changes. `$$1` is a literal
|
|
8
|
+
* "$1", for a skill that takes args and mentions one. One pass, so an
|
|
9
|
+
* argument's own text is never filled in again. */
|
|
4
10
|
export function substituteArguments(body, args) {
|
|
5
11
|
const argString = args ?? '';
|
|
6
|
-
let result = body.replaceAll('$ARGUMENTS', argString);
|
|
7
12
|
const positional = argString.split(/\s+/).filter(Boolean);
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
return body.replace(/\$(\$(?=\d)|ARGUMENTS|\d+)/g, (match, token) => {
|
|
14
|
+
if (token === '$')
|
|
15
|
+
return '$';
|
|
16
|
+
if (token === 'ARGUMENTS')
|
|
17
|
+
return argString;
|
|
18
|
+
const index = Number(token);
|
|
19
|
+
return index >= 1 && index <= positional.length ? positional[index - 1] : match;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** Replaces `$name` placeholders (lowercase, e.g. `$order_number`) with
|
|
23
|
+
* named parameters. A name in `declared` that wasn't given becomes empty
|
|
24
|
+
* text; any other `$word` is left as it is, so `$ARGUMENTS`, `$1` and
|
|
25
|
+
* dollar amounts are untouched. Names it fills are added to `used`. */
|
|
26
|
+
export function substituteParameters(body, params, options = {}) {
|
|
27
|
+
const declared = new Set(options.declared ?? []);
|
|
28
|
+
return body.replace(/\$([a-z_][a-z0-9_]*)/g, (match, name) => {
|
|
29
|
+
if (Object.prototype.hasOwnProperty.call(params, name)) {
|
|
30
|
+
options.used?.add(name);
|
|
31
|
+
return params[name];
|
|
32
|
+
}
|
|
33
|
+
return declared.has(name) ? '' : match;
|
|
11
34
|
});
|
|
12
|
-
return result;
|
|
13
35
|
}
|
|
@@ -21,6 +21,11 @@ export interface TemplateSchedule {
|
|
|
21
21
|
name: string;
|
|
22
22
|
/** What the agent receives on each run. */
|
|
23
23
|
message: string;
|
|
24
|
+
/** Set when the template comes from a workflow: the schedule runs that
|
|
25
|
+
* workflow (its start message at run time) rather than `message`. */
|
|
26
|
+
workflowId?: string;
|
|
27
|
+
/** Send each run's result through the agent's notifier. */
|
|
28
|
+
notify?: boolean;
|
|
24
29
|
}
|
|
25
30
|
/** An app (core/mcp-apps.ts) a template works through, and the server
|
|
26
31
|
* tools it relies on: switched on when the agent is created, if the app is
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ToolSchema } from '#core/agent-config.js';
|
|
2
|
+
import type { WorkflowParameter } from '#core/workflows.js';
|
|
2
3
|
import type { ModelCall } from '#core/run-agent.js';
|
|
3
4
|
import type { StarterTemplate, TemplateApp } from './templates.js';
|
|
4
5
|
export type Decision = 'allow' | 'ask' | 'deny';
|
|
@@ -76,6 +77,15 @@ export interface WorkflowPlan {
|
|
|
76
77
|
};
|
|
77
78
|
/** What the agent receives when the workflow starts. */
|
|
78
79
|
startMessage: string;
|
|
80
|
+
/** Values a run is given, filled into $name in the start message and
|
|
81
|
+
* steps (core/workflows.ts). Empty when every run is the same. */
|
|
82
|
+
parameters: Record<string, WorkflowParameter>;
|
|
83
|
+
/** Whether each scheduled run's result is sent to the person through
|
|
84
|
+
* the agent's messaging app, and why (shown on the review screen). */
|
|
85
|
+
notify: {
|
|
86
|
+
on: boolean;
|
|
87
|
+
reason: string;
|
|
88
|
+
};
|
|
79
89
|
target: {
|
|
80
90
|
kind: 'existing' | 'new';
|
|
81
91
|
agent?: string;
|
|
@@ -115,13 +125,28 @@ export interface WorkflowPlannerTools {
|
|
|
115
125
|
/** A server's tools, when it can be listed without a key (or with one already set). */
|
|
116
126
|
mcpServerTools?(id: string): Promise<unknown>;
|
|
117
127
|
}
|
|
118
|
-
export declare const WORKFLOW_PLANNER_PROMPT = "You turn a workflow a person describes into a plan for a LoopEngine agent.\n\nLoopEngine agents call tools. Tools come from abilities (installable packages: public ones on npm, or the workspace's own private ones) or are built in. Each tool has a permission: allow (runs on its own), ask (waits for a person to approve, edit or deny it) or deny. When nobody is in Chat, questions and approvals go to the agent's messaging app (Slack, Telegram, Lark or email) and the run waits, for days if needed, then carries on.\n\nHow to plan:\n1. Look before you plan: list_agents (what exists, and what each agent's tools and rules are), list_workspace_abilities, search_public_abilities for each kind of step, ability_details for any ability you might use (its exact tool names and the settings it needs), and list_connections.\n2. Split the workflow into steps, in order. For each step:\n - something to fetch or do: kind \"tool\", with the exact tool name and where it comes from. Pick the source in this order:\n a. A tool the target agent already has (from list_agents): use it, nothing to install.\n b. An ability: a workspace ability (list_workspace_abilities) or a public one (search_public_abilities, e.g. \"firecrawl\" or \"web search\" for searching and reading the web). Set \"ability\" to its install spec (the npm name, or a workspace ability's spec) and use the exact tool name from ability_details.\n c. Only when no ability can do it, and search_mcp_servers is available: an app's MCP server. Search for the app (e.g. \"linear\", \"stripe\"), pick the app's own server (official), else a saved or verified one, then call mcp_server_tools. Set \"mcp\" to the server's id, and \"tool\" to its exact tool name when mcp_server_tools lists them (leave \"tool\" empty when it needs a key first, and say in \"detail\" what the step does). It's set up as a private ability when the workflow is created, which picks its tools and their permissions, so add no rules for MCP tools.\n - the person chooses or answers (\"ask me which\", \"let me pick\"): kind \"ask\", tool \"system_ask_user\" (built in, no ability). Say what options are offered. It reaches them through the agent's messaging app by itself, so don't add a separate step to post to Slack or another chat first, and don't install a chat ability just to ask.\n - the person reviews or approves before something happens (\"let me review it\", \"before it's sent\"): kind \"approval\", on the tool that does that thing; its rule becomes \"ask\".\n - the agent's own work (writing, summarising, choosing): kind \"other\".\n - nothing found can do it: set \"missing\" to what's needed, and add it to gaps.\n3. Rules: every tool the workflow uses gets one. Reading and searching: allow. Anything a person should see first (sending, publishing, paying, creating something others see): ask. Never set deny unless asked.\n4. Skill: the steps in order, as numbered instructions the agent follows, naming the tools and saying exactly when to stop and ask. Write it so the agent keeps going after each answer or decision.\n5. Trigger: a schedule (\"every Monday at 9\" is every \"7d\", at \"09:00\", weekday 1) or on_request (started from Chat or a message). startMessage is what the agent receives when it starts.\n6. Target: an existing agent only when it already has (or can install) the tools, none of the workflow's rules would change a decision it already has, and the same people would approve. Otherwise a new agent, with a short lowercase-hyphenated name and a system prompt saying who it is and what it's for. Give the reason either way.\
|
|
128
|
+
export declare const WORKFLOW_PLANNER_PROMPT = "You turn a workflow a person describes into a plan for a LoopEngine agent.\n\nLoopEngine agents call tools. Tools come from abilities (installable packages: public ones on npm, or the workspace's own private ones) or are built in. Each tool has a permission: allow (runs on its own), ask (waits for a person to approve, edit or deny it) or deny. When nobody is in Chat, questions and approvals go to the agent's messaging app (Slack, Telegram, Lark or email) and the run waits, for days if needed, then carries on.\n\nHow to plan:\n1. Look before you plan: list_agents (what exists, and what each agent's tools and rules are), list_workspace_abilities, search_public_abilities for each kind of step, ability_details for any ability you might use (its exact tool names and the settings it needs), and list_connections.\n2. Split the workflow into steps, in order. For each step:\n - something to fetch or do: kind \"tool\", with the exact tool name and where it comes from. Pick the source in this order:\n a. A tool the target agent already has (from list_agents): use it, nothing to install.\n b. An ability: a workspace ability (list_workspace_abilities) or a public one (search_public_abilities, e.g. \"firecrawl\" or \"web search\" for searching and reading the web). Set \"ability\" to its install spec (the npm name, or a workspace ability's spec) and use the exact tool name from ability_details.\n c. Only when no ability can do it, and search_mcp_servers is available: an app's MCP server. Search for the app (e.g. \"linear\", \"stripe\"), pick the app's own server (official), else a saved or verified one, then call mcp_server_tools. Set \"mcp\" to the server's id, and \"tool\" to its exact tool name when mcp_server_tools lists them (leave \"tool\" empty when it needs a key first, and say in \"detail\" what the step does). It's set up as a private ability when the workflow is created, which picks its tools and their permissions, so add no rules for MCP tools.\n - the person chooses or answers (\"ask me which\", \"let me pick\"): kind \"ask\", tool \"system_ask_user\" (built in, no ability). Say what options are offered. It reaches them through the agent's messaging app by itself, so don't add a separate step to post to Slack or another chat first, and don't install a chat ability just to ask.\n - the person reviews or approves before something happens (\"let me review it\", \"before it's sent\"): kind \"approval\", on the tool that does that thing; its rule becomes \"ask\".\n - the agent's own work (writing, summarising, choosing): kind \"other\".\n - nothing found can do it: set \"missing\" to what's needed, and add it to gaps.\n3. Rules: every tool the workflow uses gets one. Reading and searching: allow. Anything a person should see first (sending, publishing, paying, creating something others see): ask. Never set deny unless asked.\n4. Skill: the steps in order, as numbered instructions the agent follows, naming the tools and saying exactly when to stop and ask. Write it so the agent keeps going after each answer or decision.\n5. Trigger: a schedule (\"every Monday at 9\" is every \"7d\", at \"09:00\", weekday 1) or on_request (started from Chat or a message). startMessage is what the agent receives when it starts.\n6. Notify (scheduled workflows): whether each run's result is sent to the person through the agent's messaging app. true when the result is for them and no step already delivers it (\"tell me\", \"send me\", \"let me know\", a summary or findings that no step posts or emails). false when a step already sends it (posting to a channel, emailing a report): it would arrive twice. false when the work itself is the outcome (answering emails, filing tasks), where a message every run is noise; questions and approvals reach them either way. Say why in one short sentence.\n7. Parameters: if the job is about something that changes from run to run (an order, a ticket, an alert, a customer, a date range), declare it as a parameter: a short snake_case name, a one-line description, and required when a run makes no sense without it. Write $name wherever its value goes, in startMessage and in the skill (\"Check the refund request for order $order_number.\"). Apps and people fill them in when they start a run. A scheduled job that finds its own work (\"check the inbox every 10 minutes\", \"last week's sales\") needs none. Don't make fixed settings, such as the repository or the channel, parameters: write them into the skill.\n8. Target: an existing agent only when it already has (or can install) the tools, none of the workflow's rules would change a decision it already has, and the same people would approve. Otherwise a new agent, with a short lowercase-hyphenated name and a system prompt saying who it is and what it's for. Give the reason either way.\n9. keys: the setting names its abilities need that list_connections or the agents don't show as set (from ability_details settings). Not for MCP servers: their keys are asked for on the plan.\n\nThen call save_plan once, with the whole plan. Don't ask the person questions: if something is unclear, plan the most reasonable version and note the assumption in gaps.";
|
|
119
129
|
export declare function workflowPlannerTools(withMcp?: boolean): ToolSchema[];
|
|
120
130
|
/** lowercase-hyphenated, 2–40 characters. */
|
|
121
131
|
export declare function slugify(value: string, fallback?: string): string;
|
|
122
132
|
/** A plan from the model's save_plan input, or undefined when it lacks a
|
|
123
133
|
* name, steps, a skill or a new-agent prompt. Fills in what can be derived. */
|
|
124
134
|
export declare function validateWorkflowPlan(raw: unknown): WorkflowPlan | undefined;
|
|
135
|
+
/** A plan's notify choice; off when the planner gave none. */
|
|
136
|
+
export declare function planNotify(raw: unknown): WorkflowPlan['notify'];
|
|
137
|
+
/** A plan's parameters, tidied: names made snake_case (or dropped), a
|
|
138
|
+
* $name the start message or skill uses but doesn't declare added as an
|
|
139
|
+
* optional one, and, for a scheduled workflow, required ones made
|
|
140
|
+
* optional (a schedule has no one to fill them in), with a note saying
|
|
141
|
+
* so. Also used on a plan edited before Create. */
|
|
142
|
+
export declare function planParameters(raw: unknown, context: {
|
|
143
|
+
startMessage: string;
|
|
144
|
+
skillBody: string;
|
|
145
|
+
scheduled: boolean;
|
|
146
|
+
}): {
|
|
147
|
+
parameters: Record<string, WorkflowParameter>;
|
|
148
|
+
notes: string[];
|
|
149
|
+
};
|
|
125
150
|
export interface WorkflowPlanResult {
|
|
126
151
|
plan?: WorkflowPlan;
|
|
127
152
|
/** The planner's last words when it didn't save a plan. */
|
|
@@ -17,8 +17,10 @@ How to plan:
|
|
|
17
17
|
3. Rules: every tool the workflow uses gets one. Reading and searching: allow. Anything a person should see first (sending, publishing, paying, creating something others see): ask. Never set deny unless asked.
|
|
18
18
|
4. Skill: the steps in order, as numbered instructions the agent follows, naming the tools and saying exactly when to stop and ask. Write it so the agent keeps going after each answer or decision.
|
|
19
19
|
5. Trigger: a schedule ("every Monday at 9" is every "7d", at "09:00", weekday 1) or on_request (started from Chat or a message). startMessage is what the agent receives when it starts.
|
|
20
|
-
6.
|
|
21
|
-
7.
|
|
20
|
+
6. Notify (scheduled workflows): whether each run's result is sent to the person through the agent's messaging app. true when the result is for them and no step already delivers it ("tell me", "send me", "let me know", a summary or findings that no step posts or emails). false when a step already sends it (posting to a channel, emailing a report): it would arrive twice. false when the work itself is the outcome (answering emails, filing tasks), where a message every run is noise; questions and approvals reach them either way. Say why in one short sentence.
|
|
21
|
+
7. Parameters: if the job is about something that changes from run to run (an order, a ticket, an alert, a customer, a date range), declare it as a parameter: a short snake_case name, a one-line description, and required when a run makes no sense without it. Write $name wherever its value goes, in startMessage and in the skill ("Check the refund request for order $order_number."). Apps and people fill them in when they start a run. A scheduled job that finds its own work ("check the inbox every 10 minutes", "last week's sales") needs none. Don't make fixed settings, such as the repository or the channel, parameters: write them into the skill.
|
|
22
|
+
8. Target: an existing agent only when it already has (or can install) the tools, none of the workflow's rules would change a decision it already has, and the same people would approve. Otherwise a new agent, with a short lowercase-hyphenated name and a system prompt saying who it is and what it's for. Give the reason either way.
|
|
23
|
+
9. keys: the setting names its abilities need that list_connections or the agents don't show as set (from ability_details settings). Not for MCP servers: their keys are asked for on the plan.
|
|
22
24
|
|
|
23
25
|
Then call save_plan once, with the whole plan. Don't ask the person questions: if something is unclear, plan the most reasonable version and note the assumption in gaps.`;
|
|
24
26
|
export function workflowPlannerTools(withMcp = false) {
|
|
@@ -72,6 +74,17 @@ export function workflowPlannerTools(withMcp = false) {
|
|
|
72
74
|
needsApprovals: { type: 'boolean' },
|
|
73
75
|
skill: { type: 'object', properties: { description: { type: 'string' }, body: { type: 'string' } }, required: ['description', 'body'] },
|
|
74
76
|
startMessage: { type: 'string' },
|
|
77
|
+
notify: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
description: 'Scheduled workflows: whether each run\'s result goes to the person through the agent\'s messaging app, and why in one short sentence.',
|
|
80
|
+
properties: { on: { type: 'boolean' }, reason: { type: 'string' } },
|
|
81
|
+
required: ['on', 'reason'],
|
|
82
|
+
},
|
|
83
|
+
parameters: {
|
|
84
|
+
type: 'object',
|
|
85
|
+
description: 'Values each run is given, by snake_case name; use $name in startMessage and the skill. Leave out when every run is the same.',
|
|
86
|
+
additionalProperties: { type: 'object', properties: { description: { type: 'string' }, required: { type: 'boolean' } } },
|
|
87
|
+
},
|
|
75
88
|
target: { type: 'object', properties: { kind: { type: 'string', enum: ['existing', 'new'] }, agent: { type: 'string' }, reason: { type: 'string' } }, required: ['kind', 'reason'] },
|
|
76
89
|
newAgent: { type: 'object', properties: { name: { type: 'string' }, systemPrompt: { type: 'string' } }, required: ['name', 'systemPrompt'] },
|
|
77
90
|
gaps: { type: 'array', items: { type: 'string' } },
|
|
@@ -156,6 +169,9 @@ export function validateWorkflowPlan(raw) {
|
|
|
156
169
|
])].filter((spec) => !steps.some((s) => s.connection === spec && s.ability !== spec));
|
|
157
170
|
const targetRaw = (r.target ?? {});
|
|
158
171
|
const id = slugify(str(r.id, 60) ?? name);
|
|
172
|
+
const startMessage = str(r.startMessage, 1000) ?? `Run the "${name}" workflow.`;
|
|
173
|
+
const gaps = Array.isArray(r.gaps) ? r.gaps.flatMap((g) => (str(g, 400) ? [str(g, 400)] : [])).slice(0, 10) : [];
|
|
174
|
+
const planned = planParameters(r.parameters, { startMessage, skillBody, scheduled: trigger.kind === 'schedule' });
|
|
159
175
|
return {
|
|
160
176
|
name,
|
|
161
177
|
id,
|
|
@@ -167,16 +183,52 @@ export function validateWorkflowPlan(raw) {
|
|
|
167
183
|
keys: Array.isArray(r.keys) ? [...new Set(r.keys.flatMap((k) => (str(k, 80) ? [str(k, 80)] : [])))] : [],
|
|
168
184
|
needsApprovals: r.needsApprovals === true || steps.some((s) => s.kind === 'ask' || s.kind === 'approval'),
|
|
169
185
|
skill: { description: str(skillRaw.description, 400) ?? summary, body: skillBody },
|
|
170
|
-
startMessage
|
|
186
|
+
startMessage,
|
|
187
|
+
parameters: planned.parameters,
|
|
188
|
+
notify: planNotify(r.notify),
|
|
171
189
|
target: {
|
|
172
190
|
kind: targetRaw.kind === 'existing' && str(targetRaw.agent, 80) ? 'existing' : 'new',
|
|
173
191
|
...(targetRaw.kind === 'existing' && str(targetRaw.agent, 80) ? { agent: str(targetRaw.agent, 80) } : {}),
|
|
174
192
|
reason: str(targetRaw.reason, 600) ?? '',
|
|
175
193
|
},
|
|
176
194
|
newAgent: { name: slugify(str(agentRaw.name, 60) ?? id, id), systemPrompt },
|
|
177
|
-
gaps:
|
|
195
|
+
gaps: [...gaps, ...planned.notes].slice(0, 12),
|
|
178
196
|
};
|
|
179
197
|
}
|
|
198
|
+
/** A plan's notify choice; off when the planner gave none. */
|
|
199
|
+
export function planNotify(raw) {
|
|
200
|
+
const n = raw && typeof raw === 'object' ? raw : {};
|
|
201
|
+
return { on: n.on === true, reason: typeof n.reason === 'string' ? n.reason.trim().slice(0, 200) : '' };
|
|
202
|
+
}
|
|
203
|
+
/** A plan's parameters, tidied: names made snake_case (or dropped), a
|
|
204
|
+
* $name the start message or skill uses but doesn't declare added as an
|
|
205
|
+
* optional one, and, for a scheduled workflow, required ones made
|
|
206
|
+
* optional (a schedule has no one to fill them in), with a note saying
|
|
207
|
+
* so. Also used on a plan edited before Create. */
|
|
208
|
+
export function planParameters(raw, context) {
|
|
209
|
+
const parameters = {};
|
|
210
|
+
const notes = [];
|
|
211
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
212
|
+
for (const [rawName, spec] of Object.entries(raw)) {
|
|
213
|
+
const name = rawName.trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^[^a-z_]+/, '').replace(/_+/g, '_').replace(/_$/, '').slice(0, 40);
|
|
214
|
+
if (!name || parameters[name])
|
|
215
|
+
continue;
|
|
216
|
+
const s = spec && typeof spec === 'object' ? spec : {};
|
|
217
|
+
parameters[name] = { description: typeof s.description === 'string' ? s.description.trim().slice(0, 300) : '', required: s.required === true };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
for (const match of `${context.startMessage}\n${context.skillBody}`.matchAll(/\$([a-z_][a-z0-9_]*)/g)) {
|
|
221
|
+
parameters[match[1]] ??= { description: '', required: false };
|
|
222
|
+
}
|
|
223
|
+
if (context.scheduled) {
|
|
224
|
+
const required = Object.entries(parameters).filter(([, p]) => p.required);
|
|
225
|
+
for (const [, p] of required)
|
|
226
|
+
p.required = false;
|
|
227
|
+
if (required.length)
|
|
228
|
+
notes.push(`A schedule can't fill in ${required.map(([n]) => n).join(', ')}, so ${required.length > 1 ? 'they are' : 'it is'} optional; give ${required.length > 1 ? 'them' : 'it'} a value on the schedule, or start runs by hand or from an app.`);
|
|
229
|
+
}
|
|
230
|
+
return { parameters, notes };
|
|
231
|
+
}
|
|
180
232
|
/** Runs the planner on a description until it saves a plan. */
|
|
181
233
|
export async function planWorkflowWithModel(modelCall, description, tools) {
|
|
182
234
|
const messages = [{ role: 'user', content: `Plan this workflow:\n\n${description}` }];
|
|
@@ -284,7 +336,7 @@ export function workflowTemplate(plan) {
|
|
|
284
336
|
skills: [{ id: plan.id, description: plan.skill.description, body: plan.skill.body }],
|
|
285
337
|
rules: plan.rules.map((r) => ({ name: slugify(`${plan.id}-${r.tool}`, `${plan.id}-rule`), tool: r.tool, decision: r.decision })),
|
|
286
338
|
...(plan.trigger.kind === 'schedule' && plan.trigger.every
|
|
287
|
-
? { schedule: { every: plan.trigger.every, ...(plan.trigger.at ? { at: plan.trigger.at } : {}), ...(plan.trigger.weekday !== undefined ? { weekday: plan.trigger.weekday } : {}), name: plan.name, message: plan.startMessage } }
|
|
339
|
+
? { schedule: { every: plan.trigger.every, ...(plan.trigger.at ? { at: plan.trigger.at } : {}), ...(plan.trigger.weekday !== undefined ? { weekday: plan.trigger.weekday } : {}), name: plan.name, message: plan.startMessage, workflowId: plan.id, ...(plan.notify?.on ? { notify: true } : {}) } }
|
|
288
340
|
: {}),
|
|
289
341
|
needsApprovals: plan.needsApprovals,
|
|
290
342
|
},
|
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
import type { ToolDefinition } from '../agent-config.js';
|
|
2
|
-
|
|
2
|
+
/** `agentDir`: the agent's folder, where its workflows are listed
|
|
3
|
+
* (workflows.yml). Without it the agent can only schedule messages. */
|
|
4
|
+
export declare function createScheduleTools(agentName: string, options?: {
|
|
5
|
+
agentDir?: string;
|
|
6
|
+
}): ToolDefinition[];
|