loopengine 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/durable-approvals.d.ts +106 -0
- package/dist/core/durable-approvals.js +158 -0
- package/dist/core/gateway-tools.js +10 -9
- package/dist/index.d.ts +26 -1
- package/dist/index.js +27 -1
- package/dist/web/actauth-admin.d.ts +36 -0
- package/dist/web/actauth-admin.js +108 -0
- package/dist/web/agent-file-admin.d.ts +30 -0
- package/dist/web/agent-file-admin.js +165 -0
- package/dist/web/agents-config-page.d.ts +1 -0
- package/dist/web/agents-config-page.js +1807 -0
- package/dist/web/agents-list-page.d.ts +1 -0
- package/dist/web/agents-list-page.js +246 -0
- package/dist/web/dev-ui-styles.d.ts +1 -0
- package/dist/web/dev-ui-styles.js +63 -0
- package/dist/web/global-config-page.d.ts +1 -0
- package/dist/web/global-config-page.js +303 -0
- package/dist/web/global-config.d.ts +57 -0
- package/dist/web/global-config.js +67 -0
- package/dist/web/http-tool-admin.d.ts +37 -0
- package/dist/web/http-tool-admin.js +223 -0
- package/dist/web/playground.d.ts +1 -0
- package/dist/web/playground.js +1271 -0
- package/dist/web/skills-admin.d.ts +23 -0
- package/dist/web/skills-admin.js +67 -0
- package/dist/web/web-approver.d.ts +35 -0
- package/dist/web/web-approver.js +88 -0
- package/package.json +5 -2
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare class SkillInvalidIdError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export declare class SkillNotFoundError extends Error {
|
|
4
|
+
}
|
|
5
|
+
export interface SkillContent {
|
|
6
|
+
id: string;
|
|
7
|
+
description: string;
|
|
8
|
+
body: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function readSkill(agentName: string, skillId: string): SkillContent;
|
|
11
|
+
/** Creates the skill if `skillId` doesn't exist yet, or overwrites it in
|
|
12
|
+
* place if it does — same "this is the new content, not a diff to
|
|
13
|
+
* merge" semantics as a PUT. Frontmatter is regenerated from `skillId`
|
|
14
|
+
* (as `name`) and `description`; any *other* frontmatter keys a skill
|
|
15
|
+
* might have (e.g. `paths` — see SkillFrontmatter's own doc comment) are
|
|
16
|
+
* intentionally not preserved here, since this admin UI has no field for
|
|
17
|
+
* them. A skill that needs those is still editable by hand — this just
|
|
18
|
+
* isn't the tool for it. */
|
|
19
|
+
export declare function writeSkill(agentName: string, skillId: string, content: {
|
|
20
|
+
description: string;
|
|
21
|
+
body: string;
|
|
22
|
+
}): void;
|
|
23
|
+
export declare function deleteSkill(agentName: string, skillId: string): void;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Lets an operator add, edit, or remove a skill from the Skills tab in
|
|
2
|
+
// /agents/config, instead of hand-writing agents/<name>/skills/<id>/
|
|
3
|
+
// SKILL.md directly. Deliberately scoped to *flat* skills only — a
|
|
4
|
+
// nested skill (e.g. agents/<name>/skills/deploy/web/SKILL.md, which
|
|
5
|
+
// SkillGarden's own discoverSkillFiles namespaces as 'deploy:web') isn't
|
|
6
|
+
// addressable through this admin surface; its id would need to carry a
|
|
7
|
+
// '/' through a single URL path segment, which either means encoding
|
|
8
|
+
// tricks or a second routing scheme, for a nesting feature nothing in
|
|
9
|
+
// this repo's own example agents actually uses. Read-only display of a
|
|
10
|
+
// nested skill (via describeAgent's own skills list) still works fine —
|
|
11
|
+
// only adding/editing/removing one through this UI is out of scope.
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { parseSkillFile } from 'skillgarden';
|
|
15
|
+
import { agentDir } from '../core/gateway-tools.js';
|
|
16
|
+
export class SkillInvalidIdError extends Error {
|
|
17
|
+
}
|
|
18
|
+
export class SkillNotFoundError extends Error {
|
|
19
|
+
}
|
|
20
|
+
// Same character set agent/subagent names are already validated against
|
|
21
|
+
// elsewhere in this repo (bin/cli.ts's NAME_PATTERN) — a skill id becomes a
|
|
22
|
+
// directory name on disk, so this is what keeps it from ever containing
|
|
23
|
+
// '/', '..', or anything else that could resolve outside its own
|
|
24
|
+
// skills/ folder.
|
|
25
|
+
const SKILL_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
26
|
+
function requireValidSkillId(skillId) {
|
|
27
|
+
if (!SKILL_ID_PATTERN.test(skillId)) {
|
|
28
|
+
throw new SkillInvalidIdError(`Invalid skill id '${skillId}' — must be lowercase, alphanumeric, hyphen-separated (e.g. "summarize-files"); nested skills aren't editable through this admin UI.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function skillsDir(agentName) {
|
|
32
|
+
return join(agentDir(agentName), 'skills');
|
|
33
|
+
}
|
|
34
|
+
function skillFilePath(agentName, skillId) {
|
|
35
|
+
requireValidSkillId(skillId);
|
|
36
|
+
return join(skillsDir(agentName), skillId, 'SKILL.md');
|
|
37
|
+
}
|
|
38
|
+
export function readSkill(agentName, skillId) {
|
|
39
|
+
const path = skillFilePath(agentName, skillId);
|
|
40
|
+
if (!existsSync(path)) {
|
|
41
|
+
throw new SkillNotFoundError(`No skill '${skillId}' for '${agentName}'.`);
|
|
42
|
+
}
|
|
43
|
+
const { frontmatter, body } = parseSkillFile(readFileSync(path, 'utf8'), skillId);
|
|
44
|
+
return { id: skillId, description: frontmatter.description ?? '', body };
|
|
45
|
+
}
|
|
46
|
+
/** Creates the skill if `skillId` doesn't exist yet, or overwrites it in
|
|
47
|
+
* place if it does — same "this is the new content, not a diff to
|
|
48
|
+
* merge" semantics as a PUT. Frontmatter is regenerated from `skillId`
|
|
49
|
+
* (as `name`) and `description`; any *other* frontmatter keys a skill
|
|
50
|
+
* might have (e.g. `paths` — see SkillFrontmatter's own doc comment) are
|
|
51
|
+
* intentionally not preserved here, since this admin UI has no field for
|
|
52
|
+
* them. A skill that needs those is still editable by hand — this just
|
|
53
|
+
* isn't the tool for it. */
|
|
54
|
+
export function writeSkill(agentName, skillId, content) {
|
|
55
|
+
const path = skillFilePath(agentName, skillId);
|
|
56
|
+
const description = content.description.replace(/\n/g, ' ').trim();
|
|
57
|
+
const frontmatter = `---\nname: ${skillId}\ndescription: ${JSON.stringify(description)}\n---\n\n`;
|
|
58
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
59
|
+
writeFileSync(path, frontmatter + content.body.trim() + '\n');
|
|
60
|
+
}
|
|
61
|
+
export function deleteSkill(agentName, skillId) {
|
|
62
|
+
const path = skillFilePath(agentName, skillId);
|
|
63
|
+
if (!existsSync(path)) {
|
|
64
|
+
throw new SkillNotFoundError(`No skill '${skillId}' for '${agentName}'.`);
|
|
65
|
+
}
|
|
66
|
+
rmSync(dirname(path), { recursive: true, force: true });
|
|
67
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { WebchatApprover, type PendingApproval } from 'actauth';
|
|
2
|
+
/** Every WebchatApprover a request might end up using should be created
|
|
3
|
+
* through this, not `new WebchatApprover()` directly — the id it hands out
|
|
4
|
+
* on each new pending approval is otherwise unreachable from
|
|
5
|
+
* decideApproval()/listApprovals() below. Both adapters/http.ts routes
|
|
6
|
+
* call this once per turn, with that turn's own sessionId: the streaming
|
|
7
|
+
* route wires onPending to an SSE event on that exact response so the
|
|
8
|
+
* approval popup appears inline in the conversation that's actually
|
|
9
|
+
* blocked on it; the plain route wires it to resolve the early-return
|
|
10
|
+
* race handleMessages uses instead of blocking indefinitely (see its own
|
|
11
|
+
* doc comment) — same reason, no HTTP request should hang on a human
|
|
12
|
+
* with nothing telling the caller that's what's happening. */
|
|
13
|
+
export declare function createTrackedApprover(sessionId: string, onPending?: (approval: PendingApproval) => void): WebchatApprover;
|
|
14
|
+
/** Every currently-pending approval, from any tracked instance. Unfiltered
|
|
15
|
+
* (both omitted) returns every approval pending anywhere in this process,
|
|
16
|
+
* across every agent, tenant, and session — real uses (an operator's own
|
|
17
|
+
* admin view, say) should filter; both agent and session scoping are
|
|
18
|
+
* exact (agent from actauth's own Scope, already on every PendingApproval;
|
|
19
|
+
* session from sessionByApprover, always known — see its own doc
|
|
20
|
+
* comment). */
|
|
21
|
+
export declare function listApprovals(filter?: {
|
|
22
|
+
agent?: string;
|
|
23
|
+
sessionId?: string;
|
|
24
|
+
}): PendingApproval[];
|
|
25
|
+
/** Routes to whichever WebchatApprover instance actually owns `id` — the
|
|
26
|
+
* caller doesn't need to know which turn/session created it. */
|
|
27
|
+
export declare function decideApproval(id: string, approved: boolean): boolean;
|
|
28
|
+
/** Peek at a pending approval's own entry and owning session, without
|
|
29
|
+
* deciding it — adapters/http.ts needs this *before* calling
|
|
30
|
+
* decideApproval() above, to know which turn's early-return race (see its
|
|
31
|
+
* own sessionTurns) to resume once this one's decided. */
|
|
32
|
+
export declare function findApproval(id: string): {
|
|
33
|
+
approval: PendingApproval;
|
|
34
|
+
sessionId: string;
|
|
35
|
+
} | undefined;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// adapters/http.ts's wiring around actauth's WebchatApprover (see its own
|
|
2
|
+
// approvers.ts) — one process can have several WebchatApprover instances
|
|
3
|
+
// live at once (see createTrackedApprover's own doc comment for why),
|
|
4
|
+
// so POST /approvals/:id/approve|deny needs to know which instance
|
|
5
|
+
// actually owns a given pending id before it can call decide() on it.
|
|
6
|
+
// That routing table is what this file owns; the approver class itself
|
|
7
|
+
// stays in actauth, generic and framework-agnostic.
|
|
8
|
+
import { WebchatApprover } from 'actauth';
|
|
9
|
+
const approversById = new Map();
|
|
10
|
+
// Which session each *instance* is serving — not each pending approval,
|
|
11
|
+
// because a single WebchatApprover instance only ever needs to serve one
|
|
12
|
+
// session's worth of calls: both adapters/http.ts routes now create a
|
|
13
|
+
// fresh tracked approver per call (see createTrackedApprover's own doc
|
|
14
|
+
// comment), so this is always known, not best-effort. A WeakMap, not a
|
|
15
|
+
// Map, so an old instance (nothing else references it once its own
|
|
16
|
+
// approvals are gone) doesn't get held alive forever just for this
|
|
17
|
+
// bookkeeping.
|
|
18
|
+
const sessionByApprover = new WeakMap();
|
|
19
|
+
/** Every WebchatApprover a request might end up using should be created
|
|
20
|
+
* through this, not `new WebchatApprover()` directly — the id it hands out
|
|
21
|
+
* on each new pending approval is otherwise unreachable from
|
|
22
|
+
* decideApproval()/listApprovals() below. Both adapters/http.ts routes
|
|
23
|
+
* call this once per turn, with that turn's own sessionId: the streaming
|
|
24
|
+
* route wires onPending to an SSE event on that exact response so the
|
|
25
|
+
* approval popup appears inline in the conversation that's actually
|
|
26
|
+
* blocked on it; the plain route wires it to resolve the early-return
|
|
27
|
+
* race handleMessages uses instead of blocking indefinitely (see its own
|
|
28
|
+
* doc comment) — same reason, no HTTP request should hang on a human
|
|
29
|
+
* with nothing telling the caller that's what's happening. */
|
|
30
|
+
export function createTrackedApprover(sessionId, onPending) {
|
|
31
|
+
let approver;
|
|
32
|
+
approver = new WebchatApprover({
|
|
33
|
+
onPending: (approval) => {
|
|
34
|
+
approversById.set(approval.id, approver);
|
|
35
|
+
onPending?.(approval);
|
|
36
|
+
},
|
|
37
|
+
onSettled: (id) => {
|
|
38
|
+
approversById.delete(id);
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
sessionByApprover.set(approver, sessionId);
|
|
42
|
+
return approver;
|
|
43
|
+
}
|
|
44
|
+
/** Every currently-pending approval, from any tracked instance. Unfiltered
|
|
45
|
+
* (both omitted) returns every approval pending anywhere in this process,
|
|
46
|
+
* across every agent, tenant, and session — real uses (an operator's own
|
|
47
|
+
* admin view, say) should filter; both agent and session scoping are
|
|
48
|
+
* exact (agent from actauth's own Scope, already on every PendingApproval;
|
|
49
|
+
* session from sessionByApprover, always known — see its own doc
|
|
50
|
+
* comment). */
|
|
51
|
+
export function listApprovals(filter) {
|
|
52
|
+
const results = [];
|
|
53
|
+
for (const approver of new Set(approversById.values())) {
|
|
54
|
+
const sessionId = sessionByApprover.get(approver);
|
|
55
|
+
for (const approval of approver.list()) {
|
|
56
|
+
if (filter?.agent && approval.scope.agent !== filter.agent)
|
|
57
|
+
continue;
|
|
58
|
+
if (filter?.sessionId && sessionId !== filter.sessionId)
|
|
59
|
+
continue;
|
|
60
|
+
results.push(approval);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return results;
|
|
64
|
+
}
|
|
65
|
+
/** Routes to whichever WebchatApprover instance actually owns `id` — the
|
|
66
|
+
* caller doesn't need to know which turn/session created it. */
|
|
67
|
+
export function decideApproval(id, approved) {
|
|
68
|
+
const approver = approversById.get(id);
|
|
69
|
+
if (!approver)
|
|
70
|
+
return false;
|
|
71
|
+
return approver.decide(id, approved);
|
|
72
|
+
}
|
|
73
|
+
/** Peek at a pending approval's own entry and owning session, without
|
|
74
|
+
* deciding it — adapters/http.ts needs this *before* calling
|
|
75
|
+
* decideApproval() above, to know which turn's early-return race (see its
|
|
76
|
+
* own sessionTurns) to resume once this one's decided. */
|
|
77
|
+
export function findApproval(id) {
|
|
78
|
+
const approver = approversById.get(id);
|
|
79
|
+
if (!approver)
|
|
80
|
+
return undefined;
|
|
81
|
+
const approval = approver.list().find((a) => a.id === id);
|
|
82
|
+
if (!approval)
|
|
83
|
+
return undefined;
|
|
84
|
+
const sessionId = sessionByApprover.get(approver);
|
|
85
|
+
if (sessionId === undefined)
|
|
86
|
+
return undefined;
|
|
87
|
+
return { approval, sessionId };
|
|
88
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loopengine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -58,6 +58,9 @@
|
|
|
58
58
|
"dist/core/http-notify-triggers",
|
|
59
59
|
"dist/core/system-tools",
|
|
60
60
|
"dist/core/system-skills",
|
|
61
|
+
"dist/core/durable-approvals.js",
|
|
62
|
+
"dist/core/durable-approvals.d.ts",
|
|
63
|
+
"dist/web",
|
|
61
64
|
"protocol/loop-event.schema.json",
|
|
62
65
|
"PROTOCOL.md",
|
|
63
66
|
"LICENSE"
|
|
@@ -77,12 +80,12 @@
|
|
|
77
80
|
"sessionknit": "^0.0.2",
|
|
78
81
|
"skillgarden": "^0.0.5",
|
|
79
82
|
"toollane": "^0.0.1",
|
|
83
|
+
"typescript": "^5.7.0",
|
|
80
84
|
"yaml": "^2.6.0"
|
|
81
85
|
},
|
|
82
86
|
"devDependencies": {
|
|
83
87
|
"@types/node": "^26.2.0",
|
|
84
88
|
"tsx": "^4.19.0",
|
|
85
|
-
"typescript": "^5.7.0",
|
|
86
89
|
"vitest": "^2.1.9"
|
|
87
90
|
}
|
|
88
91
|
}
|