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 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** | Messages the agent receives on a timer — once at a set time, or every N minutes/hours/days. Run now, pause, resume, cancel, see each run's result, and optionally send results through the agent's notifier. |
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
- // Blocks only another provider can use (another model's reasoning:
33
- // 'thinking', 'openai_reasoning') are left out; as text they'd be
34
- // empty text blocks, which the API rejects.
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.filter((block) => ['text', 'tool_use', 'tool_result'].includes(block.type)).map(toContentBlockParam),
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
- const maxTokens = options.maxTokens ?? 4096;
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,
@@ -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 body = skillGarden.invoke(block.input.skill, block.input?.args);
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;
@@ -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: string;
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. */
@@ -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
- if (!input.message?.trim())
145
- throw new ScheduleError('message is required');
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: input.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
- invoke(name: string, args?: string): string;
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
- invoke(name, args) {
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
- return substituteArguments(skill.body, args);
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
- result = result.replace(/\$(\d+)/g, (_match, indexStr) => {
9
- const index = Number(indexStr);
10
- return positional[index - 1] ?? '';
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.\n7. 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.";
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. 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.
21
- 7. 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.
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: str(r.startMessage, 1000) ?? `Run the "${name}" workflow.`,
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: Array.isArray(r.gaps) ? r.gaps.flatMap((g) => (str(g, 400) ? [str(g, 400)] : [])).slice(0, 10) : [],
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
- export declare function createScheduleTools(agentName: string): ToolDefinition[];
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[];