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.
Files changed (40) hide show
  1. package/PROTOCOL.md +245 -0
  2. package/README.md +44 -5
  3. package/core/system-skills/composio-large-outputs/SKILL.md +25 -0
  4. package/dist/{cli.d.ts → bin/cli.d.ts} +25 -4
  5. package/dist/{cli.js → bin/cli.js} +131 -16
  6. package/dist/{agent-as-tool.d.ts → core/agent-as-tool.d.ts} +2 -2
  7. package/dist/{agent-as-tool.js → core/agent-as-tool.js} +1 -1
  8. package/dist/core/client.d.ts +113 -0
  9. package/dist/core/client.js +123 -0
  10. package/dist/core/discover-agents.d.ts +45 -0
  11. package/dist/{discover-agents.js → core/discover-agents.js} +11 -3
  12. package/dist/{gateway-tools.d.ts → core/gateway-tools.d.ts} +38 -6
  13. package/dist/{gateway-tools.js → core/gateway-tools.js} +140 -28
  14. package/dist/core/loop-events.d.ts +175 -0
  15. package/dist/core/loop-events.js +1 -0
  16. package/dist/{model-calls → core/model-calls}/anthropic-model-call.d.ts +1 -1
  17. package/dist/{model-calls → core/model-calls}/deepseek-model-call.d.ts +1 -1
  18. package/dist/{model-calls → core/model-calls}/openai-model-call.d.ts +1 -1
  19. package/dist/{model-calls → core/model-calls}/openai-model-call.js +1 -1
  20. package/dist/{run-agent.d.ts → core/run-agent.d.ts} +79 -11
  21. package/dist/{run-agent.js → core/run-agent.js} +242 -60
  22. package/dist/{session-store.d.ts → core/session-store.d.ts} +14 -2
  23. package/dist/{session-store.js → core/session-store.js} +19 -0
  24. package/dist/core/system-tools/ask_user.d.ts +72 -0
  25. package/dist/core/system-tools/ask_user.js +136 -0
  26. package/dist/core/system-tools/index.d.ts +3 -0
  27. package/dist/core/system-tools/index.js +12 -0
  28. package/dist/core/system-tools/read_file.d.ts +28 -0
  29. package/dist/core/system-tools/read_file.js +64 -0
  30. package/dist/{vector-index.js → core/vector-index.js} +1 -1
  31. package/dist/index.d.ts +19 -16
  32. package/dist/index.js +16 -9
  33. package/package.json +34 -28
  34. package/protocol/loop-event.schema.json +257 -0
  35. package/dist/discover-agents.d.ts +0 -24
  36. /package/dist/{agent-config.d.ts → core/agent-config.d.ts} +0 -0
  37. /package/dist/{agent-config.js → core/agent-config.js} +0 -0
  38. /package/dist/{model-calls → core/model-calls}/anthropic-model-call.js +0 -0
  39. /package/dist/{model-calls → core/model-calls}/deepseek-model-call.js +0 -0
  40. /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
- async function synthesizeCreateModelCall(model) {
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. Deliberately bypasses
107
- * loadGatewayToolsFromDir's mtime cache — this is an occasional,
108
- * deliberate operator action, not a per-turn hot path, so "always
109
- * current" is worth more here than "cheap." */
110
- export declare function describeGatewayTools(agentName: string): Promise<GatewayToolStatus[]>;
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 adapters/global-config-page.ts. */
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 "next to this file, not process.cwd()" reasoning run-agent.ts's
19
- // own agentsRootDir uses — real code (an actauth.yml write, a
20
- // gateway-tools.yml write) needs to land in the same place a built dist/
21
- // would look for it, not wherever the process happened to be launched
22
- // from.
23
- const agentsRootDir = join(dirname(fileURLToPath(import.meta.url)), 'agents');
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) => (i === index ? { ...s, slugs: remainingSlugs } : s));
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
- async function connectEntry(entry) {
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 source = await connectComposioSource(entry.name, { slugs: entry.slugs, cliCommand: entry.cliCommand });
316
- try {
317
- return await source.loadTools();
318
- }
319
- finally {
320
- await source.close();
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 (see its own "always current worth
338
- * more than cheap" doc comment), an occasional, deliberate admin action.
339
- * Deliberately *not* applied inside connectEntry itself, even though
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. Deliberately bypasses
382
- * loadGatewayToolsFromDir's mtime cache — this is an occasional,
383
- * deliberate operator action, not a per-turn hot path, so "always
384
- * current" is worth more here than "cheap." */
385
- export async function describeGatewayTools(agentName) {
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 rawTools = await connectEntry(entry);
390
- // See withRealComposioDescriptions' own doc comment for why this
391
- // enrichment happens here, not inside connectEntry itself.
392
- const tools = entry.provider === 'composio' ? await withRealComposioDescriptions(rawTools, entry.slugs, entry.cliCommand) : rawTools;
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 adapters/global-config-page.ts. */
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
  }