loopengine 0.1.5 → 0.1.7

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 CHANGED
@@ -190,7 +190,7 @@ loopengine run customer-service --json "order A-1001 arrived broken, can you ref
190
190
 
191
191
  ```
192
192
  {"type":"session","sessionId":"5a83c6b4-..."}
193
- {"type":"budget:check","action":"ok","usedTokens":2,"budgetTokens":8000,"ratio":0.00025}
193
+ {"type":"budget:check","action":"ok","usedTokens":2,"budgetTokens":100000,"ratio":0.00002}
194
194
  {"type":"tool:started","id":"call_1","tool":"issue_refund", ...}
195
195
  {"type":"done","text":"Refunded $42.00..."}
196
196
  ```
package/README.md CHANGED
@@ -466,6 +466,18 @@ you, using the matching API key from the environment:
466
466
  | `'anthropic'` | `ANTHROPIC_API_KEY` | no — defaults to `claude-sonnet-5` |
467
467
  | `'openai'` | `OPENAI_API_KEY` | yes |
468
468
  | `'deepseek'` | `DEEPSEEK_API_KEY` | yes |
469
+ | `'kimi'` | `MOONSHOT_API_KEY` | yes |
470
+ | `'glm'` | `GLM_API_KEY` | yes |
471
+ | `'gemini'` | `GEMINI_API_KEY` | yes |
472
+
473
+ `'kimi'`'s env var is `MOONSHOT_API_KEY`, not `KIMI_API_KEY` — deliberate,
474
+ matching Moonshot AI's own docs (the API/company behind Kimi) rather than
475
+ this package's own provider name. `openai`/`deepseek`/`kimi`/`glm`/
476
+ `gemini` all reuse the same OpenAI-Chat-Completions-compatible request
477
+ translation, just pointed at each provider's own base URL — see
478
+ `core/model-calls/*.ts` for the provider-specific details (base URLs,
479
+ `max_completion_tokens` vs `max_tokens`, and Gemini's own "still in beta"
480
+ caveat on Google's compatibility layer).
469
481
 
470
482
  For anything else (a custom SDK client, a canned/simulated model for
471
483
  testing), export your own `createModelCall(): ModelCall` instead.
package/dist/bin/cli.d.ts CHANGED
@@ -7,7 +7,7 @@ export declare class AgentNotFoundError extends Error {
7
7
  }
8
8
  export declare class AgentModelError extends Error {
9
9
  }
10
- type Provider = 'anthropic' | 'openai' | 'deepseek';
10
+ type Provider = 'anthropic' | 'openai' | 'deepseek' | 'kimi' | 'glm' | 'gemini';
11
11
  export interface AgentTemplateOptions {
12
12
  systemPrompt?: string;
13
13
  model?: {
package/dist/bin/cli.js CHANGED
@@ -26,6 +26,11 @@ const MODEL_ENV_VAR = {
26
26
  anthropic: 'ANTHROPIC_API_KEY',
27
27
  openai: 'OPENAI_API_KEY',
28
28
  deepseek: 'DEEPSEEK_API_KEY',
29
+ // Moonshot AI's own env var name, not KIMI_API_KEY — see
30
+ // kimi-model-call.ts's own header comment for why.
31
+ kimi: 'MOONSHOT_API_KEY',
32
+ glm: 'GLM_API_KEY',
33
+ gemini: 'GEMINI_API_KEY',
29
34
  };
30
35
  // Escapes a value for safe interpolation into a single-quoted TS string
31
36
  // literal in generated code — an operator-supplied systemPrompt (from
@@ -237,9 +237,10 @@ export type HttpNotifierConfig = {
237
237
  * export your own `createModelCall` for anything this can't express (a
238
238
  * canned/simulated `ModelCall` for a demo, a custom SDK client, a
239
239
  * provider this doesn't list). `model` is required for `openai`/
240
- * `deepseek` (no safe hardcoded default — a flagship model name changes
241
- * too often to bake one in) but optional for `anthropic` (defaults to
242
- * `'claude-sonnet-5'`), mirroring each factory's own options exactly. */
240
+ * `deepseek`/`kimi`/`glm`/`gemini` (no safe hardcoded default — a
241
+ * flagship model name changes too often to bake one in) but optional for
242
+ * `anthropic` (defaults to `'claude-sonnet-5'`), mirroring each factory's
243
+ * own options exactly. */
243
244
  export type AgentModelConfig = {
244
245
  provider: 'anthropic';
245
246
  model?: string;
@@ -256,6 +257,24 @@ export type AgentModelConfig = {
256
257
  apiKey?: string;
257
258
  maxTokens?: number;
258
259
  baseURL?: string;
260
+ } | {
261
+ provider: 'kimi';
262
+ model: string;
263
+ apiKey?: string;
264
+ maxTokens?: number;
265
+ baseURL?: string;
266
+ } | {
267
+ provider: 'glm';
268
+ model: string;
269
+ apiKey?: string;
270
+ maxTokens?: number;
271
+ baseURL?: string;
272
+ } | {
273
+ provider: 'gemini';
274
+ model: string;
275
+ apiKey?: string;
276
+ maxTokens?: number;
277
+ baseURL?: string;
259
278
  };
260
279
  export interface ToolSchema {
261
280
  name: string;
@@ -47,8 +47,20 @@ export async function synthesizeCreateModelCall(model) {
47
47
  const { createOpenAIModelCall } = await import('./model-calls/openai-model-call.js');
48
48
  return () => (cached ??= createOpenAIModelCall(model));
49
49
  }
50
- const { createDeepSeekModelCall } = await import('./model-calls/deepseek-model-call.js');
51
- return () => (cached ??= createDeepSeekModelCall(model));
50
+ if (model.provider === 'deepseek') {
51
+ const { createDeepSeekModelCall } = await import('./model-calls/deepseek-model-call.js');
52
+ return () => (cached ??= createDeepSeekModelCall(model));
53
+ }
54
+ if (model.provider === 'kimi') {
55
+ const { createKimiModelCall } = await import('./model-calls/kimi-model-call.js');
56
+ return () => (cached ??= createKimiModelCall(model));
57
+ }
58
+ if (model.provider === 'glm') {
59
+ const { createGlmModelCall } = await import('./model-calls/glm-model-call.js');
60
+ return () => (cached ??= createGlmModelCall(model));
61
+ }
62
+ const { createGeminiModelCall } = await import('./model-calls/gemini-model-call.js');
63
+ return () => (cached ??= createGeminiModelCall(model));
52
64
  }
53
65
  /** Resolves one directory entry to an agent module's path, or undefined if
54
66
  * this entry isn't one — a `.ts`/`.js` file directly (the common case), or
@@ -0,0 +1,17 @@
1
+ import OpenAI from 'openai';
2
+ import type { ModelCall } from '#core/run-agent.js';
3
+ export interface GeminiModelCallOptions {
4
+ /** Defaults to the GEMINI_API_KEY env var. */
5
+ apiKey?: string;
6
+ /** No hardcoded default — same reasoning as OpenAIModelCallOptions.model:
7
+ * pass the one you actually want. */
8
+ model: string;
9
+ maxTokens?: number;
10
+ /** Defaults to Google's own OpenAI-compatible endpoint — override to
11
+ * point at a proxy/mirror. Trailing slash required (see this file's
12
+ * own header comment). */
13
+ baseURL?: string;
14
+ /** Inject a pre-configured client instead of building one from apiKey/baseURL — e.g. to pass a custom `fetch` in tests. */
15
+ client?: OpenAI;
16
+ }
17
+ export declare function createGeminiModelCall(options: GeminiModelCallOptions): ModelCall;
@@ -0,0 +1,53 @@
1
+ // A sixth real ModelCall implementation. Google's own OpenAI-compatible
2
+ // endpoint for Gemini (ai.google.dev/gemini-api/docs/openai) supports the
3
+ // same messages/tools/tool_choice request shape and tool_calls response
4
+ // shape as OpenAI's Chat Completions API, so this reuses
5
+ // openai-model-call.ts's translation verbatim via the real `openai` SDK
6
+ // client, same pattern deepseek/kimi/glm-model-call.ts already
7
+ // established — rather than a bespoke native @google/genai integration
8
+ // (Gemini's own SDK has a meaningfully different message-role/content-
9
+ // parts/function-calling shape).
10
+ //
11
+ // Caveat, from Google's own docs: "Support for the OpenAI libraries is
12
+ // still in beta while we extend feature support" — this is the one
13
+ // provider here riding a compatibility layer its own vendor calls beta,
14
+ // not a fully stable wire contract the way DeepSeek/Kimi/GLM's own docs
15
+ // present theirs. Uses `max_completion_tokens`, same as Kimi/GLM.
16
+ //
17
+ // The trailing slash on the base URL matters — Google's endpoint 404s
18
+ // without it (confirmed against their own docs' exact example URL).
19
+ import OpenAI from 'openai';
20
+ import { toMessageParams, toModelResponse } from './openai-model-call.js';
21
+ export function createGeminiModelCall(options) {
22
+ if (!options.client && !options.apiKey && !process.env.GEMINI_API_KEY) {
23
+ // Fails here, with a Gemini-specific message, rather than letting the
24
+ // OpenAI SDK's own constructor validation reject it — that error says
25
+ // "set OPENAI_API_KEY", which would send someone who never touched
26
+ // that variable chasing the wrong fix.
27
+ throw new Error('createGeminiModelCall: no apiKey given and GEMINI_API_KEY is not set.');
28
+ }
29
+ const client = options.client ??
30
+ new OpenAI({
31
+ // Explicitly null, not undefined, if neither is set — see
32
+ // kimi-model-call.ts's identical comment for why this matters even
33
+ // though the guard above already rules out reaching here with no
34
+ // key at all.
35
+ apiKey: options.apiKey ?? process.env.GEMINI_API_KEY ?? null,
36
+ baseURL: options.baseURL ?? 'https://generativelanguage.googleapis.com/v1beta/openai/',
37
+ });
38
+ const maxTokens = options.maxTokens ?? 4096;
39
+ return async (messages, system, tools) => {
40
+ const response = await client.chat.completions.create({
41
+ model: options.model,
42
+ max_completion_tokens: maxTokens,
43
+ messages: [{ role: 'system', content: system }, ...messages.flatMap(toMessageParams)],
44
+ tools: tools.length
45
+ ? tools.map((t) => ({
46
+ type: 'function',
47
+ function: { name: t.name, description: t.description, parameters: t.input_schema },
48
+ }))
49
+ : undefined,
50
+ });
51
+ return toModelResponse(response.choices[0]);
52
+ };
53
+ }
@@ -0,0 +1,15 @@
1
+ import OpenAI from 'openai';
2
+ import type { ModelCall } from '#core/run-agent.js';
3
+ export interface GlmModelCallOptions {
4
+ /** Defaults to the GLM_API_KEY env var. */
5
+ apiKey?: string;
6
+ /** No hardcoded default — same reasoning as OpenAIModelCallOptions.model:
7
+ * pass the one you actually want. */
8
+ model: string;
9
+ maxTokens?: number;
10
+ /** Defaults to Zhipu's own API — override to point at a proxy/mirror. */
11
+ baseURL?: string;
12
+ /** Inject a pre-configured client instead of building one from apiKey/baseURL — e.g. to pass a custom `fetch` in tests. */
13
+ client?: OpenAI;
14
+ }
15
+ export declare function createGlmModelCall(options: GlmModelCallOptions): ModelCall;
@@ -0,0 +1,46 @@
1
+ // A fifth real ModelCall implementation. GLM (Zhipu AI / Z.ai)'s Chat
2
+ // Completions API is OpenAI-compatible — confirmed against Zhipu's own
3
+ // docs (docs.bigmodel.cn/cn/guide/develop/openai/introduction): same
4
+ // messages/tools/tool_choice request shape, same tool_calls response
5
+ // shape — so this reuses openai-model-call.ts's message/response
6
+ // translation verbatim via the real `openai` SDK client, just pointed at
7
+ // Zhipu's base URL, rather than duplicating that logic. Same pattern
8
+ // deepseek-model-call.ts/kimi-model-call.ts already established.
9
+ //
10
+ // Uses the newer `max_completion_tokens` field, same as Kimi — no
11
+ // DeepSeek-style documented exception for GLM.
12
+ import OpenAI from 'openai';
13
+ import { toMessageParams, toModelResponse } from './openai-model-call.js';
14
+ export function createGlmModelCall(options) {
15
+ if (!options.client && !options.apiKey && !process.env.GLM_API_KEY) {
16
+ // Fails here, with a GLM-specific message, rather than letting the
17
+ // OpenAI SDK's own constructor validation reject it — that error says
18
+ // "set OPENAI_API_KEY", which would send someone who never touched
19
+ // that variable chasing the wrong fix.
20
+ throw new Error('createGlmModelCall: no apiKey given and GLM_API_KEY is not set.');
21
+ }
22
+ const client = options.client ??
23
+ new OpenAI({
24
+ // Explicitly null, not undefined, if neither is set — see
25
+ // kimi-model-call.ts's identical comment for why this matters even
26
+ // though the guard above already rules out reaching here with no
27
+ // key at all.
28
+ apiKey: options.apiKey ?? process.env.GLM_API_KEY ?? null,
29
+ baseURL: options.baseURL ?? 'https://open.bigmodel.cn/api/paas/v4/',
30
+ });
31
+ const maxTokens = options.maxTokens ?? 4096;
32
+ return async (messages, system, tools) => {
33
+ const response = await client.chat.completions.create({
34
+ model: options.model,
35
+ max_completion_tokens: maxTokens,
36
+ messages: [{ role: 'system', content: system }, ...messages.flatMap(toMessageParams)],
37
+ tools: tools.length
38
+ ? tools.map((t) => ({
39
+ type: 'function',
40
+ function: { name: t.name, description: t.description, parameters: t.input_schema },
41
+ }))
42
+ : undefined,
43
+ });
44
+ return toModelResponse(response.choices[0]);
45
+ };
46
+ }
@@ -0,0 +1,15 @@
1
+ import OpenAI from 'openai';
2
+ import type { ModelCall } from '#core/run-agent.js';
3
+ export interface KimiModelCallOptions {
4
+ /** Defaults to the MOONSHOT_API_KEY env var. */
5
+ apiKey?: string;
6
+ /** No hardcoded default — same reasoning as OpenAIModelCallOptions.model:
7
+ * pass the one you actually want. */
8
+ model: string;
9
+ maxTokens?: number;
10
+ /** Defaults to Moonshot's own API — override to point at a proxy/mirror. */
11
+ baseURL?: string;
12
+ /** Inject a pre-configured client instead of building one from apiKey/baseURL — e.g. to pass a custom `fetch` in tests. */
13
+ client?: OpenAI;
14
+ }
15
+ export declare function createKimiModelCall(options: KimiModelCallOptions): ModelCall;
@@ -0,0 +1,59 @@
1
+ // A fourth real ModelCall implementation. Kimi (Moonshot AI)'s Chat
2
+ // Completions API is wire-compatible with OpenAI's — confirmed against
3
+ // Moonshot's own docs (platform.kimi.ai/docs/api/chat): same messages/
4
+ // tools/tool_choice request shape, same tool_calls response shape — so
5
+ // this reuses openai-model-call.ts's message/response translation
6
+ // verbatim via the real `openai` SDK client, just pointed at Moonshot's
7
+ // base URL, rather than duplicating that logic. Same pattern
8
+ // deepseek-model-call.ts already established for a third provider.
9
+ //
10
+ // Unlike DeepSeek, Moonshot's docs use the newer `max_completion_tokens`
11
+ // field (matching the current OpenAI SDK) rather than the deprecated
12
+ // `max_tokens` DeepSeek's own docs still list — decided at the request-
13
+ // building call site below, since toMessageParams/toModelResponse don't
14
+ // touch that field either way.
15
+ //
16
+ // Env var is MOONSHOT_API_KEY, not KIMI_API_KEY, despite every other
17
+ // provider here matching its own literal exactly — deliberate: this
18
+ // matches the underlying API/company's own docs and what anyone
19
+ // copy-pasting from Moonshot's own examples already has set, not this
20
+ // package's own provider name.
21
+ import OpenAI from 'openai';
22
+ import { toMessageParams, toModelResponse } from './openai-model-call.js';
23
+ export function createKimiModelCall(options) {
24
+ if (!options.client && !options.apiKey && !process.env.MOONSHOT_API_KEY) {
25
+ // Fails here, with a Kimi-specific message, rather than letting the
26
+ // OpenAI SDK's own constructor validation reject it — that error says
27
+ // "set OPENAI_API_KEY", which would send someone who never touched
28
+ // that variable chasing the wrong fix.
29
+ throw new Error('createKimiModelCall: no apiKey given and MOONSHOT_API_KEY is not set.');
30
+ }
31
+ const client = options.client ??
32
+ new OpenAI({
33
+ // Explicitly null, not undefined, if neither is set — the OpenAI
34
+ // SDK's own constructor falls back to reading OPENAI_API_KEY itself
35
+ // when apiKey is undefined (its normal, correct behavior for
36
+ // createOpenAIModelCall), which here would silently authenticate
37
+ // against Moonshot with an OpenAI key if one happens to be set. The
38
+ // guard above already rules out reaching here with no key at all,
39
+ // but null (not undefined) is kept regardless so this line is
40
+ // correct on its own, not just because of the guard before it.
41
+ apiKey: options.apiKey ?? process.env.MOONSHOT_API_KEY ?? null,
42
+ baseURL: options.baseURL ?? 'https://api.moonshot.ai/v1',
43
+ });
44
+ const maxTokens = options.maxTokens ?? 4096;
45
+ return async (messages, system, tools) => {
46
+ const response = await client.chat.completions.create({
47
+ model: options.model,
48
+ max_completion_tokens: maxTokens,
49
+ messages: [{ role: 'system', content: system }, ...messages.flatMap(toMessageParams)],
50
+ tools: tools.length
51
+ ? tools.map((t) => ({
52
+ type: 'function',
53
+ function: { name: t.name, description: t.description, parameters: t.input_schema },
54
+ }))
55
+ : undefined,
56
+ });
57
+ return toModelResponse(response.choices[0]);
58
+ };
59
+ }
@@ -310,7 +310,12 @@ async function buildTurnContext(config, modelCall, options) {
310
310
  // that doesn't exist (a flat-file agent with no skills at all)
311
311
  // harmless: an empty index, not an error.
312
312
  const skillsDirs = config.skillsDirs ?? [`agents/${config.name}/skills`];
313
- const skillGarden = new SkillGarden({ dirs: [...skillsDirs, systemSkillsDir], indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 });
313
+ // 2000, not an arbitrary round number: skillgarden's own default is
314
+ // ~1% of a real agent context window (~100k-200k tokens for the
315
+ // providers this repo wires up) — see its own SkillGardenOptions doc
316
+ // comment. loopengine used to default lower (200), which left barely
317
+ // enough room for a handful of skills before truncating the index.
318
+ const skillGarden = new SkillGarden({ dirs: [...skillsDirs, systemSkillsDir], indexBudgetTokens: config.skillIndexBudgetTokens ?? 2000 });
314
319
  const skillIndex = skillGarden.buildIndex().included;
315
320
  // Also the tail-preservation window recover() below relies on — kept as
316
321
  // one named constant so the two can never drift out of sync.
@@ -321,8 +326,17 @@ async function buildTurnContext(config, modelCall, options) {
321
326
  // budgetTokens/softThreshold so a nudge firing and compaction's own
322
327
  // recovery target agree — see budget.ts's own BudgetTrackerOptions doc
323
328
  // comment.
324
- const budgetTracker = new BudgetTracker({ budgetTokens: config.contextBudgetTokens ?? 8000 });
325
- const compactor = new Compactor({ budgetTokens: config.contextBudgetTokens ?? 8000, softThreshold: budgetTracker.softThreshold, tailMessages });
329
+ // 100000, not 8000: the providers this repo wires up (Claude, GPT-4o,
330
+ // DeepSeek) all give a real conversation 128k-200k tokens of window,
331
+ // and 8000 was small enough that a handful of tool calls (each with a
332
+ // full JSON schema + result) routinely tripped compaction on an
333
+ // otherwise ordinary turn. 100000 leaves headroom under even the
334
+ // smallest of those windows for the system prompt, tool schemas, and
335
+ // the model's own response, while softThreshold's default 0.7 (a
336
+ // 70000-token nudge point) and hardThreshold's 0.92 still leave real
337
+ // margin before hitting it.
338
+ const budgetTracker = new BudgetTracker({ budgetTokens: config.contextBudgetTokens ?? 100000 });
339
+ const compactor = new Compactor({ budgetTokens: config.contextBudgetTokens ?? 100000, softThreshold: budgetTracker.softThreshold, tailMessages });
326
340
  const rules = loadRules(config);
327
341
  // AgentConfig.httpNotifier only ever stands in for the http channel —
328
342
  // see its own doc comment for why cli/http_stream never consult it at
package/dist/index.d.ts CHANGED
@@ -14,6 +14,12 @@ export { createOpenAIModelCall } from './core/model-calls/openai-model-call.js';
14
14
  export type { OpenAIModelCallOptions } from './core/model-calls/openai-model-call.js';
15
15
  export { createDeepSeekModelCall } from './core/model-calls/deepseek-model-call.js';
16
16
  export type { DeepSeekModelCallOptions } from './core/model-calls/deepseek-model-call.js';
17
+ export { createKimiModelCall } from './core/model-calls/kimi-model-call.js';
18
+ export type { KimiModelCallOptions } from './core/model-calls/kimi-model-call.js';
19
+ export { createGlmModelCall } from './core/model-calls/glm-model-call.js';
20
+ export type { GlmModelCallOptions } from './core/model-calls/glm-model-call.js';
21
+ export { createGeminiModelCall } from './core/model-calls/gemini-model-call.js';
22
+ export type { GeminiModelCallOptions } from './core/model-calls/gemini-model-call.js';
17
23
  export { discoverAgents, loadAgentModule, synthesizeCreateModelCall } from './core/discover-agents.js';
18
24
  export type { AgentModule } from './core/discover-agents.js';
19
25
  export { agentAsTool } from './core/agent-as-tool.js';
@@ -35,6 +41,8 @@ export { globalConfigPageHtml } from './web/global-config-page.js';
35
41
  export { playgroundHtml } from './web/playground.js';
36
42
  export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
37
43
  export type { SkillContent } from './web/skills-admin.js';
44
+ export { listSkillgardenCatalog, readSkillgardenCatalogEntry, addSkillgardenSkillToAgent, SkillgardenUnavailableError } from './web/skillgarden-admin.js';
45
+ export type { SkillgardenCatalogEntry, SkillgardenCatalogDetail } from './web/skillgarden-admin.js';
38
46
  export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
39
47
  export type { ActauthRuleInput, ActauthConfigView } from './web/actauth-admin.js';
40
48
  export { describeModelProviders, describeGateways } from './web/global-config.js';
package/dist/index.js CHANGED
@@ -16,6 +16,9 @@ export { VectorIndex, embed, cosineSimilarity } from './core/vector-index.js';
16
16
  export { createAnthropicModelCall } from './core/model-calls/anthropic-model-call.js';
17
17
  export { createOpenAIModelCall } from './core/model-calls/openai-model-call.js';
18
18
  export { createDeepSeekModelCall } from './core/model-calls/deepseek-model-call.js';
19
+ export { createKimiModelCall } from './core/model-calls/kimi-model-call.js';
20
+ export { createGlmModelCall } from './core/model-calls/glm-model-call.js';
21
+ export { createGeminiModelCall } from './core/model-calls/gemini-model-call.js';
19
22
  export { discoverAgents, loadAgentModule, synthesizeCreateModelCall } from './core/discover-agents.js';
20
23
  export { agentAsTool } from './core/agent-as-tool.js';
21
24
  // Everything below backs the Admin UI (agents-config/list/global-config
@@ -40,6 +43,7 @@ export { agentsListPageHtml } from './web/agents-list-page.js';
40
43
  export { globalConfigPageHtml } from './web/global-config-page.js';
41
44
  export { playgroundHtml } from './web/playground.js';
42
45
  export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
46
+ export { listSkillgardenCatalog, readSkillgardenCatalogEntry, addSkillgardenSkillToAgent, SkillgardenUnavailableError } from './web/skillgarden-admin.js';
43
47
  export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
44
48
  export { describeModelProviders, describeGateways } from './web/global-config.js';
45
49
  export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.js';
@@ -2,13 +2,16 @@ export declare class AgentEditNotSupportedError extends Error {
2
2
  }
3
3
  export declare class AgentFileNotFoundError extends Error {
4
4
  }
5
- type Provider = 'anthropic' | 'openai' | 'deepseek';
5
+ type Provider = 'anthropic' | 'openai' | 'deepseek' | 'kimi' | 'glm' | 'gemini';
6
6
  export interface AgentEditableFields {
7
7
  systemPrompt?: string;
8
8
  model?: {
9
9
  provider: Provider;
10
10
  model?: string;
11
11
  };
12
+ maxTurns?: number;
13
+ contextBudgetTokens?: number;
14
+ skillIndexBudgetTokens?: number;
12
15
  }
13
16
  export interface AgentEditResult {
14
17
  systemPrompt?: string;
@@ -16,6 +19,9 @@ export interface AgentEditResult {
16
19
  provider: Provider;
17
20
  model: string;
18
21
  };
22
+ maxTurns?: number;
23
+ contextBudgetTokens?: number;
24
+ skillIndexBudgetTokens?: number;
19
25
  }
20
26
  /** Surgically rewrites just the fields given in `fields` — see this
21
27
  * file's own header comment for why, and for the "refuse rather than
@@ -45,6 +45,11 @@ const MODEL_ENV_VAR = {
45
45
  anthropic: 'ANTHROPIC_API_KEY',
46
46
  openai: 'OPENAI_API_KEY',
47
47
  deepseek: 'DEEPSEEK_API_KEY',
48
+ // Moonshot AI's own env var name, not KIMI_API_KEY — see
49
+ // core/model-calls/kimi-model-call.ts's own header comment for why.
50
+ kimi: 'MOONSHOT_API_KEY',
51
+ glm: 'GLM_API_KEY',
52
+ gemini: 'GEMINI_API_KEY',
48
53
  };
49
54
  // The exact shape agentIndexTemplate (bin/cli.ts) always generates for the
50
55
  // model field's trailing comment — used to recognize a comment this
@@ -89,6 +94,31 @@ function findConfigObjectLiteral(sourceFile) {
89
94
  function findProperty(obj, name) {
90
95
  return obj.properties.find((p) => p.name !== undefined && ts.isIdentifier(p.name) && p.name.text === name);
91
96
  }
97
+ // maxTurns/contextBudgetTokens/skillIndexBudgetTokens are the only three
98
+ // AgentConfig fields this file edits that are *usually absent* — every
99
+ // agent in this repo, and create-loopengine's own scaffold template,
100
+ // leaves all three unset and rides run-agent.ts's own defaults (25 /
101
+ // 100000 / 2000), unlike systemPrompt/model which agentIndexTemplate always
102
+ // writes out explicitly. So "the property isn't in this file" can't mean
103
+ // refuse here the way it does for a genuinely unusual systemPrompt/model
104
+ // shape — that would make every freshly scaffolded agent's limits
105
+ // permanently uneditable through this API. Missing properties are
106
+ // collected into `toInsert` instead and spliced in as new lines just
107
+ // before the object literal's closing brace (see editAgentFile's own use
108
+ // of this); an *existing* property whose value isn't a plain number
109
+ // literal is still refused, same "don't guess" rule as everything else
110
+ // in this file.
111
+ function upsertNumericProperty(configObj, sourceFile, edits, toInsert, name, value) {
112
+ const prop = findProperty(configObj, name);
113
+ if (!prop) {
114
+ toInsert.push(`${name}: ${value}`);
115
+ return;
116
+ }
117
+ if (!ts.isPropertyAssignment(prop) || !ts.isNumericLiteral(prop.initializer)) {
118
+ throw new AgentEditNotSupportedError(`${name} in this file is not a plain number literal — edit it directly.`);
119
+ }
120
+ edits.push({ start: prop.initializer.getStart(sourceFile), end: prop.initializer.getEnd(), text: String(value) });
121
+ }
92
122
  /** Surgically rewrites just the fields given in `fields` — see this
93
123
  * file's own header comment for why, and for the "refuse rather than
94
124
  * guess" rule this follows for anything that isn't a plain, recognizable
@@ -150,6 +180,31 @@ export function editAgentFile(agentName, fields) {
150
180
  edits.push({ start: comment.pos, end: comment.end, text: `// reads ${MODEL_ENV_VAR[provider]}` });
151
181
  }
152
182
  }
183
+ const toInsert = [];
184
+ for (const name of ['maxTurns', 'contextBudgetTokens', 'skillIndexBudgetTokens']) {
185
+ const value = fields[name];
186
+ if (value === undefined)
187
+ continue;
188
+ if (!Number.isInteger(value) || value < 1) {
189
+ throw new AgentEditNotSupportedError(`${name} must be a positive integer.`);
190
+ }
191
+ upsertNumericProperty(configObj, sourceFile, edits, toInsert, name, value);
192
+ result[name] = value;
193
+ }
194
+ if (toInsert.length) {
195
+ // Insert right before the closing "}", not right after the last
196
+ // property node — a hand-written trailing line comment there (like
197
+ // model's own "// reads ANTHROPIC_API_KEY" above) would otherwise end
198
+ // up with new properties spliced into the middle of it. The object's
199
+ // own pre-existing newline-before-"}" becomes the separator before
200
+ // the first inserted line for free; only an object with *no*
201
+ // properties at all needs its own leading newline.
202
+ const closeBracePos = configObj.getEnd() - 1;
203
+ const hasProps = configObj.properties.length > 0;
204
+ const needsLeadingComma = hasProps && !configObj.properties.hasTrailingComma;
205
+ const text = (hasProps ? '' : '\n') + (needsLeadingComma ? ',' : '') + toInsert.map((p) => ` ${p},\n`).join('');
206
+ edits.push({ start: closeBracePos, end: closeBracePos, text });
207
+ }
153
208
  if (!edits.length)
154
209
  return result;
155
210
  // Apply from the end of the file backward — each edit's start/end was
@@ -131,12 +131,12 @@ export const agentsConfigPageHtml = `<!doctype html>
131
131
  letter-spacing: normal;
132
132
  padding: 2px 8px;
133
133
  }
134
- .http-tool-subsection {
134
+ .admin-subsection {
135
135
  margin-top: 14px;
136
136
  padding-top: 14px;
137
137
  border-top: 1px dashed light-dark(#eee, #2a2a2e);
138
138
  }
139
- .http-tool-subsection h4 {
139
+ .admin-subsection h4 {
140
140
  font-size: 12px;
141
141
  text-transform: uppercase;
142
142
  letter-spacing: 0.04em;
@@ -309,6 +309,84 @@ export const agentsConfigPageHtml = `<!doctype html>
309
309
  stretches the checkbox to the full row width, which visually
310
310
  overlaps the description text under it instead of sitting beside it. */
311
311
  form.add-source input[type="checkbox"] { width: auto; flex-shrink: 0; margin-top: 2px; }
312
+ /* Same column-stacking issue as .tool-picker-item above: .http-tool-
313
+ inline-label (the "required" / "send as JSON body" checkboxes in the
314
+ HTTP tool builder) is a <label> too, so "form.add-source label" wins
315
+ on specificity alone and stacks the checkbox above its own text
316
+ instead of beside it — this rule needs to match "label" too to win. */
317
+ form.add-source label.http-tool-inline-label {
318
+ display: flex;
319
+ flex-direction: row;
320
+ align-items: center;
321
+ gap: 6px;
322
+ }
323
+ /* These three are plain <div>s of sibling inputs/selects/buttons, not
324
+ <label>s, so none of the rules above touch them — without an explicit
325
+ row layout they just wrap at the browser's default inline spacing,
326
+ which reads as everything jammed together with no breathing room.
327
+ form.add-source itself caps at max-width: 480px (see above), which
328
+ isn't wide enough to hold a field row's 5 items (name/type/
329
+ description/required/delete) on one line at a readable width, so
330
+ this wraps rather than forcing a single cramped line — each item
331
+ gets a min-width so text inputs wrap onto their own line instead of
332
+ shrinking to an unreadable sliver. */
333
+ .http-tool-field-row, .http-tool-header-row, .http-tool-method-url {
334
+ display: flex;
335
+ flex-wrap: wrap;
336
+ align-items: center;
337
+ gap: 8px;
338
+ margin-bottom: 8px;
339
+ }
340
+ /* Same "form.add-source input { width: 100% }" override as the checkbox
341
+ fix above — each field here sits beside its siblings in a row, not
342
+ stacked full-width above them. */
343
+ .http-tool-field-row input[type="text"],
344
+ .http-tool-field-row select,
345
+ .http-tool-header-row input[type="text"],
346
+ .http-tool-method-url input[type="text"],
347
+ .http-tool-method-url select {
348
+ width: auto;
349
+ }
350
+ .http-tool-field-row .http-tool-field-name { flex: 1 1 100px; min-width: 100px; }
351
+ .http-tool-field-row .http-tool-field-type { flex: 0 0 auto; }
352
+ .http-tool-field-row .http-tool-field-description { flex: 2 1 140px; min-width: 140px; }
353
+ .http-tool-field-row .http-tool-inline-label { flex: 0 0 auto; white-space: nowrap; }
354
+ .http-tool-header-row .http-tool-header-key { flex: 1 1 100px; min-width: 100px; }
355
+ .http-tool-header-row .http-tool-header-value { flex: 2 1 140px; min-width: 140px; }
356
+ .http-tool-method-url select { flex: 0 0 auto; }
357
+ .http-tool-method-url input[type="text"] { flex: 1 1 160px; min-width: 160px; }
358
+ .http-tool-field-row .http-tool-remove-row,
359
+ .http-tool-header-row .http-tool-remove-row {
360
+ flex: 0 0 auto;
361
+ margin-left: auto;
362
+ }
363
+ /* Taller than .hint-btn's own 2px 8px default (shared with the Gateway
364
+ Tools "Refresh" button, which sits inline in an <h3> and shouldn't
365
+ grow) — these two sit on their own line below the field/header rows,
366
+ so there's no header-row height to match. */
367
+ #httpToolAddFieldBtn, #httpToolAddHeaderBtn {
368
+ padding: 6px 12px;
369
+ }
370
+ .skillgarden-category-badge {
371
+ display: inline-block;
372
+ font-family: ui-sans-serif, system-ui, sans-serif;
373
+ font-size: 10px;
374
+ font-weight: 600;
375
+ text-transform: uppercase;
376
+ letter-spacing: 0.03em;
377
+ padding: 1px 7px;
378
+ border-radius: 999px;
379
+ background: light-dark(#eef1ec, #1e2519);
380
+ color: light-dark(#3f6b4a, #7fbf8f);
381
+ margin-right: 6px;
382
+ }
383
+ #skillgardenFilterInput { margin-bottom: 8px; }
384
+ .skillgarden-catalog-body {
385
+ margin-top: 8px;
386
+ padding-top: 8px;
387
+ border-top: 1px solid light-dark(#eee, #2a2a2e);
388
+ font-size: 13px;
389
+ }
312
390
  </style>
313
391
  </head>
314
392
  <body>
@@ -575,7 +653,7 @@ export const agentsConfigPageHtml = `<!doctype html>
575
653
  '<form class="add-source" id="modelForm" style="display:none">' +
576
654
  '<label>Provider' +
577
655
  '<select name="provider">' +
578
- ['anthropic', 'openai', 'deepseek'].map(function (p) {
656
+ ['anthropic', 'openai', 'deepseek', 'kimi', 'glm', 'gemini'].map(function (p) {
579
657
  return '<option value="' + p + '"' + (p === cfg.model.provider ? ' selected' : '') + '>' + p + '</option>';
580
658
  }).join('') +
581
659
  '</select>' +
@@ -599,15 +677,31 @@ export const agentsConfigPageHtml = `<!doctype html>
599
677
  '<dt>tenantFor</dt><dd>' + badge(cfg.tenantFor.indexOf('custom') === 0 ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.tenantFor) + '</span></dd>' +
600
678
  '<dt>isSafeTool</dt><dd>' + badge(cfg.isSafeTool === 'custom' ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.isSafeTool) + '</span></dd>' +
601
679
  '</dl></section>' +
602
- '<section><h3>Limits &amp; budgets</h3><dl class="kv">' +
603
- '<dt>maxTurns</dt><dd>' + escapeHtml(cfg.maxTurns) + '</dd>' +
604
- '<dt>contextBudgetTokens</dt><dd>' + escapeHtml(cfg.contextBudgetTokens) + '</dd>' +
605
- '<dt>skillIndexBudgetTokens</dt><dd>' + escapeHtml(cfg.skillIndexBudgetTokens) + '</dd>' +
606
- '<dt>skillsDirs</dt><dd>' + escapeHtml(cfg.skillsDirs.join(', ') || '(none)') + '</dd>' +
607
- '</dl></section>';
680
+ '<section><h3>Limits &amp; budgets <button type="button" class="edit-btn" id="editLimitsBtn">Edit</button></h3>' +
681
+ '<dl class="kv" id="limitsDisplay">' +
682
+ '<dt>maxTurns</dt><dd>' + escapeHtml(cfg.maxTurns) + '</dd>' +
683
+ '<dt>contextBudgetTokens</dt><dd>' + escapeHtml(cfg.contextBudgetTokens) + '</dd>' +
684
+ '<dt>skillIndexBudgetTokens</dt><dd>' + escapeHtml(cfg.skillIndexBudgetTokens) + '</dd>' +
685
+ '</dl>' +
686
+ '<form class="add-source" id="limitsForm" style="display:none">' +
687
+ '<label>Max turns <span class="hint">(hard cap on model calls in one turn)</span>' +
688
+ '<input type="number" name="maxTurns" min="1" step="1" value="' + escapeHtml(cfg.maxTurns) + '">' +
689
+ '</label>' +
690
+ '<label>Context budget <span class="hint">(tokens — triggers compaction of older messages once exceeded)</span>' +
691
+ '<input type="number" name="contextBudgetTokens" min="1" step="1" value="' + escapeHtml(cfg.contextBudgetTokens) + '">' +
692
+ '</label>' +
693
+ '<label>Skill index budget <span class="hint">(tokens — caps the always-loaded skill name/description index)</span>' +
694
+ '<input type="number" name="skillIndexBudgetTokens" min="1" step="1" value="' + escapeHtml(cfg.skillIndexBudgetTokens) + '">' +
695
+ '</label>' +
696
+ '<button type="submit">Save</button>' +
697
+ '<button type="button" id="cancelLimitsBtn">Cancel</button>' +
698
+ '<div class="error" id="limitsError"></div>' +
699
+ '</form>' +
700
+ '<dl class="kv"><dt>skillsDirs</dt><dd>' + escapeHtml(cfg.skillsDirs.join(', ') || '(none)') + '</dd></dl>' +
701
+ '</section>';
608
702
  }
609
703
 
610
- // Wires the System prompt / Model Edit buttons rendered by
704
+ // Wires the System prompt / Model / Limits & budgets Edit buttons rendered by
611
705
  // renderOverviewHtml above — called every time that HTML gets (re)set
612
706
  // (renderDetail, refreshOverviewPanel, refreshSkillsDependentPanels),
613
707
  // same pattern every other tab's own wireXHandlers already follows.
@@ -708,6 +802,53 @@ export const agentsConfigPageHtml = `<!doctype html>
708
802
  });
709
803
  });
710
804
  }
805
+
806
+ var editLimitsBtn = overviewPanel.querySelector('#editLimitsBtn');
807
+ if (editLimitsBtn) {
808
+ var limitsDisplay = overviewPanel.querySelector('#limitsDisplay');
809
+ var limitsForm = overviewPanel.querySelector('#limitsForm');
810
+ var limitsError = overviewPanel.querySelector('#limitsError');
811
+
812
+ editLimitsBtn.addEventListener('click', function () {
813
+ limitsDisplay.style.display = 'none';
814
+ editLimitsBtn.style.display = 'none';
815
+ limitsForm.style.display = '';
816
+ });
817
+ overviewPanel.querySelector('#cancelLimitsBtn').addEventListener('click', function () {
818
+ limitsForm.style.display = 'none';
819
+ limitsDisplay.style.display = '';
820
+ editLimitsBtn.style.display = '';
821
+ limitsError.textContent = '';
822
+ });
823
+ limitsForm.addEventListener('submit', function (ev) {
824
+ ev.preventDefault();
825
+ limitsError.textContent = '';
826
+ var data = new FormData(limitsForm);
827
+ var submitBtn = limitsForm.querySelector('button[type="submit"]');
828
+ submitBtn.disabled = true;
829
+ var body = {
830
+ maxTurns: Number(data.get('maxTurns')),
831
+ contextBudgetTokens: Number(data.get('contextBudgetTokens')),
832
+ skillIndexBudgetTokens: Number(data.get('skillIndexBudgetTokens')),
833
+ };
834
+ fetch('/agents/' + encodeURIComponent(name), {
835
+ method: 'PUT',
836
+ headers: { 'content-type': 'application/json' },
837
+ body: JSON.stringify(body),
838
+ })
839
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
840
+ .then(function (result) {
841
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
842
+ currentCfg = result.body;
843
+ overviewPanel.innerHTML = renderOverviewHtml(result.body);
844
+ wireOverviewHandlers(name);
845
+ })
846
+ .catch(function (err) {
847
+ limitsError.textContent = err.message;
848
+ submitBtn.disabled = false;
849
+ });
850
+ });
851
+ }
711
852
  }
712
853
 
713
854
  // ---- Overview's own Actauth section: read-only, from cfg.permissions
@@ -1016,18 +1157,194 @@ export const agentsConfigPageHtml = `<!doctype html>
1016
1157
  '<button type="submit" id="skillSubmitBtn">Add</button>' +
1017
1158
  '<button type="button" id="skillCancelBtn" style="display:none">Cancel</button>' +
1018
1159
  '<div id="skillFormError" class="error"></div>' +
1019
- '</form></section>';
1160
+ '</form></section>' +
1161
+ '<section><h3>Browse skillgarden catalog</h3>' +
1162
+ '<input type="text" id="skillgardenFilterInput" placeholder="Filter by name or category&hellip;">' +
1163
+ '<div id="skillgardenCatalogList"><p class="hint">Loading&hellip;</p></div>' +
1164
+ '</section>';
1020
1165
  }
1021
1166
 
1022
1167
  function skillsPanelEl() {
1023
1168
  return detail.querySelector('[data-tab-panel="skills"]');
1024
1169
  }
1025
1170
 
1171
+ // ---- Skills tab's "Browse skillgarden catalog" section: a
1172
+ // read-only, agent-independent list of everything GET /skillgarden-
1173
+ // catalog reports (see web/skillgarden-admin.ts) — cached in this one
1174
+ // module-level variable, not per-agent like gatewayLoadedFor, since
1175
+ // the catalog itself never varies by agent. renderSkillsTabHtml
1176
+ // regenerates the whole Skills panel (including this section's own
1177
+ // markup) on every agent switch/refresh, so loadSkillgardenCatalogSection
1178
+ // re-renders from this cache instead of re-fetching every time — only
1179
+ // the very first time this tab is opened in the page's lifetime hits
1180
+ // the network at all. ----
1181
+ var skillgardenCatalog = null;
1182
+
1183
+ function skillgardenCatalogListEl() {
1184
+ var panel = skillsPanelEl();
1185
+ return panel ? panel.querySelector('#skillgardenCatalogList') : null;
1186
+ }
1187
+
1188
+ function renderSkillgardenCatalogEntry(entry) {
1189
+ return '<div class="source" data-category="' + escapeHtml(entry.category) + '" data-id="' + escapeHtml(entry.id) + '">' +
1190
+ '<div class="source-head">' +
1191
+ '<h4><span class="skillgarden-category-badge">' + escapeHtml(entry.category) + '</span>' + escapeHtml(entry.id) + '</h4>' +
1192
+ '<span>' +
1193
+ '<button type="button" class="skillgarden-view-btn" data-category="' + escapeHtml(entry.category) + '" data-id="' + escapeHtml(entry.id) + '">View</button> ' +
1194
+ '<button type="button" class="skillgarden-add-btn" data-category="' + escapeHtml(entry.category) + '" data-id="' + escapeHtml(entry.id) + '">Add to this agent</button>' +
1195
+ '</span>' +
1196
+ '</div>' +
1197
+ '<p class="hint">' + escapeHtml(entry.description) + '</p>' +
1198
+ '<div class="skillgarden-catalog-body" style="display:none"></div>' +
1199
+ '</div>';
1200
+ }
1201
+
1202
+ function renderSkillgardenCatalogList(entries) {
1203
+ return entries.length ? entries.map(renderSkillgardenCatalogEntry).join('') : '<p class="hint">No skills match.</p>';
1204
+ }
1205
+
1206
+ function applySkillgardenCatalog(name, entries) {
1207
+ var listEl = skillgardenCatalogListEl();
1208
+ if (!listEl) return;
1209
+ listEl.innerHTML = renderSkillgardenCatalogList(entries);
1210
+ wireSkillgardenCatalogHandlers(name);
1211
+ }
1212
+
1213
+ function filteredSkillgardenCatalog(filterText) {
1214
+ if (!filterText) return skillgardenCatalog;
1215
+ var needle = filterText.toLowerCase();
1216
+ return skillgardenCatalog.filter(function (entry) {
1217
+ return entry.category.toLowerCase().indexOf(needle) !== -1 || entry.id.toLowerCase().indexOf(needle) !== -1;
1218
+ });
1219
+ }
1220
+
1221
+ function loadSkillgardenCatalogSection(name) {
1222
+ var listEl = skillgardenCatalogListEl();
1223
+ if (!listEl) return;
1224
+ if (skillgardenCatalog) {
1225
+ var panel = skillsPanelEl();
1226
+ var filterInput = panel ? panel.querySelector('#skillgardenFilterInput') : null;
1227
+ applySkillgardenCatalog(name, filteredSkillgardenCatalog(filterInput ? filterInput.value.trim() : ''));
1228
+ return;
1229
+ }
1230
+ listEl.innerHTML = '<p class="hint">Loading&hellip;</p>';
1231
+ fetch('/skillgarden-catalog')
1232
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1233
+ .then(function (result) {
1234
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
1235
+ skillgardenCatalog = result.body;
1236
+ if (currentName !== name) return;
1237
+ applySkillgardenCatalog(name, skillgardenCatalog);
1238
+ })
1239
+ .catch(function (err) {
1240
+ if (currentName !== name) return;
1241
+ listEl.innerHTML = '<p class="error">Could not load skillgarden catalog: ' + escapeHtml(err.message) + '</p>';
1242
+ });
1243
+ }
1244
+
1245
+ // force=true retries as an explicit overwrite after the operator
1246
+ // confirms — same "ask once a plain add collides, don't ask up front"
1247
+ // flow the rest of this page never has to think about since nothing
1248
+ // else lets an operator add something whose id they don't already
1249
+ // control the uniqueness of themselves.
1250
+ function performSkillgardenAdd(name, category, id, btn, originalText, force) {
1251
+ fetch('/agents/' + encodeURIComponent(name) + '/skillgarden-catalog/' + encodeURIComponent(category) + '/' + encodeURIComponent(id), {
1252
+ method: 'POST',
1253
+ headers: { 'content-type': 'application/json' },
1254
+ body: JSON.stringify({ force: !!force }),
1255
+ })
1256
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1257
+ .then(function (result) {
1258
+ if (result.ok) {
1259
+ refreshSkillsDependentPanels(name);
1260
+ return;
1261
+ }
1262
+ if (!force && /already exists/.test(result.body.error || '')) {
1263
+ if (confirm('A skill named "' + id + '" already exists for this agent. Overwrite it?')) {
1264
+ performSkillgardenAdd(name, category, id, btn, originalText, true);
1265
+ return;
1266
+ }
1267
+ btn.disabled = false;
1268
+ btn.textContent = originalText;
1269
+ return;
1270
+ }
1271
+ throw new Error(result.body.error || 'request failed');
1272
+ })
1273
+ .catch(function (err) {
1274
+ alert('Could not add "' + id + '": ' + err.message);
1275
+ btn.disabled = false;
1276
+ btn.textContent = originalText;
1277
+ });
1278
+ }
1279
+
1280
+ function wireSkillgardenCatalogHandlers(name) {
1281
+ var listEl = skillgardenCatalogListEl();
1282
+ if (!listEl) return;
1283
+
1284
+ var viewButtons = listEl.querySelectorAll('.skillgarden-view-btn');
1285
+ for (var i = 0; i < viewButtons.length; i++) {
1286
+ viewButtons[i].addEventListener('click', function (ev) {
1287
+ var btn = ev.currentTarget;
1288
+ var category = btn.getAttribute('data-category');
1289
+ var id = btn.getAttribute('data-id');
1290
+ var card = btn.closest('.source');
1291
+ var bodyEl = card.querySelector('.skillgarden-catalog-body');
1292
+ if (bodyEl.style.display !== 'none') {
1293
+ bodyEl.style.display = 'none';
1294
+ btn.textContent = 'View';
1295
+ return;
1296
+ }
1297
+ if (bodyEl.getAttribute('data-loaded') === '1') {
1298
+ bodyEl.style.display = 'block';
1299
+ btn.textContent = 'Hide';
1300
+ return;
1301
+ }
1302
+ btn.disabled = true;
1303
+ fetch('/skillgarden-catalog/' + encodeURIComponent(category) + '/' + encodeURIComponent(id))
1304
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1305
+ .then(function (result) {
1306
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
1307
+ bodyEl.innerHTML = renderMarkdownPreview(result.body.body);
1308
+ bodyEl.setAttribute('data-loaded', '1');
1309
+ bodyEl.style.display = 'block';
1310
+ btn.textContent = 'Hide';
1311
+ })
1312
+ .catch(function (err) {
1313
+ bodyEl.innerHTML = '<p class="error">' + escapeHtml(err.message) + '</p>';
1314
+ bodyEl.style.display = 'block';
1315
+ })
1316
+ .then(function () { btn.disabled = false; });
1317
+ });
1318
+ }
1319
+
1320
+ var addButtons = listEl.querySelectorAll('.skillgarden-add-btn');
1321
+ for (var j = 0; j < addButtons.length; j++) {
1322
+ addButtons[j].addEventListener('click', function (ev) {
1323
+ var btn = ev.currentTarget;
1324
+ var category = btn.getAttribute('data-category');
1325
+ var id = btn.getAttribute('data-id');
1326
+ var originalText = btn.textContent;
1327
+ btn.disabled = true;
1328
+ btn.textContent = 'Adding…';
1329
+ performSkillgardenAdd(name, category, id, btn, originalText, false);
1330
+ });
1331
+ }
1332
+ }
1333
+
1026
1334
  function wireSkillsHandlers(name) {
1027
1335
  var panel = skillsPanelEl();
1028
1336
  if (!panel) return;
1029
1337
  var editingSkillId = null;
1030
1338
 
1339
+ loadSkillgardenCatalogSection(name);
1340
+ var skillgardenFilterInput = panel.querySelector('#skillgardenFilterInput');
1341
+ if (skillgardenFilterInput) {
1342
+ skillgardenFilterInput.addEventListener('input', function () {
1343
+ if (!skillgardenCatalog) return;
1344
+ applySkillgardenCatalog(name, filteredSkillgardenCatalog(skillgardenFilterInput.value.trim()));
1345
+ });
1346
+ }
1347
+
1031
1348
  // Write/Preview toggle for the Body field — same idea as GitHub's own
1032
1349
  // issue/PR editor: the textarea and the rendered preview show/hide
1033
1350
  // each other, the textarea's value is the only source of truth (the
@@ -1158,7 +1475,7 @@ export const agentsConfigPageHtml = `<!doctype html>
1158
1475
 
1159
1476
  function renderToolsTabHtml(cfg) {
1160
1477
  return '<section><h3>Local tools (' + cfg.localTools.length + ')</h3>' + renderTools(cfg.localTools) +
1161
- '<div class="http-tool-subsection"><h4>Create an HTTP tool</h4>' + renderHttpToolFormHtml() + '</div>' +
1478
+ '<div class="admin-subsection"><h4>Create an HTTP tool</h4>' + renderHttpToolFormHtml() + '</div>' +
1162
1479
  '</section>' +
1163
1480
  '<section id="gatewayToolsSection">' +
1164
1481
  '<h3>Gateway Tools <button type="button" id="gatewayToolsRefreshBtn" class="hint-btn">Refresh</button></h3>' +
@@ -1207,7 +1524,7 @@ export const agentsConfigPageHtml = `<!doctype html>
1207
1524
  '<input type="text" name="name" required pattern="[a-z][a-z0-9_]*" placeholder="lookup_order_status">' +
1208
1525
  '</label>' +
1209
1526
  '<label>Description' +
1210
- '<input type="text" name="description" required placeholder="What this tool does">' +
1527
+ '<textarea name="description" id="httpToolDescriptionInput" required placeholder="What this tool does"></textarea>' +
1211
1528
  '</label>' +
1212
1529
  '<label>Fields' +
1213
1530
  '<div id="httpToolFieldRows"></div>' +
@@ -1,6 +1,6 @@
1
1
  import type { AgentConfig } from '../core/agent-config.js';
2
2
  export interface ModelProviderInfo {
3
- provider: 'anthropic' | 'openai' | 'deepseek';
3
+ provider: 'anthropic' | 'openai' | 'deepseek' | 'kimi' | 'glm' | 'gemini';
4
4
  envVar: string;
5
5
  configured: boolean;
6
6
  }
@@ -8,6 +8,11 @@ const KNOWN_PROVIDERS = [
8
8
  { provider: 'anthropic', envVar: 'ANTHROPIC_API_KEY' },
9
9
  { provider: 'openai', envVar: 'OPENAI_API_KEY' },
10
10
  { provider: 'deepseek', envVar: 'DEEPSEEK_API_KEY' },
11
+ // Moonshot AI's own env var name, not KIMI_API_KEY — see
12
+ // core/model-calls/kimi-model-call.ts's own header comment for why.
13
+ { provider: 'kimi', envVar: 'MOONSHOT_API_KEY' },
14
+ { provider: 'glm', envVar: 'GLM_API_KEY' },
15
+ { provider: 'gemini', envVar: 'GEMINI_API_KEY' },
11
16
  ];
12
17
  /** Every registered agent's resolved model, alongside whether each
13
18
  * supported provider actually has credentials available in this
@@ -0,0 +1,24 @@
1
+ export declare class SkillgardenUnavailableError extends Error {
2
+ }
3
+ export interface SkillgardenCatalogEntry {
4
+ category: string;
5
+ id: string;
6
+ description: string;
7
+ }
8
+ export interface SkillgardenCatalogDetail extends SkillgardenCatalogEntry {
9
+ body: string;
10
+ }
11
+ export declare function listSkillgardenCatalog(): SkillgardenCatalogEntry[];
12
+ export declare function readSkillgardenCatalogEntry(category: string, id: string): SkillgardenCatalogDetail;
13
+ /** Copies `registry/<category>/<id>/` into `agents/<agentName>/skills/<id>/`
14
+ * — delegates entirely to skillgarden's own `addSkill`, which already
15
+ * knows this exact `agents/<agent>/skills` destination convention (see
16
+ * its own doc comment) and already refuses (or, with `force`,
17
+ * overwrites) an existing destination. Returns the bare skill id actually
18
+ * installed — never category-qualified, matching `addSkill`'s own
19
+ * "category is a registry-organization concept, not forced onto what a
20
+ * caller now owns" convention. */
21
+ export declare function addSkillgardenSkillToAgent(agentName: string, category: string, id: string, force?: boolean): {
22
+ id: string;
23
+ path: string;
24
+ };
@@ -0,0 +1,76 @@
1
+ // Lets an operator browse skillgarden's own bundled skill registry from
2
+ // the Skills tab in /agents/config, and add one straight into an agent's
3
+ // agents/<name>/skills/ folder — a shortcut for the same thing
4
+ // `npx skillgarden add <skill> --agent <name>` already does from a
5
+ // terminal, surfaced in the UI instead. This module only ever *reads*
6
+ // the registry and delegates the actual copy to skillgarden's own
7
+ // exported `addSkill` (see below) — it never re-implements that logic.
8
+ import { createRequire } from 'node:module';
9
+ import { dirname, join } from 'node:path';
10
+ import { SkillGarden, addSkill as skillgardenAddSkill } from 'skillgarden';
11
+ export class SkillgardenUnavailableError extends Error {
12
+ }
13
+ // skillgarden's own CLI resolves its bundled registry/ relative to its
14
+ // own compiled dist/cli.js location (see its cli.ts's own
15
+ // DEFAULT_REGISTRY_DIR), but doesn't export that path — `addSkill`
16
+ // itself defaults to it internally, which is enough for *installing* a
17
+ // skill, but browsing/previewing the catalog needs the directory path
18
+ // itself. Re-derived here the same way Node's own module resolution
19
+ // would: from package.json's real installed location, not assumed
20
+ // relative to this file (skillgarden is a real npm dependency, not a
21
+ // vendored copy).
22
+ function registryDir() {
23
+ const require = createRequire(import.meta.url);
24
+ let pkgJsonPath;
25
+ try {
26
+ pkgJsonPath = require.resolve('skillgarden/package.json');
27
+ }
28
+ catch {
29
+ throw new SkillgardenUnavailableError('skillgarden is not installed — run `npm install skillgarden` to enable the skill catalog.');
30
+ }
31
+ return join(dirname(pkgJsonPath), 'registry');
32
+ }
33
+ // A large, fixed budget, not run-agent.ts's own live skillIndexBudgetTokens
34
+ // default — this indexes a small, fixed, bundled catalog for browsing,
35
+ // not an agent's own live context, so nothing here should ever silently
36
+ // truncate the list an operator sees.
37
+ const CATALOG_INDEX_BUDGET_TOKENS = 1_000_000;
38
+ // discoverSkillFiles namespaces registry/<category>/<skill>/SKILL.md as
39
+ // "<category>:<skill>" (colon-joined, one level of nesting) — split on
40
+ // just the first colon, not skillId itself, in case a future entry ever
41
+ // nests deeper than one category level.
42
+ function splitNamespacedName(namespacedName) {
43
+ const colon = namespacedName.indexOf(':');
44
+ return { category: namespacedName.slice(0, colon), id: namespacedName.slice(colon + 1) };
45
+ }
46
+ function catalogGarden() {
47
+ return new SkillGarden({ dirs: [registryDir()], indexBudgetTokens: CATALOG_INDEX_BUDGET_TOKENS });
48
+ }
49
+ export function listSkillgardenCatalog() {
50
+ const { included } = catalogGarden().buildIndex();
51
+ return included
52
+ .map((entry) => ({ ...splitNamespacedName(entry.name), description: entry.description }))
53
+ .sort((a, b) => (a.category === b.category ? a.id.localeCompare(b.id) : a.category.localeCompare(b.category)));
54
+ }
55
+ export function readSkillgardenCatalogEntry(category, id) {
56
+ const garden = catalogGarden();
57
+ garden.buildIndex();
58
+ // load() throws a plain Error for an unindexed name — same "not found"
59
+ // shape a bad category/id combination should surface as; the caller
60
+ // (adapters/http.ts) maps any throw here to 404, so there's nothing
61
+ // more specific to catch and re-throw.
62
+ const loaded = garden.load(`${category}:${id}`);
63
+ return { category, id, description: loaded.description, body: loaded.body };
64
+ }
65
+ /** Copies `registry/<category>/<id>/` into `agents/<agentName>/skills/<id>/`
66
+ * — delegates entirely to skillgarden's own `addSkill`, which already
67
+ * knows this exact `agents/<agent>/skills` destination convention (see
68
+ * its own doc comment) and already refuses (or, with `force`,
69
+ * overwrites) an existing destination. Returns the bare skill id actually
70
+ * installed — never category-qualified, matching `addSkill`'s own
71
+ * "category is a registry-organization concept, not forced onto what a
72
+ * caller now owns" convention. */
73
+ export function addSkillgardenSkillToAgent(agentName, category, id, force = false) {
74
+ const result = skillgardenAddSkill({ skill: `${category}/${id}`, agent: agentName, registryDir: registryDir(), force });
75
+ return { id, path: join(result.destination, 'SKILL.md') };
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
@@ -41,6 +41,12 @@
41
41
  "dist/core/model-calls/openai-model-call.d.ts",
42
42
  "dist/core/model-calls/deepseek-model-call.js",
43
43
  "dist/core/model-calls/deepseek-model-call.d.ts",
44
+ "dist/core/model-calls/kimi-model-call.js",
45
+ "dist/core/model-calls/kimi-model-call.d.ts",
46
+ "dist/core/model-calls/glm-model-call.js",
47
+ "dist/core/model-calls/glm-model-call.d.ts",
48
+ "dist/core/model-calls/gemini-model-call.js",
49
+ "dist/core/model-calls/gemini-model-call.d.ts",
44
50
  "dist/core/discover-agents.js",
45
51
  "dist/core/discover-agents.d.ts",
46
52
  "dist/core/agent-as-tool.js",
@@ -78,7 +84,7 @@
78
84
  "mcpplug": "^0.0.1",
79
85
  "openai": "^7.4.0",
80
86
  "sessionknit": "^0.0.2",
81
- "skillgarden": "^0.0.5",
87
+ "skillgarden": "^0.0.9",
82
88
  "toollane": "^0.0.1",
83
89
  "typescript": "^5.7.0",
84
90
  "yaml": "^2.6.0"