loopengine 0.0.21 → 0.1.2
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/PROTOCOL.md +3 -3
- package/README.md +45 -10
- package/dist/core/agent-config.d.ts +282 -3
- package/dist/core/budget.d.ts +42 -0
- package/dist/core/budget.js +39 -0
- package/dist/core/compaction.d.ts +43 -0
- package/dist/core/compaction.js +70 -0
- package/dist/core/http-notifier.d.ts +10 -0
- package/dist/core/http-notifier.js +116 -0
- package/dist/core/http-notify-triggers/database.d.ts +19 -0
- package/dist/core/http-notify-triggers/database.js +53 -0
- package/dist/core/http-notify-triggers/email.d.ts +44 -0
- package/dist/core/http-notify-triggers/email.js +126 -0
- package/dist/core/http-notify-triggers/lark.d.ts +28 -0
- package/dist/core/http-notify-triggers/lark.js +168 -0
- package/dist/core/http-notify-triggers/redis.d.ts +33 -0
- package/dist/core/http-notify-triggers/redis.js +54 -0
- package/dist/core/http-notify-triggers/slack.d.ts +21 -0
- package/dist/core/http-notify-triggers/slack.js +91 -0
- package/dist/core/http-notify-triggers/webhook.d.ts +27 -0
- package/dist/core/http-notify-triggers/webhook.js +94 -0
- package/dist/core/loop-events.d.ts +30 -6
- package/dist/core/model-calls/openai-model-call.js +32 -16
- package/dist/core/recovery.d.ts +49 -0
- package/dist/core/recovery.js +124 -0
- package/dist/core/run-agent.d.ts +141 -13
- package/dist/core/run-agent.js +296 -54
- package/dist/core/session-store.d.ts +1 -0
- package/dist/core/session-store.js +6 -4
- package/dist/core/system-skills/system-skills/composio-large-outputs/SKILL.md +25 -0
- package/dist/core/system-tools/ask_user.d.ts +97 -31
- package/dist/core/system-tools/ask_user.js +157 -58
- package/dist/core/system-tools/index.d.ts +1 -1
- package/dist/core/system-tools/index.js +1 -1
- package/dist/package.json +6 -0
- package/package.json +14 -4
- package/protocol/loop-event.schema.json +2 -2
- /package/{core → dist/core}/system-skills/composio-large-outputs/SKILL.md +0 -0
|
@@ -75,11 +75,13 @@ function hasUnresolvedToolCall(message) {
|
|
|
75
75
|
Array.isArray(message.content) &&
|
|
76
76
|
message.content.some((block) => block.type === 'tool_use'));
|
|
77
77
|
}
|
|
78
|
+
// Exported so resumeAgent (run-agent.ts) can strip this exact message
|
|
79
|
+
// back out when it turns out the "interruption" wasn't a crash at all —
|
|
80
|
+
// see that constant's own doc comment for why the two can't be told
|
|
81
|
+
// apart at the point sessionknit's own resume() runs.
|
|
82
|
+
export const CRASH_RECOVERY_CONTINUATION = '[session resumed after an interruption — the assistant had requested tool calls but the process stopped before any results were recorded; treat those tools as not having run]';
|
|
78
83
|
function buildContinuation() {
|
|
79
|
-
return {
|
|
80
|
-
role: 'user',
|
|
81
|
-
content: '[session resumed after an interruption — the assistant had requested tool calls but the process stopped before any results were recorded; treat those tools as not having run]',
|
|
82
|
-
};
|
|
84
|
+
return { role: 'user', content: CRASH_RECOVERY_CONTINUATION };
|
|
83
85
|
}
|
|
84
86
|
/** Wraps a SessionKnit log behind the flat load/run/append contract
|
|
85
87
|
* runAgent's callers use. Shared by both the file- and Redis-backed
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: composio-large-outputs
|
|
3
|
+
description: How to retrieve a gateway tool's real output when its result says storedInFile is true instead of returning the data inline.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Gateway tools with a large result
|
|
7
|
+
|
|
8
|
+
Some gateway tools (Composio-sourced ones in particular) return a small
|
|
9
|
+
pointer object instead of their real output when the actual result is
|
|
10
|
+
too large to return inline:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
{
|
|
14
|
+
"successful": true,
|
|
15
|
+
"storedInFile": true,
|
|
16
|
+
"tokenCount": 10234,
|
|
17
|
+
"outputFilePath": "/tmp/.../SOME_TOOL_OUTPUT_xxxxx.json"
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
When you see `storedInFile: true` in a tool result, the real data is
|
|
22
|
+
**not** in that object — it's in the file at `outputFilePath`. Call
|
|
23
|
+
`system_read_file` with that exact path to retrieve the actual content
|
|
24
|
+
before answering. Don't guess at an answer, summarize from the pointer
|
|
25
|
+
alone, or claim you don't have the data — read the file first.
|
|
@@ -1,20 +1,75 @@
|
|
|
1
|
-
import type { ToolDefinition } from '../agent-config.js';
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
32
|
-
*
|
|
33
|
-
* whether anything was listening for onPending in the
|
|
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
|
|
37
|
-
* pending entry nothing could ever answer — same
|
|
38
|
-
* ConsoleApprover exists for permission asks with nowhere else
|
|
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
|
-
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
58
|
-
*
|
|
59
|
-
* whether anything was listening for onPending in the
|
|
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
|
|
63
|
-
* pending entry nothing could ever answer — same
|
|
64
|
-
* ConsoleApprover exists for permission asks with nowhere else
|
|
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
|
|
94
|
-
|
|
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
|
-
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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
|
|
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
|
|
213
|
+
return defaultQuestionHandler.find(id);
|
|
124
214
|
}
|
|
125
|
-
/**
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loopengine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.2",
|
|
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.
|
|
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/
|
|
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
|
-
"
|
|
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"],
|
|
File without changes
|