loopengine 0.1.95 → 0.1.97
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 +21 -2
- package/dist/bin/ability-manager.d.ts +4 -0
- package/dist/bin/ability-manager.js +34 -2
- package/dist/bin/cli.d.ts +1 -1
- package/dist/bin/cli.js +3 -0
- package/dist/core/ability-details.d.ts +31 -0
- package/dist/core/ability-details.js +140 -0
- package/dist/core/ability-sources.d.ts +59 -0
- package/dist/core/ability-sources.js +312 -0
- package/dist/core/agent-config.d.ts +21 -1
- package/dist/core/discover-agents.js +11 -1
- package/dist/core/http-notifier.d.ts +7 -1
- package/dist/core/http-notifier.js +68 -9
- package/dist/core/http-notify-triggers/email.d.ts +2 -2
- package/dist/core/http-notify-triggers/email.js +2 -4
- package/dist/core/http-notify-triggers/lark.d.ts +2 -2
- package/dist/core/http-notify-triggers/lark.js +2 -4
- package/dist/core/http-notify-triggers/slack.d.ts +2 -2
- package/dist/core/http-notify-triggers/slack.js +2 -4
- package/dist/core/http-notify-triggers/telegram.d.ts +28 -0
- package/dist/core/http-notify-triggers/telegram.js +71 -0
- package/dist/core/http-notify-triggers/webhook.d.ts +2 -2
- package/dist/core/http-notify-triggers/webhook.js +2 -4
- package/dist/core/model-calls/meta-model-call.d.ts +15 -0
- package/dist/core/model-calls/meta-model-call.js +48 -0
- package/dist/core/model-calls/openai-model-call.d.ts +5 -0
- package/dist/core/model-calls/openai-model-call.js +47 -1
- package/dist/core/model-calls/xai-model-call.d.ts +15 -0
- package/dist/core/model-calls/xai-model-call.js +45 -0
- package/dist/core/notifier-file.d.ts +40 -6
- package/dist/core/notifier-file.js +119 -25
- package/dist/core/system-agents/workflow-planner.d.ts +100 -0
- package/dist/core/system-agents/workflow-planner.js +278 -0
- package/dist/core/workflows.d.ts +26 -0
- package/dist/core/workflows.js +39 -0
- package/dist/core/workspace-abilities.d.ts +31 -0
- package/dist/core/workspace-abilities.js +110 -0
- package/dist/index.d.ts +18 -4
- package/dist/index.js +17 -3
- package/dist/web/ability-details-admin.d.ts +17 -0
- package/dist/web/ability-details-admin.js +45 -0
- package/dist/web/ability-sources-admin.d.ts +33 -0
- package/dist/web/ability-sources-admin.js +65 -0
- package/dist/web/agent-file-admin.d.ts +1 -1
- package/dist/web/agent-file-admin.js +3 -0
- package/dist/web/agents-config-page.js +600 -220
- package/dist/web/agents-list-page.js +49 -20
- package/dist/web/console-shell.js +244 -3
- package/dist/web/dev-ui-styles.js +12 -0
- package/dist/web/global-config-page.js +348 -95
- package/dist/web/global-config.d.ts +44 -1
- package/dist/web/global-config.js +162 -2
- package/dist/web/interview-admin.js +2 -2
- package/dist/web/messaging-icons.d.ts +2 -0
- package/dist/web/messaging-icons.js +7 -0
- package/dist/web/notifier-admin.d.ts +33 -15
- package/dist/web/notifier-admin.js +109 -65
- package/dist/web/provider-icons.d.ts +2 -0
- package/dist/web/provider-icons.js +10 -0
- package/dist/web/templates-admin.d.ts +3 -0
- package/dist/web/templates-admin.js +5 -1
- package/dist/web/workflow-admin.d.ts +124 -0
- package/dist/web/workflow-admin.js +308 -0
- package/dist/web/workflow-page.d.ts +1 -0
- package/dist/web/workflow-page.js +226 -0
- package/dist/web/workspace-abilities-admin.d.ts +24 -0
- package/dist/web/workspace-abilities-admin.js +118 -0
- package/package.json +13 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ToolSchema } from '#core/agent-config.js';
|
|
2
|
+
import type { ModelCall } from '#core/run-agent.js';
|
|
3
|
+
import type { StarterTemplate } from './templates.js';
|
|
4
|
+
export type Decision = 'allow' | 'ask' | 'deny';
|
|
5
|
+
export interface WorkflowStep {
|
|
6
|
+
title: string;
|
|
7
|
+
/** tool: a tool runs. ask: the agent asks a person (system_ask_user).
|
|
8
|
+
* approval: a tool that waits for a person's OK (its rule is "ask").
|
|
9
|
+
* other: the agent's own work, like writing or deciding. */
|
|
10
|
+
kind: 'tool' | 'ask' | 'approval' | 'other';
|
|
11
|
+
tool?: string;
|
|
12
|
+
/** The ability that provides `tool`, as it would be installed. */
|
|
13
|
+
ability?: string;
|
|
14
|
+
detail?: string;
|
|
15
|
+
/** What's missing for this step, when nothing found provides it. */
|
|
16
|
+
missing?: string;
|
|
17
|
+
/** For a tool from a connected service (an agent's Composio source, not
|
|
18
|
+
* an ability): the source's name. It's copied to the agent that runs the
|
|
19
|
+
* workflow, not installed. */
|
|
20
|
+
connection?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface WorkflowPlan {
|
|
23
|
+
name: string;
|
|
24
|
+
/** lowercase-hyphenated: the workflow's id and its skill's folder. */
|
|
25
|
+
id: string;
|
|
26
|
+
summary: string;
|
|
27
|
+
trigger: {
|
|
28
|
+
kind: 'schedule' | 'on_request';
|
|
29
|
+
description: string;
|
|
30
|
+
/** schedule only: "1h", "24h", "7d". */
|
|
31
|
+
every?: string;
|
|
32
|
+
/** schedule only: time of day ("09:00") and weekday (1 = Monday). */
|
|
33
|
+
at?: string;
|
|
34
|
+
weekday?: number;
|
|
35
|
+
};
|
|
36
|
+
steps: WorkflowStep[];
|
|
37
|
+
/** Abilities to install, as specs: an npm name, or a workspace ability's file: spec. */
|
|
38
|
+
abilities: string[];
|
|
39
|
+
/** Permission each tool the workflow uses should have. */
|
|
40
|
+
rules: {
|
|
41
|
+
tool: string;
|
|
42
|
+
decision: Decision;
|
|
43
|
+
}[];
|
|
44
|
+
/** Keys its abilities need (setting names). */
|
|
45
|
+
keys: string[];
|
|
46
|
+
/** Whether a person must be reachable (a question or an approval). */
|
|
47
|
+
needsApprovals: boolean;
|
|
48
|
+
/** The steps in order, as the agent's instructions. */
|
|
49
|
+
skill: {
|
|
50
|
+
description: string;
|
|
51
|
+
body: string;
|
|
52
|
+
};
|
|
53
|
+
/** What the agent receives when the workflow starts. */
|
|
54
|
+
startMessage: string;
|
|
55
|
+
target: {
|
|
56
|
+
kind: 'existing' | 'new';
|
|
57
|
+
agent?: string;
|
|
58
|
+
reason: string;
|
|
59
|
+
};
|
|
60
|
+
/** For a new agent. */
|
|
61
|
+
newAgent: {
|
|
62
|
+
name: string;
|
|
63
|
+
systemPrompt: string;
|
|
64
|
+
};
|
|
65
|
+
/** What the workspace can't do yet, in plain words. */
|
|
66
|
+
gaps: string[];
|
|
67
|
+
}
|
|
68
|
+
export interface WorkflowPlannerTools {
|
|
69
|
+
/** Every agent: its tools, installed abilities, rules and messaging. */
|
|
70
|
+
listAgents(): Promise<unknown>;
|
|
71
|
+
searchPublicAbilities(query: string): Promise<{
|
|
72
|
+
name: string;
|
|
73
|
+
description?: string;
|
|
74
|
+
}[]>;
|
|
75
|
+
/** The workspace's private abilities. */
|
|
76
|
+
listWorkspaceAbilities(): Promise<{
|
|
77
|
+
name: string;
|
|
78
|
+
description?: string;
|
|
79
|
+
spec: string;
|
|
80
|
+
}[]>;
|
|
81
|
+
/** An ability's tools (with the decision each ships with) and settings. */
|
|
82
|
+
abilityDetails(spec: string): Promise<unknown>;
|
|
83
|
+
listConnections(): Promise<Record<string, unknown>>;
|
|
84
|
+
}
|
|
85
|
+
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 (even when an agent already has a connected tool for it), 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 does it: a tool in some agent's connectedTools (a connected service, usually one that needs a person to sign in, like Gmail or HubSpot). Give its exact tool name, set \"connection\" to that source's name and leave \"ability\" empty (it's copied to the agent that runs the workflow). Never put a connection's name in abilities.\n An ability beats a connected tool even when another agent already has the connected one: it's faster, needs only a key, and travels with the agent. If you chose a connected tool, say why in gaps.\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).\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.";
|
|
86
|
+
export declare function workflowPlannerTools(): ToolSchema[];
|
|
87
|
+
/** lowercase-hyphenated, 2–40 characters. */
|
|
88
|
+
export declare function slugify(value: string, fallback?: string): string;
|
|
89
|
+
/** A plan from the model's save_plan input, or undefined when it lacks a
|
|
90
|
+
* name, steps, a skill or a new-agent prompt. Fills in what can be derived. */
|
|
91
|
+
export declare function validateWorkflowPlan(raw: unknown): WorkflowPlan | undefined;
|
|
92
|
+
export interface WorkflowPlanResult {
|
|
93
|
+
plan?: WorkflowPlan;
|
|
94
|
+
/** The planner's last words when it didn't save a plan. */
|
|
95
|
+
reply?: string;
|
|
96
|
+
}
|
|
97
|
+
/** Runs the planner on a description until it saves a plan. */
|
|
98
|
+
export declare function planWorkflowWithModel(modelCall: ModelCall, description: string, tools: WorkflowPlannerTools): Promise<WorkflowPlanResult>;
|
|
99
|
+
/** A plan as a starter template, for creating it as a new agent. */
|
|
100
|
+
export declare function workflowTemplate(plan: WorkflowPlan): StarterTemplate;
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
const MAX_TOOL_ROUNDS = 12;
|
|
2
|
+
export const WORKFLOW_PLANNER_PROMPT = `You turn a workflow a person describes into a plan for a LoopEngine agent.
|
|
3
|
+
|
|
4
|
+
LoopEngine 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.
|
|
5
|
+
|
|
6
|
+
How to plan:
|
|
7
|
+
1. 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 (even when an agent already has a connected tool for it), ability_details for any ability you might use (its exact tool names and the settings it needs), and list_connections.
|
|
8
|
+
2. Split the workflow into steps, in order. For each step:
|
|
9
|
+
- something to fetch or do: kind "tool", with the exact tool name and where it comes from. Pick the source in this order:
|
|
10
|
+
a. A tool the target agent already has (from list_agents): use it, nothing to install.
|
|
11
|
+
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.
|
|
12
|
+
c. Only when no ability does it: a tool in some agent's connectedTools (a connected service, usually one that needs a person to sign in, like Gmail or HubSpot). Give its exact tool name, set "connection" to that source's name and leave "ability" empty (it's copied to the agent that runs the workflow). Never put a connection's name in abilities.
|
|
13
|
+
An ability beats a connected tool even when another agent already has the connected one: it's faster, needs only a key, and travels with the agent. If you chose a connected tool, say why in gaps.
|
|
14
|
+
- 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.
|
|
15
|
+
- 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".
|
|
16
|
+
- the agent's own work (writing, summarising, choosing): kind "other".
|
|
17
|
+
- nothing found can do it: set "missing" to what's needed, and add it to gaps.
|
|
18
|
+
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.
|
|
19
|
+
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.
|
|
20
|
+
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.
|
|
21
|
+
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.
|
|
22
|
+
7. keys: the setting names its abilities need that list_connections or the agents don't show as set (from ability_details settings).
|
|
23
|
+
|
|
24
|
+
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.`;
|
|
25
|
+
export function workflowPlannerTools() {
|
|
26
|
+
const step = {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
title: { type: 'string' },
|
|
30
|
+
kind: { type: 'string', enum: ['tool', 'ask', 'approval', 'other'] },
|
|
31
|
+
tool: { type: 'string' },
|
|
32
|
+
ability: { type: 'string' },
|
|
33
|
+
detail: { type: 'string' },
|
|
34
|
+
missing: { type: 'string' },
|
|
35
|
+
connection: { type: 'string' },
|
|
36
|
+
},
|
|
37
|
+
required: ['title', 'kind'],
|
|
38
|
+
};
|
|
39
|
+
return [
|
|
40
|
+
{ name: 'list_agents', description: "Every agent in the workspace: its tools, installed abilities, permission rules and messaging app.", input_schema: { type: 'object', properties: {} } },
|
|
41
|
+
{ name: 'list_workspace_abilities', description: "The workspace's own private abilities, ready to add to an agent.", input_schema: { type: 'object', properties: {} } },
|
|
42
|
+
{ name: 'search_public_abilities', description: 'Search public LoopEngine abilities on npm, e.g. "web search", "wordpress", "slack".', input_schema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } },
|
|
43
|
+
{ name: 'ability_details', description: "An ability's exact tool names, what each does, the permission each ships with, and the settings it needs. spec: its npm name or workspace spec.", input_schema: { type: 'object', properties: { spec: { type: 'string' } }, required: ['spec'] } },
|
|
44
|
+
{ name: 'list_connections', description: 'What is set up: model providers with keys, messaging apps per agent.', input_schema: { type: 'object', properties: {} } },
|
|
45
|
+
{
|
|
46
|
+
name: 'save_plan',
|
|
47
|
+
description: 'Save the workflow plan. Call once, at the end.',
|
|
48
|
+
input_schema: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {
|
|
51
|
+
name: { type: 'string' },
|
|
52
|
+
id: { type: 'string', description: 'lowercase-hyphenated' },
|
|
53
|
+
summary: { type: 'string' },
|
|
54
|
+
trigger: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
properties: {
|
|
57
|
+
kind: { type: 'string', enum: ['schedule', 'on_request'] },
|
|
58
|
+
description: { type: 'string' },
|
|
59
|
+
every: { type: 'string' },
|
|
60
|
+
at: { type: 'string' },
|
|
61
|
+
weekday: { type: 'number' },
|
|
62
|
+
},
|
|
63
|
+
required: ['kind', 'description'],
|
|
64
|
+
},
|
|
65
|
+
steps: { type: 'array', items: step },
|
|
66
|
+
abilities: { type: 'array', items: { type: 'string' } },
|
|
67
|
+
rules: { type: 'array', items: { type: 'object', properties: { tool: { type: 'string' }, decision: { type: 'string', enum: ['allow', 'ask', 'deny'] } }, required: ['tool', 'decision'] } },
|
|
68
|
+
keys: { type: 'array', items: { type: 'string' } },
|
|
69
|
+
needsApprovals: { type: 'boolean' },
|
|
70
|
+
skill: { type: 'object', properties: { description: { type: 'string' }, body: { type: 'string' } }, required: ['description', 'body'] },
|
|
71
|
+
startMessage: { type: 'string' },
|
|
72
|
+
target: { type: 'object', properties: { kind: { type: 'string', enum: ['existing', 'new'] }, agent: { type: 'string' }, reason: { type: 'string' } }, required: ['kind', 'reason'] },
|
|
73
|
+
newAgent: { type: 'object', properties: { name: { type: 'string' }, systemPrompt: { type: 'string' } }, required: ['name', 'systemPrompt'] },
|
|
74
|
+
gaps: { type: 'array', items: { type: 'string' } },
|
|
75
|
+
},
|
|
76
|
+
required: ['name', 'summary', 'trigger', 'steps', 'rules', 'skill', 'startMessage', 'target', 'newAgent'],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
const str = (value, max = 2000) => (typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : undefined);
|
|
82
|
+
/** lowercase-hyphenated, 2–40 characters. */
|
|
83
|
+
export function slugify(value, fallback = 'workflow') {
|
|
84
|
+
const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '');
|
|
85
|
+
return slug.length >= 2 ? slug : fallback;
|
|
86
|
+
}
|
|
87
|
+
const isDecision = (value) => value === 'allow' || value === 'ask' || value === 'deny';
|
|
88
|
+
/** A plan from the model's save_plan input, or undefined when it lacks a
|
|
89
|
+
* name, steps, a skill or a new-agent prompt. Fills in what can be derived. */
|
|
90
|
+
export function validateWorkflowPlan(raw) {
|
|
91
|
+
if (!raw || typeof raw !== 'object')
|
|
92
|
+
return undefined;
|
|
93
|
+
const r = raw;
|
|
94
|
+
const name = str(r.name, 80);
|
|
95
|
+
const summary = str(r.summary, 600);
|
|
96
|
+
const skillRaw = (r.skill ?? {});
|
|
97
|
+
const skillBody = str(skillRaw.body, 12000);
|
|
98
|
+
const agentRaw = (r.newAgent ?? {});
|
|
99
|
+
const systemPrompt = str(agentRaw.systemPrompt, 8000);
|
|
100
|
+
if (!name || !summary || !skillBody || !systemPrompt)
|
|
101
|
+
return undefined;
|
|
102
|
+
const steps = (Array.isArray(r.steps) ? r.steps : []).flatMap((s) => {
|
|
103
|
+
const step = (s ?? {});
|
|
104
|
+
const title = str(step.title, 200);
|
|
105
|
+
const kind = ['tool', 'ask', 'approval', 'other'].includes(step.kind) ? step.kind : 'other';
|
|
106
|
+
if (!title)
|
|
107
|
+
return [];
|
|
108
|
+
return [{
|
|
109
|
+
title,
|
|
110
|
+
kind,
|
|
111
|
+
...(str(step.tool, 120) ? { tool: str(step.tool, 120) } : {}),
|
|
112
|
+
...(str(step.ability, 300) ? { ability: str(step.ability, 300) } : {}),
|
|
113
|
+
...(str(step.detail, 600) ? { detail: str(step.detail, 600) } : {}),
|
|
114
|
+
...(str(step.missing, 300) ? { missing: str(step.missing, 300) } : {}),
|
|
115
|
+
...(str(step.connection, 120) ? { connection: str(step.connection, 120) } : {}),
|
|
116
|
+
}];
|
|
117
|
+
});
|
|
118
|
+
if (!steps.length)
|
|
119
|
+
return undefined;
|
|
120
|
+
const triggerRaw = (r.trigger ?? {});
|
|
121
|
+
const scheduled = triggerRaw.kind === 'schedule' && typeof triggerRaw.every === 'string' && /^\d+[mhd]$/.test(triggerRaw.every);
|
|
122
|
+
const trigger = scheduled
|
|
123
|
+
? {
|
|
124
|
+
kind: 'schedule',
|
|
125
|
+
description: str(triggerRaw.description, 200) ?? `every ${triggerRaw.every}`,
|
|
126
|
+
every: triggerRaw.every,
|
|
127
|
+
...(typeof triggerRaw.at === 'string' && /^\d{1,2}:\d{2}$/.test(triggerRaw.at) ? { at: triggerRaw.at } : {}),
|
|
128
|
+
...(typeof triggerRaw.weekday === 'number' && triggerRaw.weekday >= 0 && triggerRaw.weekday <= 7 ? { weekday: triggerRaw.weekday } : {}),
|
|
129
|
+
}
|
|
130
|
+
: { kind: 'on_request', description: str(triggerRaw.description, 200) ?? 'when asked' };
|
|
131
|
+
const rules = (Array.isArray(r.rules) ? r.rules : []).flatMap((x) => {
|
|
132
|
+
const rule = (x ?? {});
|
|
133
|
+
const tool = str(rule.tool, 120);
|
|
134
|
+
return tool && isDecision(rule.decision) && tool !== 'system_ask_user' ? [{ tool, decision: rule.decision }] : [];
|
|
135
|
+
});
|
|
136
|
+
// An approval step's tool always asks first.
|
|
137
|
+
for (const step of steps) {
|
|
138
|
+
if (step.kind !== 'approval' || !step.tool)
|
|
139
|
+
continue;
|
|
140
|
+
const rule = rules.find((x) => x.tool === step.tool);
|
|
141
|
+
if (rule)
|
|
142
|
+
rule.decision = 'ask';
|
|
143
|
+
else
|
|
144
|
+
rules.push({ tool: step.tool, decision: 'ask' });
|
|
145
|
+
}
|
|
146
|
+
const abilities = [...new Set([
|
|
147
|
+
...(Array.isArray(r.abilities) ? r.abilities.flatMap((a) => (str(a, 300) ? [str(a, 300)] : [])) : []),
|
|
148
|
+
...steps.flatMap((s) => (s.ability ? [s.ability] : [])),
|
|
149
|
+
])].filter((spec) => !steps.some((s) => s.connection === spec && s.ability !== spec));
|
|
150
|
+
const targetRaw = (r.target ?? {});
|
|
151
|
+
const id = slugify(str(r.id, 60) ?? name);
|
|
152
|
+
return {
|
|
153
|
+
name,
|
|
154
|
+
id,
|
|
155
|
+
summary,
|
|
156
|
+
trigger,
|
|
157
|
+
steps,
|
|
158
|
+
abilities,
|
|
159
|
+
rules,
|
|
160
|
+
keys: Array.isArray(r.keys) ? [...new Set(r.keys.flatMap((k) => (str(k, 80) ? [str(k, 80)] : [])))] : [],
|
|
161
|
+
needsApprovals: r.needsApprovals === true || steps.some((s) => s.kind === 'ask' || s.kind === 'approval'),
|
|
162
|
+
skill: { description: str(skillRaw.description, 400) ?? summary, body: skillBody },
|
|
163
|
+
startMessage: str(r.startMessage, 1000) ?? `Run the "${name}" workflow.`,
|
|
164
|
+
target: {
|
|
165
|
+
kind: targetRaw.kind === 'existing' && str(targetRaw.agent, 80) ? 'existing' : 'new',
|
|
166
|
+
...(targetRaw.kind === 'existing' && str(targetRaw.agent, 80) ? { agent: str(targetRaw.agent, 80) } : {}),
|
|
167
|
+
reason: str(targetRaw.reason, 600) ?? '',
|
|
168
|
+
},
|
|
169
|
+
newAgent: { name: slugify(str(agentRaw.name, 60) ?? id, id), systemPrompt },
|
|
170
|
+
gaps: Array.isArray(r.gaps) ? r.gaps.flatMap((g) => (str(g, 400) ? [str(g, 400)] : [])).slice(0, 10) : [],
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/** Runs the planner on a description until it saves a plan. */
|
|
174
|
+
export async function planWorkflowWithModel(modelCall, description, tools) {
|
|
175
|
+
const messages = [{ role: 'user', content: `Plan this workflow:\n\n${description}` }];
|
|
176
|
+
let nudges = 0;
|
|
177
|
+
let retries = 0;
|
|
178
|
+
for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {
|
|
179
|
+
let response;
|
|
180
|
+
try {
|
|
181
|
+
response = await modelCall(messages, WORKFLOW_PLANNER_PROMPT, workflowPlannerTools());
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
// A reply cut off part-way (its tool arguments end mid-JSON) usually
|
|
185
|
+
// means it ran out of room: ask again, for a shorter plan.
|
|
186
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
187
|
+
if (retries < 3 && /JSON|Unterminated|Unexpected end/i.test(message)) {
|
|
188
|
+
retries++;
|
|
189
|
+
messages.push({ role: 'user', content: 'Your last reply was cut off before it finished. Call save_plan again, shorter: one line per step, and a skill body under 1500 characters.' });
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
const calls = response.content.filter((b) => b.type === 'tool_use');
|
|
195
|
+
const text = response.content.filter((b) => b.type === 'text' && b.text).map((b) => b.text).join('\n').trim();
|
|
196
|
+
// An empty reply (no words, no tool calls) is left out: providers
|
|
197
|
+
// refuse a conversation holding an empty assistant message.
|
|
198
|
+
if (calls.length || text)
|
|
199
|
+
messages.push({ role: 'assistant', content: response.content });
|
|
200
|
+
if (!calls.length) {
|
|
201
|
+
// Some models answer in words instead of saving: ask for the plan,
|
|
202
|
+
// with what it has, a couple of times before giving up.
|
|
203
|
+
if (nudges < 2) {
|
|
204
|
+
nudges++;
|
|
205
|
+
messages.push({ role: 'user', content: 'Save the plan now: call save_plan with the whole plan, using what you have found. Note anything you assumed in gaps.' });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
return { reply: text || 'The planner stopped without a plan.' };
|
|
209
|
+
}
|
|
210
|
+
const results = [];
|
|
211
|
+
let plan;
|
|
212
|
+
for (const call of calls) {
|
|
213
|
+
const input = (call.input ?? {});
|
|
214
|
+
let output;
|
|
215
|
+
try {
|
|
216
|
+
switch (call.name) {
|
|
217
|
+
case 'list_agents':
|
|
218
|
+
output = await tools.listAgents();
|
|
219
|
+
break;
|
|
220
|
+
case 'list_workspace_abilities':
|
|
221
|
+
output = await tools.listWorkspaceAbilities();
|
|
222
|
+
break;
|
|
223
|
+
case 'search_public_abilities':
|
|
224
|
+
output = await tools.searchPublicAbilities(String(input.query ?? ''));
|
|
225
|
+
break;
|
|
226
|
+
case 'ability_details':
|
|
227
|
+
output = await tools.abilityDetails(String(input.spec ?? ''));
|
|
228
|
+
break;
|
|
229
|
+
case 'list_connections':
|
|
230
|
+
output = await tools.listConnections();
|
|
231
|
+
break;
|
|
232
|
+
case 'save_plan':
|
|
233
|
+
plan = validateWorkflowPlan(input);
|
|
234
|
+
output = plan ? 'saved' : 'that plan needs a name, summary, at least one step, a skill body and newAgent.systemPrompt; fix and call again';
|
|
235
|
+
break;
|
|
236
|
+
default:
|
|
237
|
+
output = `unknown tool ${call.name}`;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
output = `error: ${err instanceof Error ? err.message : String(err)}`;
|
|
242
|
+
}
|
|
243
|
+
results.push({ type: 'tool_result', tool_use_id: call.id, content: typeof output === 'string' ? output : JSON.stringify(output) });
|
|
244
|
+
}
|
|
245
|
+
if (plan)
|
|
246
|
+
return { plan };
|
|
247
|
+
messages.push({ role: 'user', content: results });
|
|
248
|
+
}
|
|
249
|
+
return { reply: 'The planner ran out of steps before saving a plan. Try describing the workflow more briefly.' };
|
|
250
|
+
}
|
|
251
|
+
/** A plan as a starter template, for creating it as a new agent. */
|
|
252
|
+
export function workflowTemplate(plan) {
|
|
253
|
+
return {
|
|
254
|
+
id: `workflow-${plan.id}`,
|
|
255
|
+
category: 'Workflow',
|
|
256
|
+
candidate: {
|
|
257
|
+
title: plan.name,
|
|
258
|
+
summary: plan.summary,
|
|
259
|
+
trigger: { kind: plan.trigger.kind === 'schedule' ? 'schedule' : 'on_request', description: plan.trigger.description, ...(plan.trigger.every ? { every: plan.trigger.every } : {}) },
|
|
260
|
+
hoursPerWeek: 1,
|
|
261
|
+
fit: 6,
|
|
262
|
+
risk: plan.rules.some((r) => r.decision === 'allow') && plan.needsApprovals ? 'medium' : 'low',
|
|
263
|
+
asksFirst: plan.steps.filter((s) => s.kind === 'approval' || s.kind === 'ask').map((s) => s.title),
|
|
264
|
+
needs: plan.abilities.map((spec) => ({ kind: 'ability', name: spec, description: spec })),
|
|
265
|
+
agent: { name: plan.newAgent.name, systemPrompt: plan.newAgent.systemPrompt, scheduleMessage: plan.startMessage, exampleMessage: plan.startMessage },
|
|
266
|
+
},
|
|
267
|
+
setup: {
|
|
268
|
+
abilities: plan.abilities,
|
|
269
|
+
keys: plan.keys,
|
|
270
|
+
skills: [{ id: plan.id, description: plan.skill.description, body: plan.skill.body }],
|
|
271
|
+
rules: plan.rules.map((r) => ({ name: slugify(`${plan.id}-${r.tool}`, `${plan.id}-rule`), tool: r.tool, decision: r.decision })),
|
|
272
|
+
...(plan.trigger.kind === 'schedule' && plan.trigger.every
|
|
273
|
+
? { 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 } }
|
|
274
|
+
: {}),
|
|
275
|
+
needsApprovals: plan.needsApprovals,
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { WorkflowStep } from './system-agents/workflow-planner.js';
|
|
2
|
+
export interface WorkflowRecord {
|
|
3
|
+
/** lowercase-hyphenated; also its skill's folder name. */
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
summary: string;
|
|
7
|
+
trigger: {
|
|
8
|
+
kind: 'schedule' | 'on_request';
|
|
9
|
+
description: string;
|
|
10
|
+
};
|
|
11
|
+
steps: WorkflowStep[];
|
|
12
|
+
/** The message each run starts with. */
|
|
13
|
+
startMessage: string;
|
|
14
|
+
/** Its schedule's name, when it has one (the Schedules tab's task_name). */
|
|
15
|
+
scheduleName?: string;
|
|
16
|
+
/** What the person wrote, kept for reference. */
|
|
17
|
+
description?: string;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function workflowsPath(agentDir: string): string;
|
|
21
|
+
/** The agent's workflows; none when it has no workflows.yml or it can't be read. */
|
|
22
|
+
export declare function readWorkflows(agentDir: string): WorkflowRecord[];
|
|
23
|
+
/** Adds a workflow, replacing one with the same id. */
|
|
24
|
+
export declare function saveWorkflow(agentDir: string, workflow: WorkflowRecord): void;
|
|
25
|
+
/** Forgets a workflow. Its skill, schedule and rules are left as they are. */
|
|
26
|
+
export declare function removeWorkflow(agentDir: string, id: string): void;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// An agent's workflows: jobs it does, each a skill (its steps), a trigger
|
|
2
|
+
// (a schedule, or a message) and checkpoints (questions, and tools set to
|
|
3
|
+
// "ask"). Recorded in agents/<name>/workflows.yml when one is added from
|
|
4
|
+
// "Describe a workflow", so the agent's Overview can list them; the skill,
|
|
5
|
+
// the schedule and the rules themselves live where they always do.
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
9
|
+
export function workflowsPath(agentDir) {
|
|
10
|
+
return join(agentDir, 'workflows.yml');
|
|
11
|
+
}
|
|
12
|
+
/** The agent's workflows; none when it has no workflows.yml or it can't be read. */
|
|
13
|
+
export function readWorkflows(agentDir) {
|
|
14
|
+
const path = workflowsPath(agentDir);
|
|
15
|
+
if (!existsSync(path))
|
|
16
|
+
return [];
|
|
17
|
+
try {
|
|
18
|
+
const parsed = parseYaml(readFileSync(path, 'utf8'));
|
|
19
|
+
return Array.isArray(parsed?.workflows) ? parsed.workflows.filter((w) => w && typeof w.id === 'string' && typeof w.name === 'string') : [];
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Adds a workflow, replacing one with the same id. */
|
|
26
|
+
export function saveWorkflow(agentDir, workflow) {
|
|
27
|
+
const workflows = readWorkflows(agentDir).filter((w) => w.id !== workflow.id);
|
|
28
|
+
workflows.push(workflow);
|
|
29
|
+
const header = "# This agent's workflows, added from the console's \"Describe a workflow\". Each one's\n# steps are its skill (skills/<id>/SKILL.md); its schedule and rules are in the Schedules and Permissions tabs.\n";
|
|
30
|
+
writeFileSync(workflowsPath(agentDir), header + stringifyYaml({ workflows }));
|
|
31
|
+
}
|
|
32
|
+
/** Forgets a workflow. Its skill, schedule and rules are left as they are. */
|
|
33
|
+
export function removeWorkflow(agentDir, id) {
|
|
34
|
+
const workflows = readWorkflows(agentDir);
|
|
35
|
+
if (!workflows.some((w) => w.id === id))
|
|
36
|
+
return;
|
|
37
|
+
const rest = workflows.filter((w) => w.id !== id);
|
|
38
|
+
writeFileSync(workflowsPath(agentDir), stringifyYaml({ workflows: rest }));
|
|
39
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export declare const WORKSPACE_ABILITIES_DIR_ENV = "LOOPENGINE_ABILITIES_DIR";
|
|
2
|
+
export interface WorkspaceAbilityRecord {
|
|
3
|
+
/** The spec it was installed from: github:owner/repo, @scope/name, file:/path. */
|
|
4
|
+
source: string;
|
|
5
|
+
version: string;
|
|
6
|
+
installedAt: string;
|
|
7
|
+
}
|
|
8
|
+
export interface WorkspaceAbility extends WorkspaceAbilityRecord {
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
/** What an agent installs it with: file:<its folder>. */
|
|
12
|
+
spec: string;
|
|
13
|
+
}
|
|
14
|
+
export declare class WorkspaceAbilityError extends Error {
|
|
15
|
+
}
|
|
16
|
+
export declare function workspaceAbilitiesDir(): string;
|
|
17
|
+
/** Every copy in the workspace, by name. A record whose folder is gone is
|
|
18
|
+
* left out. */
|
|
19
|
+
export declare function listWorkspaceAbilities(): WorkspaceAbility[];
|
|
20
|
+
/** Stores a fetched ability (`fetchedDir`, a package folder holding
|
|
21
|
+
* loopengine.ability.json) as the workspace's copy, replacing an earlier
|
|
22
|
+
* one of the same name. `source` is the spec it came from. */
|
|
23
|
+
export declare function addWorkspaceAbility(fetchedDir: string, source: string): WorkspaceAbility;
|
|
24
|
+
/** Removes a copy from the workspace. Agents that installed it keep
|
|
25
|
+
* their own copies of its files. */
|
|
26
|
+
export declare function removeWorkspaceAbility(name: string): void;
|
|
27
|
+
/** The workspace copy an agent installed from, when `path` (from a
|
|
28
|
+
* file: spec) is one. */
|
|
29
|
+
export declare function workspaceAbilityAt(path: string): (WorkspaceAbilityRecord & {
|
|
30
|
+
name: string;
|
|
31
|
+
}) | undefined;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The workspace's own copies of private abilities: installed once from a
|
|
2
|
+
// private source (core/ability-sources.ts) on the console's Abilities
|
|
3
|
+
// page, then added to any agent from its Abilities tab. Each copy is a
|
|
4
|
+
// folder under abilities/ in the project root (or the folder
|
|
5
|
+
// LOOPENGINE_ABILITIES_DIR names), and abilities/workspace.json says
|
|
6
|
+
// where each came from:
|
|
7
|
+
//
|
|
8
|
+
// { "lp-github": { "source": "github:loopengine-co/lp-github",
|
|
9
|
+
// "version": "1.0.0", "installedAt": "2026-10-31T..." } }
|
|
10
|
+
//
|
|
11
|
+
// An agent installs a copy as file:<its folder>, so it needs no token
|
|
12
|
+
// and nothing is fetched again; installing it in the workspace again
|
|
13
|
+
// updates the copy, and each agent's Upgrade then picks that up.
|
|
14
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { join, resolve, sep } from 'node:path';
|
|
16
|
+
export const WORKSPACE_ABILITIES_DIR_ENV = 'LOOPENGINE_ABILITIES_DIR';
|
|
17
|
+
export class WorkspaceAbilityError extends Error {
|
|
18
|
+
}
|
|
19
|
+
export function workspaceAbilitiesDir() {
|
|
20
|
+
return resolve(process.cwd(), process.env[WORKSPACE_ABILITIES_DIR_ENV]?.trim() || 'abilities');
|
|
21
|
+
}
|
|
22
|
+
function recordsPath() {
|
|
23
|
+
return join(workspaceAbilitiesDir(), 'workspace.json');
|
|
24
|
+
}
|
|
25
|
+
/** A copy's folder name: the package name, with a scope's slash made safe
|
|
26
|
+
* ("@acme/lp-x" is abilities/@acme+lp-x). */
|
|
27
|
+
function folderFor(name) {
|
|
28
|
+
return join(workspaceAbilitiesDir(), name.replace('/', '+'));
|
|
29
|
+
}
|
|
30
|
+
function readRecords() {
|
|
31
|
+
const path = recordsPath();
|
|
32
|
+
if (!existsSync(path))
|
|
33
|
+
return {};
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
36
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function writeRecords(records) {
|
|
43
|
+
mkdirSync(workspaceAbilitiesDir(), { recursive: true });
|
|
44
|
+
writeFileSync(recordsPath(), JSON.stringify(records, null, 2) + '\n');
|
|
45
|
+
}
|
|
46
|
+
/** Every copy in the workspace, by name. A record whose folder is gone is
|
|
47
|
+
* left out. */
|
|
48
|
+
export function listWorkspaceAbilities() {
|
|
49
|
+
return Object.entries(readRecords())
|
|
50
|
+
.flatMap(([name, record]) => {
|
|
51
|
+
const dir = folderFor(name);
|
|
52
|
+
if (!existsSync(join(dir, 'loopengine.ability.json')))
|
|
53
|
+
return [];
|
|
54
|
+
let description = '';
|
|
55
|
+
try {
|
|
56
|
+
description = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).description ?? '';
|
|
57
|
+
}
|
|
58
|
+
catch { }
|
|
59
|
+
return [{ name, description, spec: `file:${dir}`, ...record }];
|
|
60
|
+
})
|
|
61
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
62
|
+
}
|
|
63
|
+
/** Stores a fetched ability (`fetchedDir`, a package folder holding
|
|
64
|
+
* loopengine.ability.json) as the workspace's copy, replacing an earlier
|
|
65
|
+
* one of the same name. `source` is the spec it came from. */
|
|
66
|
+
export function addWorkspaceAbility(fetchedDir, source) {
|
|
67
|
+
if (!existsSync(join(fetchedDir, 'loopengine.ability.json')))
|
|
68
|
+
throw new WorkspaceAbilityError(`${source} has no loopengine.ability.json, so it isn't a loopengine ability`);
|
|
69
|
+
let pkg = {};
|
|
70
|
+
try {
|
|
71
|
+
pkg = JSON.parse(readFileSync(join(fetchedDir, 'package.json'), 'utf8'));
|
|
72
|
+
}
|
|
73
|
+
catch { }
|
|
74
|
+
if (!pkg.name || !/^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i.test(pkg.name))
|
|
75
|
+
throw new WorkspaceAbilityError(`${source} has no valid package.json name`);
|
|
76
|
+
const dir = folderFor(pkg.name);
|
|
77
|
+
// Copied beside it first, then swapped in, so a failed copy leaves the
|
|
78
|
+
// earlier one as it was.
|
|
79
|
+
const staging = `${dir}.installing`;
|
|
80
|
+
rmSync(staging, { recursive: true, force: true });
|
|
81
|
+
mkdirSync(workspaceAbilitiesDir(), { recursive: true });
|
|
82
|
+
cpSync(fetchedDir, staging, { recursive: true });
|
|
83
|
+
rmSync(dir, { recursive: true, force: true });
|
|
84
|
+
renameSync(staging, dir);
|
|
85
|
+
const records = readRecords();
|
|
86
|
+
records[pkg.name] = { source, version: pkg.version ?? '', installedAt: new Date().toISOString() };
|
|
87
|
+
writeRecords(records);
|
|
88
|
+
return listWorkspaceAbilities().find((a) => a.name === pkg.name);
|
|
89
|
+
}
|
|
90
|
+
/** Removes a copy from the workspace. Agents that installed it keep
|
|
91
|
+
* their own copies of its files. */
|
|
92
|
+
export function removeWorkspaceAbility(name) {
|
|
93
|
+
const records = readRecords();
|
|
94
|
+
if (!records[name])
|
|
95
|
+
throw new WorkspaceAbilityError(`${name} isn't in the workspace`);
|
|
96
|
+
delete records[name];
|
|
97
|
+
rmSync(folderFor(name), { recursive: true, force: true });
|
|
98
|
+
writeRecords(records);
|
|
99
|
+
}
|
|
100
|
+
/** The workspace copy an agent installed from, when `path` (from a
|
|
101
|
+
* file: spec) is one. */
|
|
102
|
+
export function workspaceAbilityAt(path) {
|
|
103
|
+
const root = workspaceAbilitiesDir() + sep;
|
|
104
|
+
const target = resolve(process.cwd(), path);
|
|
105
|
+
if (!target.startsWith(root))
|
|
106
|
+
return undefined;
|
|
107
|
+
const folder = target.slice(root.length).split(sep)[0];
|
|
108
|
+
const entry = Object.entries(readRecords()).find(([name]) => name.replace('/', '+') === folder);
|
|
109
|
+
return entry ? { name: entry[0], ...entry[1] } : undefined;
|
|
110
|
+
}
|