@saltcorn/agents 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Saltcorn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,2 @@
1
+ # agents
2
+ AI Agents for Saltcorn
package/agent-view.js ADDED
@@ -0,0 +1,608 @@
1
+ const Field = require("@saltcorn/data/models/field");
2
+ const Table = require("@saltcorn/data/models/table");
3
+ const Form = require("@saltcorn/data/models/form");
4
+ const View = require("@saltcorn/data/models/view");
5
+ const Trigger = require("@saltcorn/data/models/trigger");
6
+ const FieldRepeat = require("@saltcorn/data/models/fieldrepeat");
7
+ const db = require("@saltcorn/data/db");
8
+ const WorkflowRun = require("@saltcorn/data/models/workflow_run");
9
+ const Workflow = require("@saltcorn/data/models/workflow");
10
+ const { localeDateTime } = require("@saltcorn/markup");
11
+ const {
12
+ div,
13
+ script,
14
+ domReady,
15
+ pre,
16
+ code,
17
+ input,
18
+ h4,
19
+ style,
20
+ h5,
21
+ button,
22
+ text_attr,
23
+ i,
24
+ p,
25
+ span,
26
+ small,
27
+ form,
28
+ textarea,
29
+ a,
30
+ } = require("@saltcorn/markup/tags");
31
+ const { getState } = require("@saltcorn/data/db/state");
32
+ const {
33
+ incompleteCfgMsg,
34
+ getCompletionArguments,
35
+ find_tool,
36
+ } = require("./common");
37
+ const MarkdownIt = require("markdown-it"),
38
+ md = new MarkdownIt();
39
+
40
+ const configuration_workflow = (req) =>
41
+ new Workflow({
42
+ steps: [
43
+ {
44
+ name: req.__("Agent action"),
45
+ form: async (context) => {
46
+ const agent_actions = await Trigger.find({ action: "Agent" });
47
+ return new Form({
48
+ fields: [
49
+ {
50
+ name: "action_id",
51
+ label: "Agent action",
52
+ type: "String",
53
+ required: true,
54
+ attributes: {
55
+ options: agent_actions.map((a) => ({
56
+ label: a.name,
57
+ name: a.id,
58
+ })),
59
+ },
60
+ sublabel:
61
+ "A trigger with <code>Agent</code> action. " +
62
+ a(
63
+ {
64
+ "data-dyn-href": `\`/actions/configure/\${action_id}\``,
65
+ target: "_blank",
66
+ },
67
+ req.__("Configure")
68
+ ),
69
+ },
70
+ {
71
+ name: "show_prev_runs",
72
+ label: "Show previous runs",
73
+ type: "Bool",
74
+ },
75
+ ],
76
+ });
77
+ },
78
+ },
79
+ ],
80
+ });
81
+
82
+ const get_state_fields = () => [];
83
+
84
+ const run = async (
85
+ table_id,
86
+ viewname,
87
+ { action_id, show_prev_runs },
88
+ state,
89
+ { res, req }
90
+ ) => {
91
+ const action = await Trigger.findOne({ id: action_id });
92
+ const prevRuns = (
93
+ await WorkflowRun.find(
94
+ { trigger_id: action.id, started_by: req.user?.id },
95
+ { orderBy: "started_at", orderDesc: true, limit: 30 }
96
+ )
97
+ ).filter((r) => r.context.interactions);
98
+
99
+ const cfgMsg = incompleteCfgMsg();
100
+ if (cfgMsg) return cfgMsg;
101
+ let runInteractions = "";
102
+ if (state.run_id) {
103
+ const run = prevRuns.find((r) => r.id == state.run_id);
104
+ const interactMarkups = [];
105
+ for (const interact of run.context.interactions) {
106
+ switch (interact.role) {
107
+ case "user":
108
+ interactMarkups.push(
109
+ div(
110
+ { class: "interaction-segment" },
111
+ span({ class: "badge bg-secondary" }, "You"),
112
+ md.render(interact.content)
113
+ )
114
+ );
115
+ break;
116
+ case "assistant":
117
+ case "system":
118
+ if (interact.tool_calls) {
119
+ if (interact.content) {
120
+ interactMarkups.push(
121
+ div(
122
+ { class: "interaction-segment" },
123
+ span({ class: "badge bg-secondary" }, "Copilot"),
124
+ typeof interact.content === "string"
125
+ ? md.render(interact.content)
126
+ : interact.content
127
+ )
128
+ );
129
+ }
130
+ for (const tool_call of interact.tool_calls) {
131
+ const toolSkill = find_tool(
132
+ tool_call.function.name,
133
+ action.configuration
134
+ );
135
+ if (toolSkill) {
136
+ const row = JSON.parse(tool_call.function.arguments);
137
+ if (toolSkill.tool.renderToolCall) {
138
+ const rendered = await toolSkill.tool.renderToolCall(row, {
139
+ req,
140
+ });
141
+ if (rendered)
142
+ interactMarkups.push(
143
+ wrapSegment(
144
+ wrapCard(
145
+ toolSkill.skill.skill_label ||
146
+ toolSkill.skill.constructor.skill_name,
147
+ rendered
148
+ ),
149
+ "Copilot"
150
+ )
151
+ );
152
+ }
153
+ }
154
+ }
155
+ } else
156
+ interactMarkups.push(
157
+ div(
158
+ { class: "interaction-segment" },
159
+ span({ class: "badge bg-secondary" }, "Copilot"),
160
+ typeof interact.content === "string"
161
+ ? md.render(interact.content)
162
+ : interact.content
163
+ )
164
+ );
165
+ break;
166
+ case "tool":
167
+ if (interact.content !== "Action run") {
168
+ let markupContent;
169
+ console.log("interact", interact);
170
+ const toolSkill = find_tool(interact.name, action.configuration);
171
+ try {
172
+ if (toolSkill?.tool?.renderToolResponse)
173
+ markupContent = await toolSkill?.tool?.renderToolResponse?.(
174
+ JSON.parse(interact.content),
175
+ {
176
+ req,
177
+ }
178
+ );
179
+ } catch {
180
+ markupContent = pre(interact.content);
181
+ }
182
+ if (markupContent)
183
+ interactMarkups.push(
184
+ wrapSegment(
185
+ wrapCard(
186
+ toolSkill?.skill?.skill_label ||
187
+ toolSkill?.skill?.constructor.skill_name ||
188
+ interact.name,
189
+ markupContent
190
+ ),
191
+ "Copilot"
192
+ )
193
+ );
194
+ }
195
+ break;
196
+ }
197
+ }
198
+ runInteractions = interactMarkups.join("");
199
+ }
200
+ const input_form = form(
201
+ {
202
+ onsubmit: `event.preventDefault();spin_send_button();view_post('${viewname}', 'interact', $(this).serialize(), processCopilotResponse);return false;`,
203
+ class: "form-namespace copilot mt-2",
204
+ method: "post",
205
+ },
206
+ input({
207
+ type: "hidden",
208
+ name: "_csrf",
209
+ value: req.csrfToken(),
210
+ }),
211
+ input({
212
+ type: "hidden",
213
+ class: "form-control ",
214
+ name: "run_id",
215
+ value: state.run_id ? +state.run_id : undefined,
216
+ }),
217
+ div(
218
+ { class: "copilot-entry" },
219
+ textarea({
220
+ class: "form-control",
221
+ name: "userinput",
222
+ "data-fieldname": "userinput",
223
+ placeholder: "How can I help you?",
224
+ id: "inputuserinput",
225
+ rows: "3",
226
+ autofocus: true,
227
+ }),
228
+ span(
229
+ { class: "submit-button p-2", onclick: "$('form.copilot').submit()" },
230
+ i({ id: "sendbuttonicon", class: "far fa-paper-plane" })
231
+ )
232
+ )
233
+ );
234
+
235
+ const prev_runs_side_bar = div(
236
+ div(
237
+ {
238
+ class: "d-flex justify-content-between align-middle mb-2",
239
+ },
240
+ h5("Sessions"),
241
+
242
+ button(
243
+ {
244
+ type: "button",
245
+ class: "btn btn-secondary btn-sm py-0",
246
+ style: "font-size: 0.9em;height:1.5em",
247
+ onclick: "unset_state_field('run_id')",
248
+ title: "New session",
249
+ },
250
+ i({ class: "fas fa-redo fa-sm" })
251
+ )
252
+ ),
253
+ prevRuns.map((run) =>
254
+ div(
255
+ {
256
+ onclick: `set_state_field('run_id',${run.id})`,
257
+ class: "prevcopilotrun border p-2",
258
+ },
259
+ div(
260
+ { class: "d-flex justify-content-between" },
261
+ localeDateTime(run.started_at),
262
+ i({
263
+ class: "far fa-trash-alt",
264
+ onclick: `delprevrun(event, ${run.id})`,
265
+ })
266
+ ),
267
+
268
+ p({ class: "prevrun_content" }, run.context.interactions[0]?.content)
269
+ )
270
+ )
271
+ );
272
+ const main_chat = div(
273
+ { class: "card" },
274
+ div(
275
+ { class: "card-body" },
276
+ script({
277
+ src: `/static_assets/${db.connectObj.version_tag}/mermaid.min.js`,
278
+ }),
279
+ script(
280
+ { type: "module" },
281
+ `mermaid.initialize({securityLevel: 'loose'${
282
+ getState().getLightDarkMode(req.user) === "dark"
283
+ ? ",theme: 'dark',"
284
+ : ""
285
+ }});`
286
+ ),
287
+ div({ id: "copilotinteractions" }, runInteractions),
288
+ input_form,
289
+ style(
290
+ `div.interaction-segment:not(:first-child) {border-top: 1px solid #e7e7e7; }
291
+ div.interaction-segment {padding-top: 5px;padding-bottom: 5px;}
292
+ div.interaction-segment p {margin-bottom: 0px;}
293
+ div.interaction-segment div.card {margin-top: 0.5rem;}
294
+ div.prevcopilotrun:hover {cursor: pointer; background-color: var(--tblr-secondary-bg-subtle, var(--bs-secondary-bg-subtle, gray));}
295
+ div.prevcopilotrun i.fa-trash-alt {display: none;}
296
+ div.prevcopilotrun:hover i.fa-trash-alt {display: block;}
297
+ .copilot-entry .submit-button:hover { cursor: pointer}
298
+
299
+ .copilot-entry .submit-button {
300
+ position: relative;
301
+ top: -1.8rem;
302
+ left: 0.1rem;
303
+ }
304
+ .copilot-entry {margin-bottom: -1.25rem; margin-top: 1rem;}
305
+ p.prevrun_content {
306
+ white-space: nowrap;
307
+ overflow: hidden;
308
+ margin-bottom: 0px;
309
+ display: block;
310
+ text-overflow: ellipsis;}`
311
+ ),
312
+ script(`function processCopilotResponse(res) {
313
+ $("#sendbuttonicon").attr("class","far fa-paper-plane");
314
+ const $runidin= $("input[name=run_id")
315
+ if(res.run_id && (!$runidin.val() || $runidin.val()=="undefined"))
316
+ $runidin.val(res.run_id);
317
+ const wrapSegment = (html, who) => '<div class="interaction-segment"><span class="badge bg-secondary">'+who+'</span>'+html+'</div>'
318
+ $("#copilotinteractions").append(wrapSegment('<p>'+$("textarea[name=userinput]").val()+'</p>', "You"))
319
+ $("textarea[name=userinput]").val("")
320
+
321
+ if(res.response)
322
+ $("#copilotinteractions").append(res.response)
323
+ }
324
+ function restore_old_button_elem(btn) {
325
+ const oldText = $(btn).data("old-text");
326
+ btn.html(oldText);
327
+ btn.css({ width: "" }).prop("disabled", false);
328
+ btn.removeData("old-text");
329
+ }
330
+ function delprevrun(e, runid) {
331
+ e.preventDefault();
332
+ e.stopPropagation();
333
+ view_post('${viewname}', 'delprevrun', {run_id:runid})
334
+ $(e.target).closest(".prevcopilotrun").remove()
335
+ return false;
336
+ }
337
+ function processExecuteResponse(res) {
338
+ const btn = $("#exec-"+res.fcall_id)
339
+ restore_old_button_elem($("#exec-"+res.fcall_id))
340
+ btn.prop('disabled', true);
341
+ btn.html('<i class="fas fa-check me-1"></i>Applied')
342
+ btn.removeClass("btn-primary")
343
+ btn.addClass("btn-secondary")
344
+ if(res.postExec) {
345
+ $('#postexec-'+res.fcall_id).html(res.postExec)
346
+ }
347
+ }
348
+ function submitOnEnter(event) {
349
+ if (event.which === 13) {
350
+ if (!event.repeat) {
351
+ const newEvent = new Event("submit", {cancelable: true});
352
+ event.target.form.dispatchEvent(newEvent);
353
+ }
354
+
355
+ event.preventDefault(); // Prevents the addition of a new line in the text field
356
+ }
357
+ }
358
+ document.getElementById("inputuserinput").addEventListener("keydown", submitOnEnter);
359
+ function spin_send_button() {
360
+ $("#sendbuttonicon").attr("class","fas fa-spinner fa-spin");
361
+ }
362
+ `)
363
+ )
364
+ );
365
+ return show_prev_runs
366
+ ? {
367
+ widths: [3, 9],
368
+ gx: 3,
369
+ besides: [
370
+ {
371
+ type: "container",
372
+ contents: prev_runs_side_bar,
373
+ },
374
+ {
375
+ type: "container",
376
+ contents: main_chat,
377
+ },
378
+ ],
379
+ }
380
+ : main_chat;
381
+ };
382
+
383
+ /*
384
+
385
+ build a workflow that asks the user for their name and age
386
+
387
+ */
388
+
389
+ const interact = async (table_id, viewname, config, body, { req, res }) => {
390
+ const { userinput, run_id } = body;
391
+ let run;
392
+ if (!run_id || run_id === "undefined")
393
+ run = await WorkflowRun.create({
394
+ status: "Running",
395
+ started_by: req.user?.id,
396
+ trigger_id: config.action_id,
397
+ context: {
398
+ implemented_fcall_ids: [],
399
+ interactions: [{ role: "user", content: userinput }],
400
+ funcalls: {},
401
+ },
402
+ });
403
+ else {
404
+ run = await WorkflowRun.findOne({ id: +run_id });
405
+ await addToContext(run, {
406
+ interactions: [{ role: "user", content: userinput }],
407
+ });
408
+ }
409
+ return await process_interaction(run, config, req);
410
+ };
411
+
412
+ const delprevrun = async (table_id, viewname, config, body, { req, res }) => {
413
+ const { run_id } = body;
414
+ let run;
415
+
416
+ run = await WorkflowRun.findOne({ id: +run_id });
417
+ if (req.user?.role_id === 1 || req.user?.id === run.started_by)
418
+ await run.delete();
419
+
420
+ return;
421
+ };
422
+
423
+ const process_interaction = async (run, config, req, prevResponses = []) => {
424
+ const action = await Trigger.findOne({ id: config.action_id });
425
+ const complArgs = await getCompletionArguments(action.configuration);
426
+ complArgs.chat = run.context.interactions;
427
+ //complArgs.debugResult = true;
428
+ console.log("complArgs", JSON.stringify(complArgs, null, 2));
429
+
430
+ const answer = await getState().functions.llm_generate.run("", complArgs);
431
+ console.log("answer", answer);
432
+ await addToContext(run, {
433
+ interactions:
434
+ typeof answer === "object" && answer.tool_calls
435
+ ? [
436
+ {
437
+ role: "assistant",
438
+ tool_calls: answer.tool_calls,
439
+ content: answer.content,
440
+ },
441
+ ]
442
+ : [{ role: "assistant", content: answer }],
443
+ });
444
+ const responses = [];
445
+
446
+ if (typeof answer === "object" && answer.tool_calls) {
447
+ if (answer.content)
448
+ responses.push(wrapSegment(md.render(answer.content), "Copilot"));
449
+ //const actions = [];
450
+ let hasResult = false;
451
+ for (const tool_call of answer.tool_calls) {
452
+ console.log("call function", tool_call.function);
453
+
454
+ await addToContext(run, {
455
+ funcalls: { [tool_call.id]: tool_call.function },
456
+ });
457
+
458
+ const tool = find_tool(tool_call.function.name, action.configuration);
459
+
460
+ if (tool) {
461
+ if (tool.tool.renderToolCall) {
462
+ const row = JSON.parse(tool_call.function.arguments);
463
+ const rendered = await tool.tool.renderToolCall(row, {
464
+ req,
465
+ });
466
+ if (rendered)
467
+ responses.push(
468
+ wrapSegment(
469
+ wrapCard(
470
+ tool.skill.skill_label || tool.skill.constructor.skill_name,
471
+ rendered
472
+ ),
473
+ "Copilot"
474
+ )
475
+ );
476
+ }
477
+ hasResult = true;
478
+ const result = await tool.tool.process(
479
+ JSON.parse(tool_call.function.arguments)
480
+ );
481
+ if (
482
+ (typeof result === "object" && Object.keys(result || {}).length) ||
483
+ typeof result === "string"
484
+ ) {
485
+ if (tool.tool.renderToolResponse) {
486
+ const rendered = await tool.tool.renderToolResponse(result, {
487
+ req,
488
+ });
489
+ if (rendered)
490
+ responses.push(
491
+ wrapSegment(
492
+ wrapCard(
493
+ tool.skill.skill_label || tool.skill.constructor.skill_name,
494
+ rendered
495
+ ),
496
+ "Copilot"
497
+ )
498
+ );
499
+ }
500
+ hasResult = true;
501
+ }
502
+ await addToContext(run, {
503
+ interactions: [
504
+ {
505
+ role: "tool",
506
+ tool_call_id: tool_call.id,
507
+ name: tool_call.function.name,
508
+ content:
509
+ result && typeof result !== "string"
510
+ ? JSON.stringify(result)
511
+ : result || "Action run",
512
+ },
513
+ ],
514
+ });
515
+ }
516
+ }
517
+ if (hasResult)
518
+ return await process_interaction(run, config, req, [
519
+ ...prevResponses,
520
+ ...responses,
521
+ ]);
522
+ } else responses.push(wrapSegment(md.render(answer), "Copilot"));
523
+
524
+ return {
525
+ json: {
526
+ success: "ok",
527
+ response: [...prevResponses, ...responses].join(""),
528
+ run_id: run.id,
529
+ },
530
+ };
531
+ };
532
+
533
+ const wrapSegment = (html, who) =>
534
+ '<div class="interaction-segment"><span class="badge bg-secondary">' +
535
+ who +
536
+ "</span>" +
537
+ html +
538
+ "</div>";
539
+
540
+ const wrapCard = (title, ...inners) =>
541
+ span({ class: "badge bg-info ms-1" }, title) +
542
+ div(
543
+ { class: "card mb-3 bg-secondary-subtle" },
544
+ div({ class: "card-body" }, inners)
545
+ );
546
+
547
+ const wrapAction = (
548
+ inner_markup,
549
+ viewname,
550
+ tool_call,
551
+ actionClass,
552
+ implemented,
553
+ run
554
+ ) =>
555
+ wrapCard(
556
+ actionClass.title,
557
+ inner_markup + implemented
558
+ ? button(
559
+ {
560
+ type: "button",
561
+ class: "btn btn-secondary d-block mt-3 float-end",
562
+ disabled: true,
563
+ },
564
+ i({ class: "fas fa-check me-1" }),
565
+ "Applied"
566
+ )
567
+ : button(
568
+ {
569
+ type: "button",
570
+ id: "exec-" + tool_call.id,
571
+ class: "btn btn-primary d-block mt-3 float-end",
572
+ onclick: `press_store_button(this, true);view_post('${viewname}', 'execute', {fcall_id: '${tool_call.id}', run_id: ${run.id}}, processExecuteResponse)`,
573
+ },
574
+ "Apply"
575
+ ) + div({ id: "postexec-" + tool_call.id })
576
+ );
577
+
578
+ const addToContext = async (run, newCtx) => {
579
+ if (run.addToContext) return await run.addToContext(newCtx);
580
+ let changed = true;
581
+ Object.keys(newCtx).forEach((k) => {
582
+ if (Array.isArray(run.context[k])) {
583
+ if (!Array.isArray(newCtx[k]))
584
+ throw new Error("Must be array to append to array");
585
+ run.context[k].push(...newCtx[k]);
586
+ changed = true;
587
+ } else if (typeof run.context[k] === "object") {
588
+ if (typeof newCtx[k] !== "object")
589
+ throw new Error("Must be object to append to object");
590
+ Object.assign(run.context[k], newCtx[k]);
591
+ changed = true;
592
+ } else {
593
+ run.context[k] = newCtx[k];
594
+ changed = true;
595
+ }
596
+ });
597
+ if (changed) await run.update({ context: run.context });
598
+ };
599
+
600
+ module.exports = {
601
+ name: "Agent Chat",
602
+ configuration_workflow,
603
+ display_state_form: false,
604
+ get_state_fields,
605
+ tableless: true,
606
+ run,
607
+ routes: { interact, delprevrun },
608
+ };
package/common.js ADDED
@@ -0,0 +1,92 @@
1
+ const { getState } = require("@saltcorn/data/db/state");
2
+
3
+ const get_skills = () => {
4
+ return [
5
+ //require("./skills/EmbeddingRetrieval"),
6
+ require("./skills/FTSRetrieval"),
7
+ //require("./skills/AdaptiveFeedback"),
8
+ ];
9
+ };
10
+
11
+ const get_skill_class = (type) => {
12
+ const classes = get_skills();
13
+ return classes.find((c) => c.skill_name === type);
14
+ };
15
+
16
+ const get_skill_instances = (config) => {
17
+ const instances = [];
18
+ for (const skillCfg of config.skills) {
19
+ const klass = get_skill_class(skillCfg.skill_type);
20
+ const skill = new klass(skillCfg);
21
+ instances.push(skill);
22
+ }
23
+ return instances;
24
+ };
25
+
26
+ const find_tool = (name, config) => {
27
+ const skills = get_skill_instances(config);
28
+ for (const skill of skills) {
29
+ const skillTools = skill.provideTools();
30
+ const tools = !skillTools
31
+ ? []
32
+ : Array.isArray(skillTools)
33
+ ? skillTools
34
+ : [skillTools];
35
+ const found = tools.find((t) => t?.function.name === name);
36
+ if (found) return { tool: found, skill };
37
+ }
38
+ };
39
+
40
+
41
+ const getCompletionArguments = async (config) => {
42
+ let tools = [];
43
+
44
+ let sysPrompts = [config.sys_prompt];
45
+
46
+ const skills = get_skill_instances(config);
47
+ for (const skill of skills) {
48
+ const sysPr = skill.systemPrompt();
49
+ if (sysPr) sysPrompts.push(sysPr);
50
+ const skillTools = skill.provideTools();
51
+ if (skillTools && Array.isArray(skillTools)) tools.push(...skillTools);
52
+ else if (skillTools) tools.push(skillTools);
53
+ }
54
+ if (tools.length === 0) tools = undefined;
55
+ return { tools, systemPrompt: sysPrompts.join("\n\n") };
56
+ };
57
+
58
+
59
+ const getCompletion = async (language, prompt) => {
60
+ return getState().functions.llm_generate.run(prompt, {
61
+ systemPrompt: `You are a helpful code assistant. Your language of choice is ${language}. Do not include any explanation, just generate the code block itself.`,
62
+ });
63
+ };
64
+
65
+ const incompleteCfgMsg = () => {
66
+ const plugin_cfgs = getState().plugin_cfgs;
67
+
68
+ if (
69
+ !plugin_cfgs["@saltcorn/large-language-model"] &&
70
+ !plugin_cfgs["large-language-model"]
71
+ ) {
72
+ const modName = Object.keys(plugin_cfgs).find((m) =>
73
+ m.includes("large-language-model")
74
+ );
75
+ if (modName)
76
+ return `LLM module not configured. Please configure <a href="/plugins/configure/${encodeURIComponent(
77
+ modName
78
+ )}">here<a> before using copilot.`;
79
+ else
80
+ return `LLM module not configured. Please install and configure <a href="/plugins">here<a> before using copilot.`;
81
+ }
82
+ };
83
+
84
+ module.exports = {
85
+ get_skills,
86
+ get_skill_class,
87
+ incompleteCfgMsg,
88
+ getCompletion,
89
+ find_tool,
90
+ get_skill_instances,
91
+ getCompletionArguments
92
+ };
package/index.js ADDED
@@ -0,0 +1,100 @@
1
+ const Workflow = require("@saltcorn/data/models/workflow");
2
+ const Form = require("@saltcorn/data/models/form");
3
+ const FieldRepeat = require("@saltcorn/data/models/fieldrepeat");
4
+ const { features } = require("@saltcorn/data/db/state");
5
+ const { get_skills, getCompletionArguments } = require("./common");
6
+ const { applyAsync } = require("@saltcorn/data/utils");
7
+ const WorkflowRun = require("@saltcorn/data/models/workflow_run");
8
+ const { interpolate } = require("@saltcorn/data/utils");
9
+ const { getState } = require("@saltcorn/data/db/state");
10
+
11
+ module.exports = {
12
+ sc_plugin_api_version: 1,
13
+ dependencies: ["@saltcorn/large-language-model"],
14
+ viewtemplates: [require("./agent-view")],
15
+ actions: {
16
+ Agent: {
17
+ disableInBuilder: true,
18
+ disableInList: true,
19
+ disableInWorkflow: true,
20
+ configFields: async ({ table, mode }) => {
21
+ const skills = get_skills();
22
+ const skills_fields = [];
23
+ for (const skill of skills) {
24
+ if (skill.configFields) {
25
+ const fields = await applyAsync(skill.configFields, undefined);
26
+ for (const field of fields) {
27
+ if (!field.showIf) field.showIf = {};
28
+ field.showIf.skill_type = skill.skill_name;
29
+ skills_fields.push(field);
30
+ }
31
+ }
32
+ }
33
+ return [
34
+ ...(table
35
+ ? [
36
+ {
37
+ name: "prompt",
38
+ label: "Prompt",
39
+ sublabel:
40
+ "When triggered from table event or table view button. Use handlebars <code>{{}}</code> to access table fields. Ignored if run in Agent Chat view.",
41
+ type: "String",
42
+ required: true,
43
+ attributes: { options: table.fields.map((f) => f.name) },
44
+ },
45
+ ]
46
+ : []),
47
+ {
48
+ name: "sys_prompt",
49
+ label: "System prompt",
50
+ sublabel: "Additional information for the system prompt",
51
+ type: "String",
52
+ fieldview: "textarea",
53
+ },
54
+
55
+ new FieldRepeat({
56
+ name: "skills",
57
+ label: "Skills",
58
+ fields: [
59
+ {
60
+ name: "skill_type",
61
+ label: "Type",
62
+ type: "String",
63
+ required: true,
64
+ attributes: { options: skills.map((s) => s.skill_name) },
65
+ },
66
+ ...skills_fields,
67
+ ],
68
+ }),
69
+ ];
70
+ },
71
+ run: async ({ configuration, user, row, trigger_id, ...rest }) => {
72
+ const userinput = interpolate(configuration.prompt, row, user);
73
+ const run = await WorkflowRun.create({
74
+ status: "Running",
75
+ started_by: user?.id,
76
+ trigger_id: trigger_id || undefined,
77
+ context: {
78
+ implemented_fcall_ids: [],
79
+ interactions: [{ role: "user", content: userinput }],
80
+ funcalls: {},
81
+ },
82
+ });
83
+ const complArgs = await getCompletionArguments(configuration);
84
+ const answer = await getState().functions.llm_generate.run(
85
+ "",
86
+ complArgs
87
+ );
88
+ },
89
+ },
90
+ },
91
+ };
92
+
93
+ /*
94
+ TODO
95
+
96
+ -embedding retrieval
97
+ -run as action
98
+ -view: set placeholder, labels
99
+
100
+ */
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@saltcorn/agents",
3
+ "version": "0.1.0",
4
+ "description": "AI agents for Saltcorn",
5
+ "main": "index.js",
6
+ "dependencies": {
7
+ "@saltcorn/data": "^0.9.0",
8
+ "underscore": "1.13.6",
9
+ "node-sql-parser": "4.15.0",
10
+ "markdown-it": "14.1.0",
11
+ "style-to-object": "1.0.8",
12
+ "node-html-parser": "7.0.1"
13
+ },
14
+ "author": "Tom Nielsen",
15
+ "license": "MIT",
16
+ "repository": "github:saltcorn/agents",
17
+ "eslintConfig": {
18
+ "extends": "eslint:recommended",
19
+ "parserOptions": {
20
+ "ecmaVersion": 2022
21
+ },
22
+ "env": {
23
+ "node": true,
24
+ "es6": true
25
+ },
26
+ "rules": {
27
+ "no-unused-vars": "off",
28
+ "no-case-declarations": "off",
29
+ "no-empty": "warn",
30
+ "no-fallthrough": "warn"
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,21 @@
1
+ const Workflow = require("@saltcorn/data/models/workflow");
2
+ const Form = require("@saltcorn/data/models/form");
3
+
4
+ class AdaptiveFeedback {
5
+ static skill_name = "Adaptive Feedback";
6
+ static async configFields() {
7
+ return [
8
+ {
9
+ name: "label",
10
+ label: "Label",
11
+ type: "String",
12
+ },
13
+ ];
14
+ }
15
+
16
+ onEvolve() {}
17
+
18
+ provideTools() {}
19
+ }
20
+
21
+ module.exports = AdaptiveFeedback;
@@ -0,0 +1,149 @@
1
+ const { div } = require("@saltcorn/markup/tags");
2
+ const Workflow = require("@saltcorn/data/models/workflow");
3
+ const Form = require("@saltcorn/data/models/form");
4
+ const Table = require("@saltcorn/data/models/table");
5
+ const View = require("@saltcorn/data/models/view");
6
+ const { getState } = require("@saltcorn/data/db/state");
7
+ const db = require("@saltcorn/data/db");
8
+
9
+ class RetrievalByEmbedding {
10
+ static skill_name = "Retrieval by embedding";
11
+
12
+ constructor(cfg) {
13
+ Object.assign(this, cfg);
14
+ }
15
+
16
+ get toolName() {
17
+ return `search_${this.table_name.replaceAll(" ", "")}`;
18
+ }
19
+
20
+ systemPrompt() {
21
+ if (this.mode === "Tool") {
22
+ const table = Table.findOne(this.vec_field.split["."][0]);
23
+
24
+ return `Use the ${this.toolName} tool to search an archive named ${
25
+ table.name
26
+ }${
27
+ table.description ? ` (${table.description})` : ""
28
+ } for documents related to a search phrase or a question`;
29
+ }
30
+ }
31
+
32
+ static async configFields() {
33
+ const allTables = await Table.find();
34
+ const tableOpts = [];
35
+ const relation_opts = {};
36
+ const list_view_opts = {};
37
+ for (const table of allTables) {
38
+ table.fields
39
+ .filter((f) => f.type?.name === "PGVector")
40
+ .forEach((f) => {
41
+ const relNm = `${table.name}.${f.name}`;
42
+ tableOpts.push(relNm);
43
+ const fkeys = table.fields
44
+ .filter((f) => f.is_fkey)
45
+ .map((f) => f.name);
46
+ relation_opts[relNm] = ["", ...fkeys];
47
+
48
+ list_view_opts[relNm] = []
49
+ });
50
+ }
51
+ return [
52
+ {
53
+ name: "mode",
54
+ label: "Mode",
55
+ type: "String",
56
+ required: true,
57
+ attributes: { options: ["Tool", "Search on every user input"] },
58
+ },
59
+ {
60
+ name: "vec_field",
61
+ label: "Vector field",
62
+ sublabel: "Field to search for vector similarity",
63
+ type: "String",
64
+ required: true,
65
+ attributes: { options: tableOpts },
66
+ },
67
+ {
68
+ name: "doc_relation",
69
+ label: "Document relation",
70
+ sublabel:
71
+ "Optional. For each vector match, retrieve row in the table related by this key instead",
72
+ type: "String",
73
+ required: true,
74
+ attributes: { calcOptions: ["vec_field", relation_opts] },
75
+ },
76
+ {
77
+ name: "list_view",
78
+ label: "List view",
79
+ type: "String",
80
+ attributes: {
81
+ calcOptions: ["vec_field", list_view_opts],
82
+ },
83
+ },
84
+ {
85
+ name: "contents_expr",
86
+ label: "Contents string",
87
+ type: "String",
88
+ sublabel:
89
+ "Use handlebars (<code>{{ }}</code>) to access fields in the retrieved rows",
90
+ },
91
+ ];
92
+ }
93
+
94
+ onMessage(msgs) {
95
+ if (this.mode !== "Search on every user input") return;
96
+ }
97
+
98
+ provideTools() {
99
+ if (this.mode !== "Tool") return [];
100
+ const table0 = Table.findOne(this.vec_field.split["."][0]);
101
+ const table_docs = this.doc_relation
102
+ ? Table.findOne(table0.getField(this.doc_relation).reftable_name)
103
+ : table0;
104
+ return {
105
+ type: "function",
106
+ process({ phrase_or_question }) {
107
+ return {
108
+ response: "There are no documents related to: " + phrase_or_question,
109
+ };
110
+ },
111
+ renderToolResponse: async ({ response, rows }, { req }) => {
112
+ if (rows) {
113
+ const view = View.findOne({ name: this.list_view });
114
+
115
+ if (view) {
116
+ const viewRes = await view.run(
117
+ {
118
+ [table_docs.pk_name]: {
119
+ in: rows.map((r) => r[table_docs.pk_name]),
120
+ },
121
+ },
122
+ { req }
123
+ );
124
+ return viewRes;
125
+ } else return "";
126
+ }
127
+ return div({ class: "border border-success p-2 m-2" }, response);
128
+ },
129
+ function: {
130
+ name: this.toolName,
131
+ description: `Search the ${table_docs.name``} archive${
132
+ table_docs.description ? ` (${table_docs.description})` : ""
133
+ } for information related to a search phrase or a question. The relevant documents will be returned`,
134
+ parameters: {
135
+ type: "object",
136
+ required: ["phrase_or_question"],
137
+ properties: {
138
+ phrase_or_question: {
139
+ type: "string",
140
+ description: "The phrase or question to search the archive with",
141
+ },
142
+ },
143
+ },
144
+ },
145
+ };
146
+ }
147
+ }
148
+
149
+ module.exports = RetrievalByEmbedding;
@@ -0,0 +1,157 @@
1
+ const { div, pre } = require("@saltcorn/markup/tags");
2
+ const Workflow = require("@saltcorn/data/models/workflow");
3
+ const Form = require("@saltcorn/data/models/form");
4
+ const Table = require("@saltcorn/data/models/table");
5
+ const View = require("@saltcorn/data/models/view");
6
+ const { getState } = require("@saltcorn/data/db/state");
7
+ const db = require("@saltcorn/data/db");
8
+
9
+ class RetrievalByFullTextSearch {
10
+ static skill_name = "Retrieval by full-text search";
11
+
12
+ get skill_label() {
13
+ return `Search ${this.table_name}`;
14
+ }
15
+
16
+ constructor(cfg) {
17
+ Object.assign(this, cfg);
18
+ }
19
+
20
+ get toolName() {
21
+ return `search_${this.table_name.replaceAll(" ", "")}`;
22
+ }
23
+
24
+ systemPrompt() {
25
+ if (this.mode === "Tool")
26
+ return `Use the ${this.toolName} tool to search the ${this.table_name} database by a search phrase which will locate rows where any field match that query`;
27
+ }
28
+
29
+ static async configFields() {
30
+ const allTables = await Table.find();
31
+ const list_view_opts = {};
32
+ for (const t of allTables) {
33
+ const lviews = await View.find_table_views_where(
34
+ t.id,
35
+ ({ state_fields, viewrow }) =>
36
+ viewrow.viewtemplate !== "Edit" &&
37
+ state_fields.every((sf) => !sf.required)
38
+ );
39
+ list_view_opts[t.name] = ["", ...lviews.map((v) => v.name)];
40
+ }
41
+ return [
42
+ {
43
+ name: "mode",
44
+ label: "Mode",
45
+ type: "String",
46
+ required: true,
47
+ attributes: { options: ["Tool", "Search on every user input"] },
48
+ },
49
+ {
50
+ name: "table_name",
51
+ label: "Table",
52
+ sublabel: "Which table to search",
53
+ type: "String",
54
+ required: true,
55
+ attributes: { options: allTables.map((t) => t.name) },
56
+ },
57
+ {
58
+ name: "list_view",
59
+ label: "List view",
60
+ type: "String",
61
+ attributes: {
62
+ calcOptions: ["table_name", list_view_opts],
63
+ },
64
+ },
65
+ {
66
+ name: "hidden_fields",
67
+ label: "Hide fields",
68
+ type: "String",
69
+ sublabel: "Comma-separated list of fields to hide from the prompt",
70
+ },
71
+ /*{
72
+ name: "contents_expr",
73
+ label: "Contents string",
74
+ type: "String",
75
+ sublabel:
76
+ "Use handlebars (<code>{{ }}</code>) to access fields in the retrieved rows",
77
+ },*/
78
+ ];
79
+ }
80
+
81
+ onMessage(msgs) {
82
+ if (this.mode !== "Search on every user input") return;
83
+ }
84
+
85
+ provideTools() {
86
+ if (this.mode !== "Tool") return [];
87
+ const table = Table.findOne(this.table_name);
88
+ return {
89
+ type: "function",
90
+ process: async ({ phrase }) => {
91
+ const scState = getState();
92
+ const language = scState.pg_ts_config;
93
+ const use_websearch = scState.getConfig("search_use_websearch", false);
94
+ const rows = await table.getRows({
95
+ _fts: {
96
+ fields: table.fields,
97
+ searchTerm: phrase,
98
+ language,
99
+ use_websearch,
100
+ table: table.name,
101
+ schema: db.isSQLite ? undefined : db.getTenantSchema(),
102
+ },
103
+ });
104
+ if (this.hidden_fields) {
105
+ const hidden_fields = this.hidden_fields
106
+ .split(",")
107
+ .map((s) => s.trim());
108
+ rows.forEach((r) => {
109
+ hidden_fields.forEach((k) => {
110
+ delete r[k];
111
+ });
112
+ });
113
+ }
114
+ if (rows.length) return { rows };
115
+ else
116
+ return {
117
+ response: "There are no rows related to: " + phrase,
118
+ };
119
+ },
120
+ /*renderToolCall({ phrase }, { req }) {
121
+ return div({ class: "border border-primary p-2 m-2" }, phrase);
122
+ },*/
123
+ renderToolResponse: async ({ response, rows }, { req }) => {
124
+ if (rows) {
125
+ const view = View.findOne({ name: this.list_view });
126
+
127
+ if (view) {
128
+ const viewRes = await view.run(
129
+ { [table.pk_name]: { in: rows.map((r) => r[table.pk_name]) } },
130
+ { req }
131
+ );
132
+ return viewRes;
133
+ } else return "";
134
+ }
135
+ return div({ class: "border border-success p-2 m-2" }, response);
136
+ },
137
+ function: {
138
+ name: this.toolName,
139
+ description: `Search the ${this.table_name} database table${
140
+ table.description ? ` (${table.description})` : ""
141
+ } by a search phrase matched against all fields in the table with full text search. The retrieved rows will be returned`,
142
+ parameters: {
143
+ type: "object",
144
+ required: ["phrase"],
145
+ properties: {
146
+ phrase: {
147
+ type: "string",
148
+ description: "The phrase to search the table with",
149
+ },
150
+ },
151
+ },
152
+ },
153
+ };
154
+ }
155
+ }
156
+
157
+ module.exports = RetrievalByFullTextSearch;