loopengine 0.0.21 → 0.1.3

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.
Files changed (38) hide show
  1. package/PROTOCOL.md +3 -3
  2. package/README.md +45 -10
  3. package/dist/core/agent-config.d.ts +291 -8
  4. package/dist/core/budget.d.ts +42 -0
  5. package/dist/core/budget.js +39 -0
  6. package/dist/core/compaction.d.ts +43 -0
  7. package/dist/core/compaction.js +70 -0
  8. package/dist/core/http-notifier.d.ts +10 -0
  9. package/dist/core/http-notifier.js +116 -0
  10. package/dist/core/http-notify-triggers/database.d.ts +19 -0
  11. package/dist/core/http-notify-triggers/database.js +53 -0
  12. package/dist/core/http-notify-triggers/email.d.ts +44 -0
  13. package/dist/core/http-notify-triggers/email.js +126 -0
  14. package/dist/core/http-notify-triggers/lark.d.ts +28 -0
  15. package/dist/core/http-notify-triggers/lark.js +168 -0
  16. package/dist/core/http-notify-triggers/redis.d.ts +33 -0
  17. package/dist/core/http-notify-triggers/redis.js +54 -0
  18. package/dist/core/http-notify-triggers/slack.d.ts +21 -0
  19. package/dist/core/http-notify-triggers/slack.js +91 -0
  20. package/dist/core/http-notify-triggers/webhook.d.ts +27 -0
  21. package/dist/core/http-notify-triggers/webhook.js +94 -0
  22. package/dist/core/loop-events.d.ts +30 -6
  23. package/dist/core/model-calls/openai-model-call.js +32 -16
  24. package/dist/core/recovery.d.ts +49 -0
  25. package/dist/core/recovery.js +124 -0
  26. package/dist/core/run-agent.d.ts +141 -13
  27. package/dist/core/run-agent.js +316 -57
  28. package/dist/core/session-store.d.ts +1 -0
  29. package/dist/core/session-store.js +6 -4
  30. package/dist/core/system-skills/system-skills/composio-large-outputs/SKILL.md +25 -0
  31. package/dist/core/system-tools/ask_user.d.ts +97 -31
  32. package/dist/core/system-tools/ask_user.js +157 -58
  33. package/dist/core/system-tools/index.d.ts +1 -1
  34. package/dist/core/system-tools/index.js +1 -1
  35. package/dist/package.json +6 -0
  36. package/package.json +14 -4
  37. package/protocol/loop-event.schema.json +2 -2
  38. /package/{core → dist/core}/system-skills/composio-large-outputs/SKILL.md +0 -0
@@ -1,20 +1,75 @@
1
- import type { ToolDefinition } from '../agent-config.js';
2
- export interface PendingQuestion {
3
- id: string;
4
- question: string;
5
- /** Suggested answers, if the model gave any — never the only allowed
6
- * answer, a human can still type free text either way (see
7
- * answerQuestion, which takes any string). */
8
- options?: string[];
9
- /** Which agent raised this — always known (config.name), so listing can
10
- * always at least be scoped per-agent even without a session id. */
11
- agent: string;
12
- /** Which conversation raised this, if the caller has one (see
13
- * RunAgentOptions.sessionId's own doc comment for when it doesn't) —
14
- * lets listQuestions scope down to exactly one conversation instead of
15
- * every question raised by this agent, or this whole process. */
16
- sessionId?: string;
17
- requestedAt: string;
1
+ import type { DurableQuestionHandler, LiveQuestionHandler, PendingQuestion, QuestionHandler, ToolDefinition } from '../agent-config.js';
2
+ export type { PendingQuestion } from '../agent-config.js';
3
+ /** The live-side sibling of the webhook-backed `DurableQuestionHandler`
4
+ * (`core/http-notify-triggers/webhook.ts`'s `WebhookNotifier`) — same
5
+ * relationship actauth's own `WebchatApprover` has to `WebhookApprover`:
6
+ * this one holds the actual `Promise` open and resolves it directly, no
7
+ * webhook, no `pendingId` handed back to a durable resolve route. Named
8
+ * to match `WebchatApprover`'s own shape (a `pending` map, `requestX()`,
9
+ * `list()`, `decide()`) even though the underlying registration/timeout
10
+ * logic already existed here before this class did — this just gives it
11
+ * an instantiable, testable home instead of leaving it as bare
12
+ * module-level state.
13
+ *
14
+ * One real, deliberate difference from `WebchatApprover`: `onPending` is
15
+ * taken per-call (an argument to `requestQuestion`), not baked in at
16
+ * construction. `WebchatApprover` gets a *fresh instance per turn* precisely
17
+ * so each one's constructor-time `onPending` can target that turn's own
18
+ * SSE connection (see web/web-approver.ts's createTrackedApprover) — but
19
+ * a question only ever needs *one* registry, unlike approvals (see the
20
+ * default instance below): `onPending` here is just an optional extra
21
+ * notification layered on top of registration, not a routing key, so
22
+ * every question is answerable via `decide()`/`list()` on this same
23
+ * instance regardless of whether anything was listening for `onPending`
24
+ * when it was raised. A caller that genuinely wants per-turn isolation
25
+ * (its own registry, not sharing the default one) can still construct
26
+ * its own `WebchatQuestionHandler` instance directly. */
27
+ export declare class WebchatQuestionHandler implements LiveQuestionHandler {
28
+ private readonly pending;
29
+ private readonly timeoutMs;
30
+ constructor(options?: {
31
+ timeoutMs?: number;
32
+ });
33
+ /** Registers a pending question and returns a Promise that resolves
34
+ * once `decide()` is called with its id (or the timeout sentinel, if
35
+ * nobody does). `onPending`, when given, fires synchronously with the
36
+ * full entry — see this class's own doc comment for why that's a
37
+ * per-call argument, not a constructor option. */
38
+ requestQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined, onPending?: (question: PendingQuestion) => void): Promise<string>;
39
+ /** Oldest first, same ordering reasoning web/web-approver.ts's
40
+ * listApprovals uses. Unfiltered (both omitted) returns every question
41
+ * pending on this instance — real uses (an operator's own admin view,
42
+ * say) should filter, since that's every conversation's questions
43
+ * mixed together. */
44
+ list(filter?: {
45
+ agent?: string;
46
+ sessionId?: string;
47
+ }): PendingQuestion[];
48
+ /** Peek at a pending question's own entry without answering it — same
49
+ * "look before you decide" reasoning web/web-approver.ts's findApproval
50
+ * exists for. */
51
+ find(id: string): PendingQuestion | undefined;
52
+ /** Returns false for an unknown/already-answered/timed-out id, so the
53
+ * caller can report that distinctly (a 404) instead of silently
54
+ * no-opping — same convention web/web-approver.ts's decideApproval
55
+ * uses. */
56
+ decide(id: string, answer: string): boolean;
57
+ }
58
+ /** The cli-channel default — blocks on the terminal it's already attached
59
+ * to, same fallback actauth's own `ConsoleApprover` is for a permission
60
+ * ask with nowhere else to go. Unlike `WebchatQuestionHandler`, this needs
61
+ * no `pending` map, no `list()`/`decide()`: nothing outside this one call
62
+ * ever answers it — the terminal that asked is the terminal that
63
+ * answers, synchronously, in the same `rl.question()`. `agent`/
64
+ * `sessionId`/`onPending` are accepted (not just `question`/`options`)
65
+ * purely so this has the same call shape `WebchatQuestionHandler.requestQuestion`
66
+ * does — a caller (createAskUserTool below) doesn't need to know which
67
+ * kind of questionHandler it's holding, it just calls `requestQuestion` either
68
+ * way — but none of the three do anything here: there's no registry to
69
+ * tag with `agent`/`sessionId`, and no separate channel to push
70
+ * `onPending` onto besides the prompt itself. */
71
+ export declare class CliQuestionHandler implements LiveQuestionHandler {
72
+ requestQuestion(question: string, options: string[] | undefined): Promise<string>;
18
73
  }
19
74
  /** Builds the ask_user ToolDefinition for one runAgent() call.
20
75
  *
@@ -28,14 +83,21 @@ export interface PendingQuestion {
28
83
  * onEvent forwards straight onto that turn's SSE connection — see
29
84
  * playground.ts's own inline card for it) finds out immediately instead
30
85
  * of having to poll listQuestions(). It's a notification only, not a
31
- * routing key: the question is registered in the same shared
32
- * questionsById either way, so answerQuestion() works regardless of
33
- * whether anything was listening for onPending in the first place.
86
+ * routing key: the question is registered on the same shared
87
+ * WebchatQuestionHandler instance either way, so answerQuestion() works
88
+ * regardless of whether anything was listening for onPending in the
89
+ * first place.
34
90
  *
35
91
  * Omitted entirely (a plain CLI run, or any caller with no live channel
36
- * of its own) falls back to a blocking terminal prompt instead of a
37
- * pending entry nothing could ever answer — same reasoning
38
- * ConsoleApprover exists for permission asks with nowhere else to go.
92
+ * of its own) falls back to CliQuestionHandler's blocking terminal
93
+ * prompt instead of a pending entry nothing could ever answer — same
94
+ * reasoning ConsoleApprover exists for permission asks with nowhere else
95
+ * to go. adapters/cli.ts passing `channel: 'cli'` but no `onQuestionPending`
96
+ * is exactly this case — the cli channel's real default, in other words,
97
+ * even though nothing threads `options.channel` through to this
98
+ * function directly (it doesn't need to: the *presence* of `onPending`
99
+ * already is that signal, one level up in run-agent.ts's own
100
+ * RunAgentOptions.onQuestionPending doc comment).
39
101
  *
40
102
  * Named `system_ask_user`, not the bare `ask_user` an agent author would
41
103
  * naturally reach for on their own — see system-tools/read_file.ts's own
@@ -52,11 +114,9 @@ export declare function createAskUserTool(context: {
52
114
  agent: string;
53
115
  sessionId?: string;
54
116
  }, onPending?: (question: PendingQuestion) => void): ToolDefinition;
55
- /** Oldest first, same ordering reasoning web/web-approver.ts's listApprovals
56
- * uses — whichever question has been waiting longest surfaces first.
57
- * Unfiltered (both omitted) returns every question pending anywhere in
58
- * this process — real uses (an operator's own admin view, say) should
59
- * filter, since that's every conversation's questions mixed together. */
117
+ /** Thin wrapper over the default WebchatQuestionHandler's own list() — see
118
+ * that class's own doc comment for why one shared instance, not a fresh
119
+ * one per call, is the right default for questions. */
60
120
  export declare function listQuestions(filter?: {
61
121
  agent?: string;
62
122
  sessionId?: string;
@@ -66,7 +126,13 @@ export declare function listQuestions(filter?: {
66
126
  * calling answerQuestion() below, to know which turn's early-return race
67
127
  * (see its own sessionTurns) to resume once this one's answered. */
68
128
  export declare function findQuestion(id: string): PendingQuestion | undefined;
69
- /** Returns false for an unknown/already-answered/timed-out id, so the
70
- * caller can report that distinctly (a 404) instead of silently
71
- * no-opping — same convention web/web-approver.ts's decideApproval uses. */
129
+ /** Thin wrapper over the default WebchatQuestionHandler's own decide(). */
72
130
  export declare function answerQuestion(id: string, answer: string): boolean;
131
+ /** Duck-types on the one method DurableQuestionHandler actually needs —
132
+ * same "no eval, just an explicit structural check" spirit as actauth's
133
+ * own isDurableApprover, which this mirrors: run-agent.ts's loop calls
134
+ * this on the resolved QuestionHandler (see RunAgentOptions.questionHandler's
135
+ * own doc comment) to decide which branch a system_ask_user call takes,
136
+ * the same way Gate uses isDurableApprover to decide which branch a
137
+ * gated tool call takes. */
138
+ export declare function isDurableQuestionHandler(questionHandler: QuestionHandler): questionHandler is DurableQuestionHandler;
@@ -1,7 +1,7 @@
1
1
  // The system ask_user tool (see this folder's own index.ts, and
2
2
  // run-agent.ts's tools merge) — lets a model pause mid-turn and ask the
3
3
  // human operator a genuinely ambiguous question instead of guessing, the
4
- // same reasoning WebApprover exists for permission 'ask' decisions
4
+ // same reasoning WebchatApprover exists for permission 'ask' decisions
5
5
  // (web/web-approver.ts), just generalized from a fixed allow/deny to an open
6
6
  // answer. No companion skill the way read_file has
7
7
  // system-skills/composio-large-outputs — that skill exists to bridge an
@@ -18,30 +18,127 @@
18
18
  // same reasoning applied to approvals.
19
19
  import { randomUUID } from 'node:crypto';
20
20
  import { createInterface } from 'node:readline/promises';
21
- // One shared, global registry — unlike WebApprover's pending approvals
22
- // (split across a shared instance and a fresh one per streamed turn, so
23
- // each turn's onPending can target its own SSE connection), a question
24
- // only ever needs *one* place to live: onPending here is just an optional
25
- // extra notification layered on top, not a routing key, so every
26
- // question — from any call, streamed or not — is always answerable via
27
- // answerQuestion()/listQuestions() below regardless of how it was raised.
28
- const questionsById = new Map();
29
21
  const DEFAULT_TIMEOUT_MS = 5 * 60_000;
30
- /** Blocking terminal prompt — the same fallback actauth's own
31
- * ConsoleApprover uses for a permission ask with nowhere else to go
32
- * (see createAskUserTool below for when this applies). */
33
- async function promptOnConsole(question, options) {
34
- console.log(`\n[ask_user] ${question}`);
35
- if (options?.length)
36
- console.log(` options: ${options.join(', ')}`);
37
- const rl = createInterface({ input: process.stdin, output: process.stdout });
38
- try {
39
- return (await rl.question('> ')).trim();
22
+ /** The live-side sibling of the webhook-backed `DurableQuestionHandler`
23
+ * (`core/http-notify-triggers/webhook.ts`'s `WebhookNotifier`) — same
24
+ * relationship actauth's own `WebchatApprover` has to `WebhookApprover`:
25
+ * this one holds the actual `Promise` open and resolves it directly, no
26
+ * webhook, no `pendingId` handed back to a durable resolve route. Named
27
+ * to match `WebchatApprover`'s own shape (a `pending` map, `requestX()`,
28
+ * `list()`, `decide()`) even though the underlying registration/timeout
29
+ * logic already existed here before this class did — this just gives it
30
+ * an instantiable, testable home instead of leaving it as bare
31
+ * module-level state.
32
+ *
33
+ * One real, deliberate difference from `WebchatApprover`: `onPending` is
34
+ * taken per-call (an argument to `requestQuestion`), not baked in at
35
+ * construction. `WebchatApprover` gets a *fresh instance per turn* precisely
36
+ * so each one's constructor-time `onPending` can target that turn's own
37
+ * SSE connection (see web/web-approver.ts's createTrackedApprover) — but
38
+ * a question only ever needs *one* registry, unlike approvals (see the
39
+ * default instance below): `onPending` here is just an optional extra
40
+ * notification layered on top of registration, not a routing key, so
41
+ * every question is answerable via `decide()`/`list()` on this same
42
+ * instance regardless of whether anything was listening for `onPending`
43
+ * when it was raised. A caller that genuinely wants per-turn isolation
44
+ * (its own registry, not sharing the default one) can still construct
45
+ * its own `WebchatQuestionHandler` instance directly. */
46
+ export class WebchatQuestionHandler {
47
+ pending = new Map();
48
+ timeoutMs;
49
+ constructor(options = {}) {
50
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
51
+ }
52
+ /** Registers a pending question and returns a Promise that resolves
53
+ * once `decide()` is called with its id (or the timeout sentinel, if
54
+ * nobody does). `onPending`, when given, fires synchronously with the
55
+ * full entry — see this class's own doc comment for why that's a
56
+ * per-call argument, not a constructor option. */
57
+ requestQuestion(question, options, agent, sessionId, onPending) {
58
+ const id = randomUUID();
59
+ const entry = { id, question, options, agent, sessionId, requestedAt: new Date().toISOString() };
60
+ return new Promise((resolve) => {
61
+ const timer = setTimeout(() => {
62
+ this.pending.delete(id);
63
+ resolve('(no answer — timed out)');
64
+ }, this.timeoutMs);
65
+ this.pending.set(id, { entry, resolve, timer });
66
+ onPending?.(entry);
67
+ });
68
+ }
69
+ /** Oldest first, same ordering reasoning web/web-approver.ts's
70
+ * listApprovals uses. Unfiltered (both omitted) returns every question
71
+ * pending on this instance — real uses (an operator's own admin view,
72
+ * say) should filter, since that's every conversation's questions
73
+ * mixed together. */
74
+ list(filter) {
75
+ return [...this.pending.values()]
76
+ .map((p) => p.entry)
77
+ .filter((q) => (!filter?.agent || q.agent === filter.agent) && (!filter?.sessionId || q.sessionId === filter.sessionId))
78
+ .sort((a, b) => a.requestedAt.localeCompare(b.requestedAt));
79
+ }
80
+ /** Peek at a pending question's own entry without answering it — same
81
+ * "look before you decide" reasoning web/web-approver.ts's findApproval
82
+ * exists for. */
83
+ find(id) {
84
+ return this.pending.get(id)?.entry;
40
85
  }
41
- finally {
42
- rl.close();
86
+ /** Returns false for an unknown/already-answered/timed-out id, so the
87
+ * caller can report that distinctly (a 404) instead of silently
88
+ * no-opping — same convention web/web-approver.ts's decideApproval
89
+ * uses. */
90
+ decide(id, answer) {
91
+ const entry = this.pending.get(id);
92
+ if (!entry)
93
+ return false;
94
+ clearTimeout(entry.timer);
95
+ this.pending.delete(id);
96
+ entry.resolve(answer);
97
+ return true;
43
98
  }
44
99
  }
100
+ // One shared, global instance — unlike WebchatApprover's pending approvals
101
+ // (split across a shared instance and a fresh one per streamed turn),
102
+ // createAskUserTool below always registers into this same one regardless
103
+ // of which call raised it, so every question is always answerable via
104
+ // the module-level listQuestions()/answerQuestion()/findQuestion()
105
+ // functions below, whether it came from a streamed turn, the plain
106
+ // route's fallback, or anywhere else — see WebchatQuestionHandler's own doc
107
+ // comment for the full reasoning.
108
+ const defaultQuestionHandler = new WebchatQuestionHandler();
109
+ /** The cli-channel default — blocks on the terminal it's already attached
110
+ * to, same fallback actauth's own `ConsoleApprover` is for a permission
111
+ * ask with nowhere else to go. Unlike `WebchatQuestionHandler`, this needs
112
+ * no `pending` map, no `list()`/`decide()`: nothing outside this one call
113
+ * ever answers it — the terminal that asked is the terminal that
114
+ * answers, synchronously, in the same `rl.question()`. `agent`/
115
+ * `sessionId`/`onPending` are accepted (not just `question`/`options`)
116
+ * purely so this has the same call shape `WebchatQuestionHandler.requestQuestion`
117
+ * does — a caller (createAskUserTool below) doesn't need to know which
118
+ * kind of questionHandler it's holding, it just calls `requestQuestion` either
119
+ * way — but none of the three do anything here: there's no registry to
120
+ * tag with `agent`/`sessionId`, and no separate channel to push
121
+ * `onPending` onto besides the prompt itself. */
122
+ export class CliQuestionHandler {
123
+ async requestQuestion(question, options) {
124
+ console.log(`\n[ask_user] ${question}`);
125
+ if (options?.length)
126
+ console.log(` options: ${options.join(', ')}`);
127
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
128
+ try {
129
+ return (await rl.question('> ')).trim();
130
+ }
131
+ finally {
132
+ rl.close();
133
+ }
134
+ }
135
+ }
136
+ // The two live-channel defaults — cli gets a blocking prompt, http/
137
+ // http_stream share one live registry — both single, shared instances
138
+ // for the same reason defaultQuestionHandler above is: neither needs
139
+ // per-turn isolation the way WebchatApprover's fresh-instance-per-turn does
140
+ // (see WebchatQuestionHandler's own doc comment).
141
+ const defaultCliQuestionHandler = new CliQuestionHandler();
45
142
  /** Builds the ask_user ToolDefinition for one runAgent() call.
46
143
  *
47
144
  * context identifies *whose* question this is (see PendingQuestion's own
@@ -54,14 +151,21 @@ async function promptOnConsole(question, options) {
54
151
  * onEvent forwards straight onto that turn's SSE connection — see
55
152
  * playground.ts's own inline card for it) finds out immediately instead
56
153
  * of having to poll listQuestions(). It's a notification only, not a
57
- * routing key: the question is registered in the same shared
58
- * questionsById either way, so answerQuestion() works regardless of
59
- * whether anything was listening for onPending in the first place.
154
+ * routing key: the question is registered on the same shared
155
+ * WebchatQuestionHandler instance either way, so answerQuestion() works
156
+ * regardless of whether anything was listening for onPending in the
157
+ * first place.
60
158
  *
61
159
  * Omitted entirely (a plain CLI run, or any caller with no live channel
62
- * of its own) falls back to a blocking terminal prompt instead of a
63
- * pending entry nothing could ever answer — same reasoning
64
- * ConsoleApprover exists for permission asks with nowhere else to go.
160
+ * of its own) falls back to CliQuestionHandler's blocking terminal
161
+ * prompt instead of a pending entry nothing could ever answer — same
162
+ * reasoning ConsoleApprover exists for permission asks with nowhere else
163
+ * to go. adapters/cli.ts passing `channel: 'cli'` but no `onQuestionPending`
164
+ * is exactly this case — the cli channel's real default, in other words,
165
+ * even though nothing threads `options.channel` through to this
166
+ * function directly (it doesn't need to: the *presence* of `onPending`
167
+ * already is that signal, one level up in run-agent.ts's own
168
+ * RunAgentOptions.onQuestionPending doc comment).
65
169
  *
66
170
  * Named `system_ask_user`, not the bare `ask_user` an agent author would
67
171
  * naturally reach for on their own — see system-tools/read_file.ts's own
@@ -90,47 +194,42 @@ export function createAskUserTool(context, onPending) {
90
194
  const question = String(input.question);
91
195
  const options = Array.isArray(input.options) ? input.options.map(String) : undefined;
92
196
  if (!onPending)
93
- return promptOnConsole(question, options);
94
- const id = randomUUID();
95
- const entry = { id, question, options, agent: context.agent, sessionId: context.sessionId, requestedAt: new Date().toISOString() };
96
- return new Promise((resolve) => {
97
- const timer = setTimeout(() => {
98
- questionsById.delete(id);
99
- resolve('(no answer — timed out)');
100
- }, DEFAULT_TIMEOUT_MS);
101
- questionsById.set(id, { entry, resolve, timer });
102
- onPending(entry);
103
- });
197
+ return defaultCliQuestionHandler.requestQuestion(question, options);
198
+ return defaultQuestionHandler.requestQuestion(question, options, context.agent, context.sessionId, onPending);
104
199
  },
105
200
  };
106
201
  }
107
- /** Oldest first, same ordering reasoning web/web-approver.ts's listApprovals
108
- * uses — whichever question has been waiting longest surfaces first.
109
- * Unfiltered (both omitted) returns every question pending anywhere in
110
- * this process — real uses (an operator's own admin view, say) should
111
- * filter, since that's every conversation's questions mixed together. */
202
+ /** Thin wrapper over the default WebchatQuestionHandler's own list() — see
203
+ * that class's own doc comment for why one shared instance, not a fresh
204
+ * one per call, is the right default for questions. */
112
205
  export function listQuestions(filter) {
113
- return [...questionsById.values()]
114
- .map((p) => p.entry)
115
- .filter((q) => (!filter?.agent || q.agent === filter.agent) && (!filter?.sessionId || q.sessionId === filter.sessionId))
116
- .sort((a, b) => a.requestedAt.localeCompare(b.requestedAt));
206
+ return defaultQuestionHandler.list(filter);
117
207
  }
118
208
  /** Peek at a pending question's own entry (its agent/sessionId, in
119
209
  * particular) without answering it — adapters/http.ts needs this *before*
120
210
  * calling answerQuestion() below, to know which turn's early-return race
121
211
  * (see its own sessionTurns) to resume once this one's answered. */
122
212
  export function findQuestion(id) {
123
- return questionsById.get(id)?.entry;
213
+ return defaultQuestionHandler.find(id);
124
214
  }
125
- /** Returns false for an unknown/already-answered/timed-out id, so the
126
- * caller can report that distinctly (a 404) instead of silently
127
- * no-opping — same convention web/web-approver.ts's decideApproval uses. */
215
+ /** Thin wrapper over the default WebchatQuestionHandler's own decide(). */
128
216
  export function answerQuestion(id, answer) {
129
- const pending = questionsById.get(id);
130
- if (!pending)
131
- return false;
132
- clearTimeout(pending.timer);
133
- questionsById.delete(id);
134
- pending.resolve(answer);
135
- return true;
217
+ return defaultQuestionHandler.decide(id, answer);
218
+ }
219
+ /** Duck-types on the one method DurableQuestionHandler actually needs —
220
+ * same "no eval, just an explicit structural check" spirit as actauth's
221
+ * own isDurableApprover, which this mirrors: run-agent.ts's loop calls
222
+ * this on the resolved QuestionHandler (see RunAgentOptions.questionHandler's
223
+ * own doc comment) to decide which branch a system_ask_user call takes,
224
+ * the same way Gate uses isDurableApprover to decide which branch a
225
+ * gated tool call takes. */
226
+ export function isDurableQuestionHandler(questionHandler) {
227
+ return typeof questionHandler.notifyPendingQuestion === 'function';
136
228
  }
229
+ // The webhook-backed DurableQuestionHandler that used to live here
230
+ // (WebhookQuestionHandler) moved to core/http-notify-triggers/webhook.ts's
231
+ // WebhookNotifier — merged with the approval-sending side that used to
232
+ // be actauth's own WebhookApprover, since sending a signed webhook POST
233
+ // was never actually approval-specific or question-specific, just
234
+ // under a different header. See that file's own header comment for the
235
+ // full reasoning.
@@ -1,3 +1,3 @@
1
1
  import type { ToolDefinition } from '../agent-config.js';
2
2
  export declare const systemTools: ToolDefinition[];
3
- export { createAskUserTool, listQuestions, answerQuestion, findQuestion, type PendingQuestion } from './ask_user.js';
3
+ export { createAskUserTool, listQuestions, answerQuestion, findQuestion, CliQuestionHandler, WebchatQuestionHandler, isDurableQuestionHandler, type PendingQuestion, } from './ask_user.js';
@@ -9,4 +9,4 @@
9
9
  // re-exported here instead of an instance.
10
10
  import { readFile } from './read_file.js';
11
11
  export const systemTools = [readFile];
12
- export { createAskUserTool, listQuestions, answerQuestion, findQuestion } from './ask_user.js';
12
+ export { createAskUserTool, listQuestions, answerQuestion, findQuestion, CliQuestionHandler, WebchatQuestionHandler, isDurableQuestionHandler, } from './ask_user.js';
@@ -0,0 +1,6 @@
1
+ {
2
+ "type": "module",
3
+ "imports": {
4
+ "#*": "./*"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.0.21",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
@@ -18,6 +18,7 @@
18
18
  "#*": "./*"
19
19
  },
20
20
  "files": [
21
+ "dist/package.json",
21
22
  "dist/index.js",
22
23
  "dist/index.d.ts",
23
24
  "dist/bin/cli.js",
@@ -46,21 +47,30 @@
46
47
  "dist/core/agent-as-tool.d.ts",
47
48
  "dist/core/gateway-tools.js",
48
49
  "dist/core/gateway-tools.d.ts",
50
+ "dist/core/budget.js",
51
+ "dist/core/budget.d.ts",
52
+ "dist/core/compaction.js",
53
+ "dist/core/compaction.d.ts",
54
+ "dist/core/recovery.js",
55
+ "dist/core/recovery.d.ts",
56
+ "dist/core/http-notifier.js",
57
+ "dist/core/http-notifier.d.ts",
58
+ "dist/core/http-notify-triggers",
49
59
  "dist/core/system-tools",
50
- "core/system-skills",
60
+ "dist/core/system-skills",
51
61
  "protocol/loop-event.schema.json",
52
62
  "PROTOCOL.md",
53
63
  "LICENSE"
54
64
  ],
55
65
  "scripts": {
56
- "build": "tsc; s=$?; chmod +x dist/bin/cli.js; exit $s",
66
+ "build": "tsc; s=$?; chmod +x dist/bin/cli.js; cp -r core/system-skills dist/core/system-skills; tsx scripts/write-dist-package-json.ts; exit $s",
57
67
  "prepublishOnly": "npm run build",
58
68
  "start": "node --env-file-if-exists=.env dist/adapters/http.js",
59
69
  "test": "vitest run"
60
70
  },
61
71
  "dependencies": {
62
72
  "@anthropic-ai/sdk": "^0.116.0",
63
- "actauth": "^0.0.7",
73
+ "actauth": "^0.0.12",
64
74
  "ioredis": "^6.0.0",
65
75
  "mcpplug": "^0.0.1",
66
76
  "openai": "^7.4.0",
@@ -11,7 +11,7 @@
11
11
  { "$ref": "#/$defs/toolStarted" },
12
12
  { "$ref": "#/$defs/toolResult" },
13
13
  { "$ref": "#/$defs/toollaneResult" },
14
- { "$ref": "#/$defs/skillLoad" },
14
+ { "$ref": "#/$defs/skillLoaded" },
15
15
  { "$ref": "#/$defs/loopSkipped" },
16
16
  { "$ref": "#/$defs/loopMaxTurns" },
17
17
  { "$ref": "#/$defs/loopDenied" },
@@ -132,7 +132,7 @@
132
132
  "additionalProperties": false
133
133
  },
134
134
 
135
- "skillLoad": {
135
+ "skillLoaded": {
136
136
  "type": "object",
137
137
  "description": "Engine. A Skill tool_use block was resolved to a real skill body via SkillGarden's own lazy load. Not gated by ActAuth — Skill invocation isn't a real tool call.",
138
138
  "required": ["type", "skill"],