loopengine 0.0.18 → 0.0.21
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 +245 -0
- package/README.md +44 -5
- package/core/system-skills/composio-large-outputs/SKILL.md +25 -0
- package/dist/{cli.d.ts → bin/cli.d.ts} +25 -4
- package/dist/{cli.js → bin/cli.js} +131 -16
- package/dist/{agent-as-tool.d.ts → core/agent-as-tool.d.ts} +2 -2
- package/dist/{agent-as-tool.js → core/agent-as-tool.js} +1 -1
- package/dist/core/client.d.ts +113 -0
- package/dist/core/client.js +123 -0
- package/dist/core/discover-agents.d.ts +45 -0
- package/dist/{discover-agents.js → core/discover-agents.js} +11 -3
- package/dist/{gateway-tools.d.ts → core/gateway-tools.d.ts} +38 -6
- package/dist/{gateway-tools.js → core/gateway-tools.js} +140 -28
- package/dist/core/loop-events.d.ts +175 -0
- package/dist/core/loop-events.js +1 -0
- package/dist/{model-calls → core/model-calls}/anthropic-model-call.d.ts +1 -1
- package/dist/{model-calls → core/model-calls}/deepseek-model-call.d.ts +1 -1
- package/dist/{model-calls → core/model-calls}/openai-model-call.d.ts +1 -1
- package/dist/{model-calls → core/model-calls}/openai-model-call.js +1 -1
- package/dist/{run-agent.d.ts → core/run-agent.d.ts} +79 -11
- package/dist/{run-agent.js → core/run-agent.js} +242 -60
- package/dist/{session-store.d.ts → core/session-store.d.ts} +14 -2
- package/dist/{session-store.js → core/session-store.js} +19 -0
- package/dist/core/system-tools/ask_user.d.ts +72 -0
- package/dist/core/system-tools/ask_user.js +136 -0
- package/dist/core/system-tools/index.d.ts +3 -0
- package/dist/core/system-tools/index.js +12 -0
- package/dist/core/system-tools/read_file.d.ts +28 -0
- package/dist/core/system-tools/read_file.js +64 -0
- package/dist/{vector-index.js → core/vector-index.js} +1 -1
- package/dist/index.d.ts +19 -16
- package/dist/index.js +16 -9
- package/package.json +34 -28
- package/protocol/loop-event.schema.json +257 -0
- package/dist/discover-agents.d.ts +0 -24
- /package/dist/{agent-config.d.ts → core/agent-config.d.ts} +0 -0
- /package/dist/{agent-config.js → core/agent-config.js} +0 -0
- /package/dist/{model-calls → core/model-calls}/anthropic-model-call.js +0 -0
- /package/dist/{model-calls → core/model-calls}/deepseek-model-call.js +0 -0
- /package/dist/{vector-index.d.ts → core/vector-index.d.ts} +0 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { LoopEvent } from './loop-events.js';
|
|
2
|
+
import type { Message } from './run-agent.js';
|
|
3
|
+
export interface RequestOptions {
|
|
4
|
+
/** Resumes an existing conversation — omit for a fresh one; see
|
|
5
|
+
* adapters/http.ts's own sessionIdFor for how the server derives one
|
|
6
|
+
* when omitted. */
|
|
7
|
+
sessionId?: string;
|
|
8
|
+
/** Extra headers on top of 'content-type: application/json' — e.g. HTTP
|
|
9
|
+
* Basic Auth (LOOPENGINE_ADMIN_AUTH) or an AgentConfig.tenantFor header. */
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
/** Merged into the request body alongside `message`/`sessionId` — for an
|
|
12
|
+
* AgentConfig with its own sessionIdFor/tenantFor reading extra fields
|
|
13
|
+
* (see adapters/http.ts's own resolveTenant). */
|
|
14
|
+
body?: Record<string, unknown>;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
/** POST /agents/:name/messages's own 200 response — the turn finished,
|
|
18
|
+
* genuinely or via a synthetic stopReason (see loop-events.ts's own
|
|
19
|
+
* DoneEvent). `events` is every LoopEvent this turn produced, start to
|
|
20
|
+
* finish — the same array a streamed turn delivers frame-by-frame. */
|
|
21
|
+
export interface SendMessageResult {
|
|
22
|
+
text: string;
|
|
23
|
+
sessionId: string;
|
|
24
|
+
events: LoopEvent[];
|
|
25
|
+
stopReason?: 'max_turns' | 'denied';
|
|
26
|
+
}
|
|
27
|
+
interface PendingResultBase {
|
|
28
|
+
pending: true;
|
|
29
|
+
id: string;
|
|
30
|
+
sessionId: string;
|
|
31
|
+
events: LoopEvent[];
|
|
32
|
+
/** GET this to rehydrate the conversation (and re-discover this same
|
|
33
|
+
* pending item) if the caller navigates away before deciding it. */
|
|
34
|
+
statusUrl: string;
|
|
35
|
+
}
|
|
36
|
+
/** POST /agents/:name/messages's own 202 response when the turn needs a
|
|
37
|
+
* human 'ask' decision before it can continue — resolve it with
|
|
38
|
+
* approveCall/denyCall below, both of which return this same
|
|
39
|
+
* MessageResult union, so a caller can treat approve/deny/answer and the
|
|
40
|
+
* original send as one uniform decide-or-finish loop. */
|
|
41
|
+
export interface PendingApprovalResult extends PendingResultBase {
|
|
42
|
+
type: 'approval';
|
|
43
|
+
tool: string;
|
|
44
|
+
args: Record<string, unknown>;
|
|
45
|
+
scope: {
|
|
46
|
+
tenant: string;
|
|
47
|
+
environment: string;
|
|
48
|
+
agent: string;
|
|
49
|
+
};
|
|
50
|
+
reason: string;
|
|
51
|
+
approveUrl: string;
|
|
52
|
+
denyUrl: string;
|
|
53
|
+
}
|
|
54
|
+
/** Same as PendingApprovalResult, for the system ask_user tool's own
|
|
55
|
+
* open-ended question instead of a fixed allow/deny — resolve with
|
|
56
|
+
* answerQuestion below. */
|
|
57
|
+
export interface PendingQuestionResult extends PendingResultBase {
|
|
58
|
+
type: 'question';
|
|
59
|
+
question: string;
|
|
60
|
+
options?: string[];
|
|
61
|
+
answerUrl: string;
|
|
62
|
+
}
|
|
63
|
+
export type PendingResult = PendingApprovalResult | PendingQuestionResult;
|
|
64
|
+
export type MessageResult = SendMessageResult | PendingResult;
|
|
65
|
+
/** The non-streaming half of the protocol — one request, one response,
|
|
66
|
+
* either done (SendMessageResult) or needing a decision (PendingResult).
|
|
67
|
+
* Reach for this over streamMessage() when the caller doesn't need live,
|
|
68
|
+
* frame-by-frame updates (a bot integration, a batch job, a callback-
|
|
69
|
+
* style webhook handler) — same underlying turn, same event vocabulary in
|
|
70
|
+
* `events`, just delivered all at once instead of incrementally. */
|
|
71
|
+
export declare function sendMessage(baseUrl: string, agent: string, message: string, options?: RequestOptions): Promise<MessageResult>;
|
|
72
|
+
export declare function approveCall(baseUrl: string, id: string, options?: RequestOptions): Promise<MessageResult>;
|
|
73
|
+
export declare function denyCall(baseUrl: string, id: string, options?: RequestOptions): Promise<MessageResult>;
|
|
74
|
+
export declare function answerQuestion(baseUrl: string, id: string, answer: string, options?: RequestOptions): Promise<MessageResult>;
|
|
75
|
+
/** GET /agents/:name/sessions/:id — rehydrates a conversation's stored
|
|
76
|
+
* history, e.g. after a page reload lost whatever in-memory state a React/
|
|
77
|
+
* Vue app was holding (mirrors web/playground.ts's own resumeSession). */
|
|
78
|
+
export declare function getSessionHistory(baseUrl: string, agent: string, sessionId: string, options?: Pick<RequestOptions, 'headers' | 'signal'>): Promise<{
|
|
79
|
+
sessionId: string;
|
|
80
|
+
history: Message[];
|
|
81
|
+
}>;
|
|
82
|
+
/** The streaming half of the protocol — an async generator yielding one
|
|
83
|
+
* LoopEvent per SSE frame as it arrives, always starting with `session`
|
|
84
|
+
* and ending with `done` or `error` (see loop-events.ts's own header
|
|
85
|
+
* comment). `for await` is the natural fit for a React effect or Vue
|
|
86
|
+
* composable driving off this directly:
|
|
87
|
+
*
|
|
88
|
+
* for await (const event of streamMessage(baseUrl, agent, text, { signal })) {
|
|
89
|
+
* setEvents((prev) => [...prev, event])
|
|
90
|
+
* if (event.type === 'done' || event.type === 'error') break
|
|
91
|
+
* }
|
|
92
|
+
*
|
|
93
|
+
* Pass an AbortController's signal to let the caller cancel mid-stream
|
|
94
|
+
* (a React effect's own cleanup, a Vue component's onUnmounted) — the
|
|
95
|
+
* underlying fetch is aborted and the generator simply stops yielding,
|
|
96
|
+
* same as any other AbortSignal-aware fetch call.
|
|
97
|
+
*/
|
|
98
|
+
export declare function streamMessage(baseUrl: string, agent: string, message: string, options?: RequestOptions): AsyncGenerator<LoopEvent, void, void>;
|
|
99
|
+
export interface StreamMessageCallbacks {
|
|
100
|
+
onEvent?: (event: LoopEvent) => void;
|
|
101
|
+
onDone?: (event: Extract<LoopEvent, {
|
|
102
|
+
type: 'done';
|
|
103
|
+
}>) => void;
|
|
104
|
+
onError?: (event: Extract<LoopEvent, {
|
|
105
|
+
type: 'error';
|
|
106
|
+
}>) => void;
|
|
107
|
+
}
|
|
108
|
+
/** Callback-flavored wrapper over streamMessage() for a caller that would
|
|
109
|
+
* rather pass handlers into a `useEffect`/lifecycle hook than manage a
|
|
110
|
+
* `for await` loop and its own break condition by hand. Resolves once the
|
|
111
|
+
* stream ends (a 'done'/'error' event, or the connection just closing). */
|
|
112
|
+
export declare function streamMessageWithCallbacks(baseUrl: string, agent: string, message: string, options: RequestOptions, callbacks: StreamMessageCallbacks): Promise<void>;
|
|
113
|
+
export {};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
async function postJson(url, body, options = {}) {
|
|
2
|
+
const res = await fetch(url, {
|
|
3
|
+
method: 'POST',
|
|
4
|
+
headers: { 'content-type': 'application/json', ...options.headers },
|
|
5
|
+
body: JSON.stringify(body),
|
|
6
|
+
signal: options.signal,
|
|
7
|
+
});
|
|
8
|
+
const parsed = (await res.json().catch(() => ({ error: `HTTP ${res.status}` })));
|
|
9
|
+
if (!res.ok)
|
|
10
|
+
throw new Error(parsed.error ?? `HTTP ${res.status}`);
|
|
11
|
+
return parsed;
|
|
12
|
+
}
|
|
13
|
+
function messageBody(message, options) {
|
|
14
|
+
return { message, ...(options.sessionId ? { sessionId: options.sessionId } : {}), ...options.body };
|
|
15
|
+
}
|
|
16
|
+
/** The non-streaming half of the protocol — one request, one response,
|
|
17
|
+
* either done (SendMessageResult) or needing a decision (PendingResult).
|
|
18
|
+
* Reach for this over streamMessage() when the caller doesn't need live,
|
|
19
|
+
* frame-by-frame updates (a bot integration, a batch job, a callback-
|
|
20
|
+
* style webhook handler) — same underlying turn, same event vocabulary in
|
|
21
|
+
* `events`, just delivered all at once instead of incrementally. */
|
|
22
|
+
export function sendMessage(baseUrl, agent, message, options = {}) {
|
|
23
|
+
return postJson(`${baseUrl}/agents/${encodeURIComponent(agent)}/messages`, messageBody(message, options), options);
|
|
24
|
+
}
|
|
25
|
+
export function approveCall(baseUrl, id, options = {}) {
|
|
26
|
+
return postJson(`${baseUrl}/approvals/${encodeURIComponent(id)}/approve`, undefined, options);
|
|
27
|
+
}
|
|
28
|
+
export function denyCall(baseUrl, id, options = {}) {
|
|
29
|
+
return postJson(`${baseUrl}/approvals/${encodeURIComponent(id)}/deny`, undefined, options);
|
|
30
|
+
}
|
|
31
|
+
export function answerQuestion(baseUrl, id, answer, options = {}) {
|
|
32
|
+
return postJson(`${baseUrl}/questions/${encodeURIComponent(id)}/answer`, { answer }, options);
|
|
33
|
+
}
|
|
34
|
+
/** GET /agents/:name/sessions/:id — rehydrates a conversation's stored
|
|
35
|
+
* history, e.g. after a page reload lost whatever in-memory state a React/
|
|
36
|
+
* Vue app was holding (mirrors web/playground.ts's own resumeSession). */
|
|
37
|
+
export async function getSessionHistory(baseUrl, agent, sessionId, options = {}) {
|
|
38
|
+
const res = await fetch(`${baseUrl}/agents/${encodeURIComponent(agent)}/sessions/${encodeURIComponent(sessionId)}`, {
|
|
39
|
+
headers: options.headers,
|
|
40
|
+
signal: options.signal,
|
|
41
|
+
});
|
|
42
|
+
const body = (await res.json());
|
|
43
|
+
if (!res.ok)
|
|
44
|
+
throw new Error(body.error ?? `HTTP ${res.status}`);
|
|
45
|
+
return body;
|
|
46
|
+
}
|
|
47
|
+
function parseSseFrame(frame) {
|
|
48
|
+
let dataLine = '';
|
|
49
|
+
for (const line of frame.split('\n')) {
|
|
50
|
+
if (line.startsWith('data: '))
|
|
51
|
+
dataLine = line.slice('data: '.length);
|
|
52
|
+
}
|
|
53
|
+
if (!dataLine)
|
|
54
|
+
return undefined;
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(dataLine);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** The streaming half of the protocol — an async generator yielding one
|
|
63
|
+
* LoopEvent per SSE frame as it arrives, always starting with `session`
|
|
64
|
+
* and ending with `done` or `error` (see loop-events.ts's own header
|
|
65
|
+
* comment). `for await` is the natural fit for a React effect or Vue
|
|
66
|
+
* composable driving off this directly:
|
|
67
|
+
*
|
|
68
|
+
* for await (const event of streamMessage(baseUrl, agent, text, { signal })) {
|
|
69
|
+
* setEvents((prev) => [...prev, event])
|
|
70
|
+
* if (event.type === 'done' || event.type === 'error') break
|
|
71
|
+
* }
|
|
72
|
+
*
|
|
73
|
+
* Pass an AbortController's signal to let the caller cancel mid-stream
|
|
74
|
+
* (a React effect's own cleanup, a Vue component's onUnmounted) — the
|
|
75
|
+
* underlying fetch is aborted and the generator simply stops yielding,
|
|
76
|
+
* same as any other AbortSignal-aware fetch call.
|
|
77
|
+
*/
|
|
78
|
+
export async function* streamMessage(baseUrl, agent, message, options = {}) {
|
|
79
|
+
const res = await fetch(`${baseUrl}/agents/${encodeURIComponent(agent)}/messages/stream`, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'content-type': 'application/json', ...options.headers },
|
|
82
|
+
body: JSON.stringify(messageBody(message, options)),
|
|
83
|
+
signal: options.signal,
|
|
84
|
+
});
|
|
85
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
86
|
+
if (!contentType.includes('text/event-stream')) {
|
|
87
|
+
const body = (await res.json().catch(() => ({ error: `HTTP ${res.status}` })));
|
|
88
|
+
yield { type: 'error', error: body.error ?? `HTTP ${res.status}` };
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!res.body)
|
|
92
|
+
return;
|
|
93
|
+
const reader = res.body.getReader();
|
|
94
|
+
const decoder = new TextDecoder();
|
|
95
|
+
let buffer = '';
|
|
96
|
+
for (;;) {
|
|
97
|
+
const { done, value } = await reader.read();
|
|
98
|
+
if (done)
|
|
99
|
+
break;
|
|
100
|
+
buffer += decoder.decode(value, { stream: true });
|
|
101
|
+
let idx;
|
|
102
|
+
while ((idx = buffer.indexOf('\n\n')) !== -1) {
|
|
103
|
+
const frame = buffer.slice(0, idx);
|
|
104
|
+
buffer = buffer.slice(idx + 2);
|
|
105
|
+
const event = parseSseFrame(frame);
|
|
106
|
+
if (event)
|
|
107
|
+
yield event;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Callback-flavored wrapper over streamMessage() for a caller that would
|
|
112
|
+
* rather pass handlers into a `useEffect`/lifecycle hook than manage a
|
|
113
|
+
* `for await` loop and its own break condition by hand. Resolves once the
|
|
114
|
+
* stream ends (a 'done'/'error' event, or the connection just closing). */
|
|
115
|
+
export async function streamMessageWithCallbacks(baseUrl, agent, message, options, callbacks) {
|
|
116
|
+
for await (const event of streamMessage(baseUrl, agent, message, options)) {
|
|
117
|
+
callbacks.onEvent?.(event);
|
|
118
|
+
if (event.type === 'done')
|
|
119
|
+
callbacks.onDone?.(event);
|
|
120
|
+
else if (event.type === 'error')
|
|
121
|
+
callbacks.onError?.(event);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AgentConfig, AgentModelConfig } from '#core/agent-config.js';
|
|
2
|
+
import type { ModelCall } from '#core/run-agent.js';
|
|
3
|
+
export interface AgentModule {
|
|
4
|
+
config: AgentConfig;
|
|
5
|
+
/** Factory, not a shared instance — a fresh ModelCall per request/session. */
|
|
6
|
+
createModelCall: () => ModelCall;
|
|
7
|
+
}
|
|
8
|
+
/** Builds a lazy, memoized createModelCall from AgentConfig.model, for a
|
|
9
|
+
* module that doesn't export its own — see that field's own doc comment.
|
|
10
|
+
* The provider's SDK module is imported here, eagerly, but that's cheap
|
|
11
|
+
* and safe with no API key at all: importing model-calls/*.ts only
|
|
12
|
+
* defines functions, it never constructs a client at module top level
|
|
13
|
+
* (see e.g. createAnthropicModelCall's own body) — so this can't fail
|
|
14
|
+
* discovery the way eagerly building the actual client would. The real
|
|
15
|
+
* client is only built the first time the returned function is actually
|
|
16
|
+
* called, and reused after that, the same "don't crash the whole server
|
|
17
|
+
* over one agent's missing API key, don't rebuild per call" reasoning
|
|
18
|
+
* agents/customer-service/index.ts's own hand-written createModelCall
|
|
19
|
+
* used before this existed.
|
|
20
|
+
*
|
|
21
|
+
* Exported so a caller updating an *already-registered* agent's model
|
|
22
|
+
* (see agent-file-admin.ts's editAgentFile, applied live via
|
|
23
|
+
* core/agent-registry.ts's updateAgent) can rebuild just this one closure
|
|
24
|
+
* without re-importing the whole module — Node's ESM loader caches an
|
|
25
|
+
* already-imported module forever, so there's no supported way to
|
|
26
|
+
* re-import a changed agent file's own createModelCall directly; this is
|
|
27
|
+
* the one piece of it that's meaningfully regenerable on its own. */
|
|
28
|
+
export declare function synthesizeCreateModelCall(model: AgentModelConfig): Promise<() => ModelCall>;
|
|
29
|
+
/** Imports one agent module at `path` and resolves it to an AgentModule —
|
|
30
|
+
* the single-file version of what discoverAgents does over a whole
|
|
31
|
+
* directory, factored out so run-agent.ts's subagent loader (see
|
|
32
|
+
* agent-as-tool.ts / loadSubagentAsTools) can load one `subagents/<name>/
|
|
33
|
+
* index.ts` at a time without duplicating this resolution logic.
|
|
34
|
+
* `label` is only used in error messages — discoverAgents passes
|
|
35
|
+
* `'<file> in <dir>'`, callers loading a single known path can just pass
|
|
36
|
+
* that path. */
|
|
37
|
+
export declare function loadAgentModule(path: string, label: string): Promise<AgentModule>;
|
|
38
|
+
/** Scans `dir` for agent modules — direct `.ts`/`.js` files, or
|
|
39
|
+
* subdirectories with an `index.ts`/`index.js` (see resolveModulePath) —
|
|
40
|
+
* each expected to export `config`, and either its own `createModelCall`
|
|
41
|
+
* or a `config.model` for one to be synthesized from (see
|
|
42
|
+
* AgentModelConfig's own doc comment). Throws, rather than skipping, on a
|
|
43
|
+
* module that doesn't match that shape or a duplicate AgentConfig.name —
|
|
44
|
+
* both are almost always a bug, not something to silently ignore. */
|
|
45
|
+
export declare function discoverAgents(dir: string): Promise<Map<string, AgentModule>>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Scans a directory for agent modules and builds a name -> module map from
|
|
2
2
|
// it — the thing that makes "drop a new file in agents/" enough to
|
|
3
3
|
// register an agent, no hand-written import-and-add-one-line edit needed
|
|
4
|
-
// anywhere else. See agent-registry.ts for the one place this repo's own
|
|
4
|
+
// anywhere else. See core/agent-registry.ts for the one place this repo's own
|
|
5
5
|
// reference app actually calls it.
|
|
6
6
|
//
|
|
7
7
|
// Keyed by AgentConfig.name, not the filename — that's the same identity
|
|
@@ -28,8 +28,16 @@ function isRawAgentModule(mod) {
|
|
|
28
28
|
* called, and reused after that, the same "don't crash the whole server
|
|
29
29
|
* over one agent's missing API key, don't rebuild per call" reasoning
|
|
30
30
|
* agents/customer-service/index.ts's own hand-written createModelCall
|
|
31
|
-
* used before this existed.
|
|
32
|
-
|
|
31
|
+
* used before this existed.
|
|
32
|
+
*
|
|
33
|
+
* Exported so a caller updating an *already-registered* agent's model
|
|
34
|
+
* (see agent-file-admin.ts's editAgentFile, applied live via
|
|
35
|
+
* core/agent-registry.ts's updateAgent) can rebuild just this one closure
|
|
36
|
+
* without re-importing the whole module — Node's ESM loader caches an
|
|
37
|
+
* already-imported module forever, so there's no supported way to
|
|
38
|
+
* re-import a changed agent file's own createModelCall directly; this is
|
|
39
|
+
* the one piece of it that's meaningfully regenerable on its own. */
|
|
40
|
+
export async function synthesizeCreateModelCall(model) {
|
|
33
41
|
let cached;
|
|
34
42
|
if (model.provider === 'anthropic') {
|
|
35
43
|
const { createAnthropicModelCall } = await import('./model-calls/anthropic-model-call.js');
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { Decision } from 'actauth';
|
|
2
2
|
import type { ToolDefinition } from './agent-config.js';
|
|
3
|
+
export interface ResolvedGatewayTool {
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
input_schema: Record<string, unknown>;
|
|
7
|
+
}
|
|
3
8
|
export interface ComposioGatewayToolEntry {
|
|
4
9
|
provider: 'composio';
|
|
5
10
|
/** Namespaces every tool this source produces (`${name}_${slug}`,
|
|
@@ -10,6 +15,18 @@ export interface ComposioGatewayToolEntry {
|
|
|
10
15
|
/** Override for a non-default binary name, or a stand-in script in
|
|
11
16
|
* tests — see ComposioSourceOptions.cliCommand. Defaults to 'composio'. */
|
|
12
17
|
cliCommand?: string;
|
|
18
|
+
/** {name, description, input_schema} per slug, once resolved — see
|
|
19
|
+
* connectEntry/describeGatewayTools. Populated lazily (a slug just
|
|
20
|
+
* added via addGatewayTool has no entry here yet) and persisted back
|
|
21
|
+
* into this same file (see persistResolvedTools), not held in memory —
|
|
22
|
+
* so a schema, once resolved, survives a server restart instead of
|
|
23
|
+
* costing another ~3s Composio round trip on the next load. Keyed by
|
|
24
|
+
* slug, not by the derived tool name (`${name}_${slug}`), since the
|
|
25
|
+
* slug is what's stable if this source were ever renamed. Hand-editing
|
|
26
|
+
* this is never expected — treat it as generated, the same as
|
|
27
|
+
* describeGatewayTools' own in-memory result used to be before this
|
|
28
|
+
* existed. */
|
|
29
|
+
resolved?: Record<string, ResolvedGatewayTool>;
|
|
13
30
|
}
|
|
14
31
|
export type GatewayToolEntry = ComposioGatewayToolEntry;
|
|
15
32
|
/** A top-level agent's real folder — exported so callers resolving other
|
|
@@ -103,11 +120,26 @@ export interface GatewayToolStatus {
|
|
|
103
120
|
* function surfaces it explicitly, per source, since an operator looking
|
|
104
121
|
* at this page needs to know *which* source is broken and why — where
|
|
105
122
|
* loadGatewayToolsFromDir just logs and drops it, since runAgent() has no
|
|
106
|
-
* structured place to hand a per-source error to.
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
|
|
123
|
+
* structured place to hand a per-source error to.
|
|
124
|
+
*
|
|
125
|
+
* No in-memory cache here (or anywhere in this file, for this) — the
|
|
126
|
+
* cache *is* gateway-tools.yml's own `resolved` field (see
|
|
127
|
+
* ComposioGatewayToolEntry's own doc comment and connectEntry, which this
|
|
128
|
+
* shares that field with), so it survives a restart instead of being
|
|
129
|
+
* lost on one. Only genuinely-unresolved slugs pay for a live fetch, and
|
|
130
|
+
* for those this still enriches with Composio's real catalog description
|
|
131
|
+
* (see withRealComposioDescriptions) before persisting — unlike
|
|
132
|
+
* connectEntry, which doesn't (see that function's own reasoning), this
|
|
133
|
+
* is an occasional operator action, not runAgent()'s hot path, so paying
|
|
134
|
+
* for the better description once, at resolution time, is worth it here.
|
|
135
|
+
* Pass `force: true` (the admin page's own "Refresh" button) to treat
|
|
136
|
+
* every slug as unresolved and overwrite their persisted entries with a
|
|
137
|
+
* fresh live check — e.g. after reconnecting an account, or on the rare
|
|
138
|
+
* chance a schema changed upstream without gateway-tools.yml itself
|
|
139
|
+
* changing. */
|
|
140
|
+
export declare function describeGatewayTools(agentName: string, options?: {
|
|
141
|
+
force?: boolean;
|
|
142
|
+
}): Promise<GatewayToolStatus[]>;
|
|
111
143
|
/** Resolves agents/<name>/gateway-tools.yml (or the equivalent nested path
|
|
112
144
|
* for a subagent — see readGatewayToolsFromDir) into real ToolDefinitions,
|
|
113
145
|
* for run-agent.ts to merge into the rest of an agent's tools. Missing
|
|
@@ -190,5 +222,5 @@ export declare function getComposioAuthStatus(cliCommand?: string): Promise<Comp
|
|
|
190
222
|
* Composio's own documented path is plain, interactive `composio login`
|
|
191
223
|
* (opens a browser) after installing the CLI — not something a web
|
|
192
224
|
* request can drive, so the Gateways panel shows that as instructions
|
|
193
|
-
* instead of a form; see its own comment in
|
|
225
|
+
* instead of a form; see its own comment in web/global-config-page.ts. */
|
|
194
226
|
export declare function disconnectComposioAccount(cliCommand?: string): Promise<void>;
|
|
@@ -15,12 +15,13 @@ import { promisify } from 'node:util';
|
|
|
15
15
|
import { parse as parseYaml, parseDocument, stringify as stringifyYaml } from 'yaml';
|
|
16
16
|
import { connectComposioSource } from 'mcpplug';
|
|
17
17
|
const execFileAsync = promisify(execFile);
|
|
18
|
-
// Same "
|
|
19
|
-
// own agentsRootDir uses
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
18
|
+
// Same "one level up from this file, not process.cwd()" reasoning
|
|
19
|
+
// run-agent.ts's own agentsRootDir uses (this file lives in core/
|
|
20
|
+
// alongside it, with agents/ one level up at repo root either way) — real
|
|
21
|
+
// code (an actauth.yml write, a gateway-tools.yml write) needs to land in
|
|
22
|
+
// the same place a built dist/ would look for it, not wherever the
|
|
23
|
+
// process happened to be launched from.
|
|
24
|
+
const agentsRootDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'agents');
|
|
24
25
|
function gatewayToolsPath(dir) {
|
|
25
26
|
return join(dir, 'gateway-tools.yml');
|
|
26
27
|
}
|
|
@@ -56,6 +57,28 @@ function writeGatewayToolsFile(dir, sources) {
|
|
|
56
57
|
mkdirSync(dir, { recursive: true });
|
|
57
58
|
writeFileSync(gatewayToolsPath(dir), stringifyYaml({ sources }));
|
|
58
59
|
}
|
|
60
|
+
// Re-reads and writes gateway-tools.yml fresh, not off whatever snapshot
|
|
61
|
+
// the caller (connectEntry/describeGatewayTools) originally read — a
|
|
62
|
+
// live resolution can take real seconds (mcpplug's per-slug CLI round
|
|
63
|
+
// trips), long enough for an admin-UI edit (add/remove a slug) to land
|
|
64
|
+
// in between. Merged into the source's existing `resolved`, not
|
|
65
|
+
// replaced, so two overlapping resolutions of different slug subsets
|
|
66
|
+
// (e.g. connectEntry resolving one newly-added slug while
|
|
67
|
+
// describeGatewayTools resolves another) don't clobber each other.
|
|
68
|
+
// Silently a no-op if the source itself is gone by the time this runs —
|
|
69
|
+
// nothing left to persist onto.
|
|
70
|
+
function persistResolvedTools(dir, sourceName, newlyResolved) {
|
|
71
|
+
if (!Object.keys(newlyResolved).length)
|
|
72
|
+
return;
|
|
73
|
+
const sources = readGatewayToolsFromDir(dir);
|
|
74
|
+
const index = sources.findIndex((s) => s.name === sourceName);
|
|
75
|
+
if (index === -1)
|
|
76
|
+
return;
|
|
77
|
+
const existing = sources[index];
|
|
78
|
+
const updated = [...sources];
|
|
79
|
+
updated[index] = { ...existing, resolved: { ...existing.resolved, ...newlyResolved } };
|
|
80
|
+
writeGatewayToolsFile(dir, updated);
|
|
81
|
+
}
|
|
59
82
|
export class GatewayToolExistsError extends Error {
|
|
60
83
|
}
|
|
61
84
|
export class GatewayToolNotFoundError extends Error {
|
|
@@ -304,21 +327,86 @@ export function removeGatewayToolSlug(agentName, sourceName, slug) {
|
|
|
304
327
|
throw new GatewayToolNotFoundError(`No gateway tool '${slug}' registered under '${sourceName}' for '${agentName}'.`);
|
|
305
328
|
}
|
|
306
329
|
const remainingSlugs = sources[index].slugs.filter((s) => s !== slug);
|
|
330
|
+
// Drops the removed slug's own resolved entry too — leaving it behind
|
|
331
|
+
// would just be dead weight (nothing in `slugs` references it anymore,
|
|
332
|
+
// so it can never be read back), and would silently reappear if the
|
|
333
|
+
// exact same slug were ever re-added later, skipping what should be a
|
|
334
|
+
// fresh resolution. Omitted entirely (not `resolved: {}`) once empty —
|
|
335
|
+
// a source that never had any resolved slugs shouldn't gain a stray
|
|
336
|
+
// empty field just because one was removed from it.
|
|
337
|
+
const { [slug]: _removed, ...remainingResolved } = sources[index].resolved ?? {};
|
|
307
338
|
const updated = remainingSlugs.length === 0
|
|
308
339
|
? sources.filter((_, i) => i !== index)
|
|
309
|
-
: sources.map((s, i) =>
|
|
340
|
+
: sources.map((s, i) => {
|
|
341
|
+
if (i !== index)
|
|
342
|
+
return s;
|
|
343
|
+
const next = { ...s, slugs: remainingSlugs };
|
|
344
|
+
if (Object.keys(remainingResolved).length)
|
|
345
|
+
next.resolved = remainingResolved;
|
|
346
|
+
else
|
|
347
|
+
delete next.resolved;
|
|
348
|
+
return next;
|
|
349
|
+
});
|
|
310
350
|
writeGatewayToolsFile(dir, updated);
|
|
311
351
|
removeAutoAddedActauthRules(dir, [`${sources[index].name}_${slug}`]);
|
|
312
352
|
}
|
|
313
|
-
|
|
353
|
+
// Builds a ToolDefinition straight from gateway-tools.yml's own persisted
|
|
354
|
+
// `resolved` entry — no CLI call at all for the schema/description, since
|
|
355
|
+
// those are exactly what's already cached. execute() itself still always
|
|
356
|
+
// shells out fresh (mirrors mcpplug's composio-source.ts own execute
|
|
357
|
+
// closure exactly — resolving *what a tool is* is cacheable, actually
|
|
358
|
+
// *running* it never is).
|
|
359
|
+
function toolFromResolved(slug, resolved, cliCommand) {
|
|
360
|
+
return {
|
|
361
|
+
name: resolved.name,
|
|
362
|
+
description: resolved.description,
|
|
363
|
+
input_schema: resolved.input_schema,
|
|
364
|
+
execute: async (input) => {
|
|
365
|
+
const result = (await runComposioCli(cliCommand ?? 'composio', ['execute', slug, '-d', JSON.stringify(input)]));
|
|
366
|
+
if (result.successful === false || result.error) {
|
|
367
|
+
throw new Error(`composio tool ${slug} failed: ${JSON.stringify(result.error ?? result)}`);
|
|
368
|
+
}
|
|
369
|
+
return result.data ?? result;
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
// The only thing that still needs mcpplug/a live Composio round trip —
|
|
374
|
+
// slugs with no persisted resolution yet (just registered, or a `force`
|
|
375
|
+
// re-check). Returns [] without touching the CLI at all when there's
|
|
376
|
+
// nothing left to resolve, which is the common case once a source's
|
|
377
|
+
// slugs have all been resolved once.
|
|
378
|
+
async function fetchUnresolvedSlugs(entry, slugs) {
|
|
379
|
+
if (!slugs.length)
|
|
380
|
+
return [];
|
|
381
|
+
const source = await connectComposioSource(entry.name, { slugs, cliCommand: entry.cliCommand });
|
|
382
|
+
try {
|
|
383
|
+
return await source.loadTools();
|
|
384
|
+
}
|
|
385
|
+
finally {
|
|
386
|
+
await source.close();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/** Resolves `entry` into real ToolDefinitions for run-agent.ts to merge
|
|
390
|
+
* in, preferring gateway-tools.yml's own persisted `resolved` data (see
|
|
391
|
+
* ComposioGatewayToolEntry's own doc comment) over asking Composio again —
|
|
392
|
+
* mcpplug's connectComposioSource (and the ~3s-per-slug CLI round trip it
|
|
393
|
+
* costs) is only ever invoked for slugs that aren't resolved yet. Newly
|
|
394
|
+
* resolved slugs are persisted back before returning, so the *next* call
|
|
395
|
+
* (even after a restart) skips the live fetch entirely too. */
|
|
396
|
+
async function connectEntry(entry, dir) {
|
|
314
397
|
if (entry.provider === 'composio') {
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
398
|
+
const resolved = entry.resolved ?? {};
|
|
399
|
+
const cachedTools = entry.slugs.filter((slug) => resolved[slug]).map((slug) => toolFromResolved(slug, resolved[slug], entry.cliCommand));
|
|
400
|
+
const unresolvedSlugs = entry.slugs.filter((slug) => !resolved[slug]);
|
|
401
|
+
const freshTools = await fetchUnresolvedSlugs(entry, unresolvedSlugs);
|
|
402
|
+
if (freshTools.length) {
|
|
403
|
+
const newlyResolved = {};
|
|
404
|
+
freshTools.forEach((tool, i) => {
|
|
405
|
+
newlyResolved[unresolvedSlugs[i]] = { name: tool.name, description: tool.description, input_schema: tool.input_schema };
|
|
406
|
+
});
|
|
407
|
+
persistResolvedTools(dir, entry.name, newlyResolved);
|
|
321
408
|
}
|
|
409
|
+
return [...cachedTools, ...freshTools];
|
|
322
410
|
}
|
|
323
411
|
// Unreachable while GatewayToolEntry is a union of one — the check (and
|
|
324
412
|
// the cast it needs) starts earning its keep the moment a second
|
|
@@ -334,9 +422,10 @@ async function connectEntry(entry) {
|
|
|
334
422
|
* <toolkit>`) has a genuinely useful one instead (e.g. "Tool to abort a
|
|
335
423
|
* repository migration that is queued or in progress. Use when you need
|
|
336
424
|
* to cancel an ongoing migration operation.") — worth fetching for
|
|
337
|
-
* describeGatewayTools specifically
|
|
338
|
-
*
|
|
339
|
-
* Deliberately *not* applied inside
|
|
425
|
+
* describeGatewayTools specifically — only paid once per slug, right
|
|
426
|
+
* before persisting it (see ComposioGatewayToolEntry's own `resolved`
|
|
427
|
+
* doc comment), not on every load. Deliberately *not* applied inside
|
|
428
|
+
* connectEntry itself, even though
|
|
340
429
|
* that would also improve what the model actually sees at runtime —
|
|
341
430
|
* connectEntry is loadGatewayToolsFromDir's hot path, and that function's
|
|
342
431
|
* own doc comment already explicitly rejects paying any extra
|
|
@@ -378,18 +467,41 @@ async function withRealComposioDescriptions(tools, slugs, cliCommand) {
|
|
|
378
467
|
* function surfaces it explicitly, per source, since an operator looking
|
|
379
468
|
* at this page needs to know *which* source is broken and why — where
|
|
380
469
|
* loadGatewayToolsFromDir just logs and drops it, since runAgent() has no
|
|
381
|
-
* structured place to hand a per-source error to.
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
|
|
470
|
+
* structured place to hand a per-source error to.
|
|
471
|
+
*
|
|
472
|
+
* No in-memory cache here (or anywhere in this file, for this) — the
|
|
473
|
+
* cache *is* gateway-tools.yml's own `resolved` field (see
|
|
474
|
+
* ComposioGatewayToolEntry's own doc comment and connectEntry, which this
|
|
475
|
+
* shares that field with), so it survives a restart instead of being
|
|
476
|
+
* lost on one. Only genuinely-unresolved slugs pay for a live fetch, and
|
|
477
|
+
* for those this still enriches with Composio's real catalog description
|
|
478
|
+
* (see withRealComposioDescriptions) before persisting — unlike
|
|
479
|
+
* connectEntry, which doesn't (see that function's own reasoning), this
|
|
480
|
+
* is an occasional operator action, not runAgent()'s hot path, so paying
|
|
481
|
+
* for the better description once, at resolution time, is worth it here.
|
|
482
|
+
* Pass `force: true` (the admin page's own "Refresh" button) to treat
|
|
483
|
+
* every slug as unresolved and overwrite their persisted entries with a
|
|
484
|
+
* fresh live check — e.g. after reconnecting an account, or on the rare
|
|
485
|
+
* chance a schema changed upstream without gateway-tools.yml itself
|
|
486
|
+
* changing. */
|
|
487
|
+
export async function describeGatewayTools(agentName, options = {}) {
|
|
488
|
+
const dir = agentDir(agentName);
|
|
386
489
|
const entries = readGatewayTools(agentName);
|
|
387
490
|
return Promise.all(entries.map(async (entry) => {
|
|
388
491
|
try {
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const
|
|
492
|
+
const resolved = entry.resolved ?? {};
|
|
493
|
+
const cachedTools = options.force ? [] : entry.slugs.filter((slug) => resolved[slug]).map((slug) => toolFromResolved(slug, resolved[slug], entry.cliCommand));
|
|
494
|
+
const unresolvedSlugs = options.force ? entry.slugs : entry.slugs.filter((slug) => !resolved[slug]);
|
|
495
|
+
const rawFreshTools = await fetchUnresolvedSlugs(entry, unresolvedSlugs);
|
|
496
|
+
const freshTools = entry.provider === 'composio' ? await withRealComposioDescriptions(rawFreshTools, unresolvedSlugs, entry.cliCommand) : rawFreshTools;
|
|
497
|
+
if (freshTools.length) {
|
|
498
|
+
const newlyResolved = {};
|
|
499
|
+
freshTools.forEach((tool, i) => {
|
|
500
|
+
newlyResolved[unresolvedSlugs[i]] = { name: tool.name, description: tool.description, input_schema: tool.input_schema };
|
|
501
|
+
});
|
|
502
|
+
persistResolvedTools(dir, entry.name, newlyResolved);
|
|
503
|
+
}
|
|
504
|
+
const tools = [...cachedTools, ...freshTools];
|
|
393
505
|
return { entry, status: 'ok', tools: tools.map((t) => ({ name: t.name, description: t.description })) };
|
|
394
506
|
}
|
|
395
507
|
catch (err) {
|
|
@@ -435,7 +547,7 @@ export async function loadGatewayToolsFromDir(dir) {
|
|
|
435
547
|
const entries = readGatewayToolsFromDir(dir);
|
|
436
548
|
const resolved = await Promise.all(entries.map(async (entry) => {
|
|
437
549
|
try {
|
|
438
|
-
return await connectEntry(entry);
|
|
550
|
+
return await connectEntry(entry, dir);
|
|
439
551
|
}
|
|
440
552
|
catch (err) {
|
|
441
553
|
console.error(`[gateway-tools] source '${entry.name}' (${entry.provider}) failed to load, skipping: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -542,7 +654,7 @@ export async function getComposioAuthStatus(cliCommand = 'composio') {
|
|
|
542
654
|
* Composio's own documented path is plain, interactive `composio login`
|
|
543
655
|
* (opens a browser) after installing the CLI — not something a web
|
|
544
656
|
* request can drive, so the Gateways panel shows that as instructions
|
|
545
|
-
* instead of a form; see its own comment in
|
|
657
|
+
* instead of a form; see its own comment in web/global-config-page.ts. */
|
|
546
658
|
export async function disconnectComposioAccount(cliCommand = 'composio') {
|
|
547
659
|
await execFileAsync(cliCommand, ['logout']);
|
|
548
660
|
}
|