@bike4mind/cli 0.16.0 → 0.17.1
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/bin/bike4mind-cli.mjs +8 -0
- package/dist/{tools-D9eSRR7Q.mjs → BackgroundAgentManager-C5HEirFN.mjs} +8565 -8372
- package/dist/{ConfigStore-CtGc4fF2.mjs → ConfigStore-gV8aHo9b.mjs} +405 -21
- package/dist/commands/apiCommand.mjs +1 -1
- package/dist/commands/doctorCommand.mjs +1 -1
- package/dist/commands/envCommand.mjs +1 -1
- package/dist/commands/headlessCommand.mjs +6 -2
- package/dist/commands/mcpCommand.mjs +1 -1
- package/dist/commands/updateCommand.mjs +1 -1
- package/dist/index.mjs +2241 -2055
- package/dist/{package-3pouvthv.mjs → package-Dzetdo6D.mjs} +1 -1
- package/package.json +13 -9
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as
|
|
2
|
+
import { $ as CommandHistoryStore, A as substituteArguments, B as DEFAULT_RETRY_CONFIG, C as createCoordinateTaskTool, D as FallbackLlmBackend, E as ApiClient, F as generateCliTools, G as ReActAgent, H as clearFeatureModuleTools, I as getProcessHooks, J as buildSkillsPromptSection, K as getPlanModeFilePath, L as ALWAYS_DENIED_FOR_AGENTS, M as extractCompactInstructions, N as loadContextFiles, O as ServerLlmBackend, P as PermissionManager, Q as CheckpointStore, R as DEFAULT_AGENT_MODEL, S as parseAgentConfig, T as createAgentDelegateTool, U as registerFeatureModuleTools, V as DEFAULT_THOROUGHNESS, W as setWebSocketToolExecutor, X as RemoteSkillSource, Y as isReadOnlyTool, Z as CustomCommandStore, _ as createGetFileStructureTool, a as WebSocketToolExecutor, at as mergeCommands, b as createWriteTodosTool, c as createReviewGateStore, ct as warmFileCache, d as createBlockerStore, et as SessionStore, f as createBlockerTools, g as formatDecisionsOutput, h as createDecisionStore, i as McpManager, it as searchCommands, j as formatStep, k as isTransientNetworkError, l as createReviewGateTool, m as createDecisionLogTool, n as SubagentOrchestrator, nt as hasFileReferences, o as WebSocketConnectionManager, ot as formatFileSize, p as formatBlockersOutput, q as buildSystemPrompt, r as AgentStore, rt as processFileReferences, s as WebSocketLlmBackend, st as searchFiles, t as BackgroundAgentManager, tt as OAuthClient, u as formatReviewGatesOutput, v as createFindDefinitionTool, w as createBackgroundAgentTools, x as createSkillTool, y as createTodoStore, z as DEFAULT_MAX_ITERATIONS } from "./BackgroundAgentManager-C5HEirFN.mjs";
|
|
3
3
|
import { n as useCliStore, t as selectActiveBackgroundAgents } from "./store-DV5s-qni.mjs";
|
|
4
|
-
import {
|
|
5
|
-
import { t as version } from "./package-
|
|
4
|
+
import { Jt as validateJupyterKernelName, Yt as validateNotebookPath$1, g as CREDIT_DEDUCT_TRANSACTION_TYPES, i as getEnvironmentName, n as logger, r as getApiUrl, t as ConfigStore, v as ChatModels } from "./ConfigStore-gV8aHo9b.mjs";
|
|
5
|
+
import { t as version } from "./package-Dzetdo6D.mjs";
|
|
6
6
|
import { r as checkForUpdate } from "./updateChecker-C8xsNY2L.mjs";
|
|
7
7
|
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
|
8
8
|
import { Box, Static, Text, render, useApp, useInput, usePaste, useStdout } from "ink";
|
|
@@ -3287,1104 +3287,625 @@ var MessageBuilder = class {
|
|
|
3287
3287
|
}
|
|
3288
3288
|
};
|
|
3289
3289
|
//#endregion
|
|
3290
|
-
//#region src/
|
|
3290
|
+
//#region src/llm/NotifyingLlmBackend.ts
|
|
3291
3291
|
/**
|
|
3292
|
-
*
|
|
3293
|
-
*
|
|
3294
|
-
* Provides methods for interacting with a local Jupyter Server instance.
|
|
3295
|
-
* Used by Keep commands to execute notebooks on the user's local machine.
|
|
3292
|
+
* LLM backend wrapper that injects background agent notifications
|
|
3293
|
+
* into the message array before each completion call.
|
|
3296
3294
|
*
|
|
3297
|
-
*
|
|
3298
|
-
*
|
|
3295
|
+
* When a background agent completes (or fails), the notification
|
|
3296
|
+
* appears as a system message so the main agent naturally sees it
|
|
3297
|
+
* in context — no polling required.
|
|
3299
3298
|
*/
|
|
3300
|
-
var
|
|
3301
|
-
constructor(
|
|
3302
|
-
|
|
3303
|
-
this.
|
|
3304
|
-
|
|
3305
|
-
|
|
3299
|
+
var NotifyingLlmBackend = class {
|
|
3300
|
+
constructor(inner, backgroundManager) {
|
|
3301
|
+
this.inner = inner;
|
|
3302
|
+
this.backgroundManager = backgroundManager;
|
|
3303
|
+
}
|
|
3304
|
+
get currentModel() {
|
|
3305
|
+
return this.inner.currentModel;
|
|
3306
|
+
}
|
|
3307
|
+
set currentModel(model) {
|
|
3308
|
+
this.inner.currentModel = model;
|
|
3309
|
+
}
|
|
3310
|
+
async complete(model, messages, options, callback) {
|
|
3311
|
+
const notifications = this.backgroundManager.drainNotifications();
|
|
3312
|
+
let effectiveMessages = messages;
|
|
3313
|
+
if (notifications.length > 0) {
|
|
3314
|
+
const notificationMessage = {
|
|
3315
|
+
role: "user",
|
|
3316
|
+
content: `[System Notification]\n\n${notifications.join("\n\n---\n\n")}\n\nPlease acknowledge these background agent results and incorporate them into your current work.`
|
|
3317
|
+
};
|
|
3318
|
+
effectiveMessages = [...messages, notificationMessage];
|
|
3319
|
+
}
|
|
3320
|
+
return this.inner.complete(model, effectiveMessages, options, callback);
|
|
3321
|
+
}
|
|
3322
|
+
pushToolMessages(messages, tool, result) {
|
|
3323
|
+
return this.inner.pushToolMessages(messages, tool, result);
|
|
3324
|
+
}
|
|
3325
|
+
async getModelInfo() {
|
|
3326
|
+
return this.inner.getModelInfo();
|
|
3306
3327
|
}
|
|
3307
3328
|
};
|
|
3329
|
+
//#endregion
|
|
3330
|
+
//#region src/utils/peonNotifier.ts
|
|
3308
3331
|
/**
|
|
3309
|
-
*
|
|
3332
|
+
* peon-ping notifier — native b4m adapter for peon-ping (https://peonping.com).
|
|
3333
|
+
*
|
|
3334
|
+
* peon-ping plays game-character voice lines + on-screen banners when an AI
|
|
3335
|
+
* coding agent finishes a turn or needs attention. It consumes a small JSON
|
|
3336
|
+
* event on stdin (the "CESP" hook contract shared by Claude Code and every
|
|
3337
|
+
* peon-ping adapter):
|
|
3338
|
+
*
|
|
3339
|
+
* { hook_event_name, notification_type, cwd, session_id, permission_mode }
|
|
3340
|
+
*
|
|
3341
|
+
* This module is the b4m CLI's equivalent of the shell adapters peon-ping
|
|
3342
|
+
* ships for other IDEs (see `adapters/openclaw.sh`), except it runs in-process
|
|
3343
|
+
* and is wired directly to the CLI's lifecycle via a store subscription.
|
|
3344
|
+
*
|
|
3345
|
+
* Behaviour is auto-detect: if a `peon.sh` is found on disk it is enabled,
|
|
3346
|
+
* otherwise every call is a silent no-op. Set `B4M_PEON_PING=0` to force it
|
|
3347
|
+
* off even when installed.
|
|
3310
3348
|
*/
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3349
|
+
/**
|
|
3350
|
+
* Resolve the path to `peon.sh`, checking the same locations peon-ping's own
|
|
3351
|
+
* adapters use. Returns null when peon-ping is not installed. Resolved once and
|
|
3352
|
+
* cached: `null` means "looked and found nothing".
|
|
3353
|
+
*/
|
|
3354
|
+
let resolvedScript;
|
|
3355
|
+
function findPeonScript() {
|
|
3356
|
+
if (resolvedScript !== void 0) return resolvedScript;
|
|
3357
|
+
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), ".claude");
|
|
3358
|
+
resolvedScript = [
|
|
3359
|
+
process.env.CLAUDE_PEON_DIR ? path.join(process.env.CLAUDE_PEON_DIR, "peon.sh") : null,
|
|
3360
|
+
path.join(claudeDir, "hooks", "peon-ping", "peon.sh"),
|
|
3361
|
+
path.join(homedir(), ".openpeon", "peon.sh")
|
|
3362
|
+
].filter((p) => p !== null).find((p) => existsSync(p)) ?? null;
|
|
3363
|
+
return resolvedScript;
|
|
3364
|
+
}
|
|
3365
|
+
/** Explicit off-switch via env, even when peon-ping is installed. */
|
|
3366
|
+
function isDisabledByEnv() {
|
|
3367
|
+
const v = process.env.B4M_PEON_PING?.toLowerCase();
|
|
3368
|
+
return v === "0" || v === "false" || v === "off";
|
|
3369
|
+
}
|
|
3370
|
+
/** True when peon-ping is installed and not disabled. */
|
|
3371
|
+
function isPeonAvailable() {
|
|
3372
|
+
return !isDisabledByEnv() && findPeonScript() !== null;
|
|
3373
|
+
}
|
|
3374
|
+
/**
|
|
3375
|
+
* Fire a single peon-ping event. Fire-and-forget and fail-safe: the child is
|
|
3376
|
+
* detached and unref'd, all output is discarded, and any spawn error is logged
|
|
3377
|
+
* to the debug log but never surfaced — the CLI must never break because of a
|
|
3378
|
+
* missing or misbehaving peon install.
|
|
3379
|
+
*/
|
|
3380
|
+
function notifyPeon(event, options = {}) {
|
|
3381
|
+
if (isDisabledByEnv()) return;
|
|
3382
|
+
const script = findPeonScript();
|
|
3383
|
+
if (!script) return;
|
|
3384
|
+
const payload = JSON.stringify({
|
|
3385
|
+
hook_event_name: event,
|
|
3386
|
+
notification_type: options.notificationType ?? "",
|
|
3387
|
+
cwd: process.cwd(),
|
|
3388
|
+
session_id: useCliStore.getState().session?.id ?? `b4m-${process.pid}`,
|
|
3389
|
+
permission_mode: ""
|
|
3390
|
+
});
|
|
3314
3391
|
try {
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3392
|
+
const child = spawn("bash", [script], {
|
|
3393
|
+
stdio: [
|
|
3394
|
+
"pipe",
|
|
3395
|
+
"ignore",
|
|
3396
|
+
"ignore"
|
|
3397
|
+
],
|
|
3398
|
+
detached: true
|
|
3399
|
+
});
|
|
3400
|
+
child.on("error", (err) => logger.debug(`peon-ping spawn failed: ${err.message}`));
|
|
3401
|
+
child.stdin?.on("error", () => {});
|
|
3402
|
+
child.stdin?.end(payload);
|
|
3403
|
+
child.unref();
|
|
3404
|
+
} catch (err) {
|
|
3405
|
+
logger.debug(`peon-ping notify error: ${err.message}`);
|
|
3318
3406
|
}
|
|
3319
|
-
if (!["http:", "https:"].includes(parsed.protocol)) throw new JupyterClientError(`Invalid protocol: ${parsed.protocol}. Only http and https are allowed`);
|
|
3320
3407
|
}
|
|
3321
3408
|
/**
|
|
3322
|
-
*
|
|
3409
|
+
* Subscribe to CLI lifecycle and emit peon-ping events:
|
|
3410
|
+
* - `Stop` when the agent finishes a turn (isThinking true → false)
|
|
3411
|
+
* - `Notification` (permission_prompt) when a permission / user-question /
|
|
3412
|
+
* review-gate prompt first appears and the user needs to act
|
|
3413
|
+
*
|
|
3414
|
+
* Fires `SessionStart` immediately. Returns an unsubscribe function; call it
|
|
3415
|
+
* (and pass `emitSessionEnd`) on exit to emit `SessionEnd`.
|
|
3323
3416
|
*/
|
|
3324
|
-
function
|
|
3325
|
-
|
|
3326
|
-
|
|
3417
|
+
function startPeonNotifier() {
|
|
3418
|
+
if (!isPeonAvailable()) return () => {};
|
|
3419
|
+
notifyPeon("SessionStart");
|
|
3420
|
+
return useCliStore.subscribe((state, prev) => {
|
|
3421
|
+
if (prev.isThinking && !state.isThinking) notifyPeon("Stop");
|
|
3422
|
+
if (!prev.permissionPrompt && !!state.permissionPrompt || !prev.userQuestionPrompt && !!state.userQuestionPrompt || !prev.reviewGatePrompt && !!state.reviewGatePrompt) notifyPeon("Notification", { notificationType: "permission_prompt" });
|
|
3423
|
+
});
|
|
3424
|
+
}
|
|
3425
|
+
/** Emit `SessionEnd`. Call on CLI exit. */
|
|
3426
|
+
function emitPeonSessionEnd() {
|
|
3427
|
+
notifyPeon("SessionEnd");
|
|
3327
3428
|
}
|
|
3429
|
+
//#endregion
|
|
3430
|
+
//#region src/agents/dynamicAgentTool.ts
|
|
3328
3431
|
/**
|
|
3329
|
-
*
|
|
3432
|
+
* Create the create_dynamic_agent tool
|
|
3433
|
+
*
|
|
3434
|
+
* This tool allows the main agent to create and spawn a one-off agent at runtime
|
|
3435
|
+
* with a custom system prompt, model, and tool restrictions. Unlike agent_delegate,
|
|
3436
|
+
* this does not require a pre-defined agent markdown file.
|
|
3437
|
+
*
|
|
3438
|
+
* @param orchestrator - The SubagentOrchestrator instance
|
|
3439
|
+
* @param parentSessionId - Current session ID
|
|
3440
|
+
* @param backgroundManager - Optional background manager for async execution
|
|
3441
|
+
* @returns Tool definition compatible with agent tools
|
|
3330
3442
|
*/
|
|
3331
|
-
function
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3443
|
+
function createDynamicAgentTool(orchestrator, parentSessionId, backgroundManager) {
|
|
3444
|
+
return {
|
|
3445
|
+
toolFn: async (args) => {
|
|
3446
|
+
const params = args;
|
|
3447
|
+
if (!params.task) throw new Error("create_dynamic_agent: task parameter is required");
|
|
3448
|
+
if (!params.name) throw new Error("create_dynamic_agent: name parameter is required");
|
|
3449
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(params.name)) throw new Error("create_dynamic_agent: name must contain only alphanumeric characters, hyphens, and underscores");
|
|
3450
|
+
if (!params.systemPrompt) throw new Error("create_dynamic_agent: systemPrompt parameter is required");
|
|
3451
|
+
const deniedTools = [...params.deniedTools || [], ...ALWAYS_DENIED_FOR_AGENTS];
|
|
3452
|
+
const agentDefinition = {
|
|
3453
|
+
description: params.description || `Dynamic agent: ${params.name}`,
|
|
3454
|
+
model: params.model || DEFAULT_AGENT_MODEL,
|
|
3455
|
+
modelResolved: true,
|
|
3456
|
+
systemPrompt: params.systemPrompt,
|
|
3457
|
+
allowedTools: params.allowedTools,
|
|
3458
|
+
deniedTools,
|
|
3459
|
+
maxIterations: { ...DEFAULT_MAX_ITERATIONS },
|
|
3460
|
+
defaultThoroughness: DEFAULT_THOROUGHNESS,
|
|
3461
|
+
defaultVariables: params.variables,
|
|
3462
|
+
retry: { ...DEFAULT_RETRY_CONFIG }
|
|
3463
|
+
};
|
|
3464
|
+
const spawnOptions = {
|
|
3465
|
+
task: params.task,
|
|
3466
|
+
agentName: params.name,
|
|
3467
|
+
thoroughness: params.thoroughness,
|
|
3468
|
+
variables: params.variables,
|
|
3469
|
+
parentSessionId,
|
|
3470
|
+
model: params.model,
|
|
3471
|
+
allowedTools: params.allowedTools,
|
|
3472
|
+
agentDefinition
|
|
3473
|
+
};
|
|
3474
|
+
if (params.run_in_background && backgroundManager) {
|
|
3475
|
+
const jobId = backgroundManager.spawn({
|
|
3476
|
+
...spawnOptions,
|
|
3477
|
+
groupDescription: params.group_description
|
|
3478
|
+
});
|
|
3479
|
+
return `Dynamic background agent "${params.name}" started. Job ID: ${jobId}. Use check_agent_status tool with this job ID to retrieve results when ready.`;
|
|
3360
3480
|
}
|
|
3361
|
-
|
|
3362
|
-
}
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3481
|
+
return (await orchestrator.delegateToAgent(spawnOptions)).summary;
|
|
3482
|
+
},
|
|
3483
|
+
toolSchema: {
|
|
3484
|
+
name: "create_dynamic_agent",
|
|
3485
|
+
description: `Create and spawn a one-off agent at runtime with a custom system prompt.
|
|
3486
|
+
|
|
3487
|
+
Unlike agent_delegate (which uses pre-defined agents), this tool lets you compose a new agent on the fly with custom instructions, model, and tool restrictions.
|
|
3488
|
+
|
|
3489
|
+
**When to use this tool:**
|
|
3490
|
+
- When no existing agent fits the task at hand
|
|
3491
|
+
- When you need a specialized agent with custom instructions for a one-off task
|
|
3492
|
+
- When you need fine-grained control over what tools the agent can access
|
|
3493
|
+
|
|
3494
|
+
**Constraints:**
|
|
3495
|
+
- Dynamic agents CANNOT call agent_delegate or create_dynamic_agent (no recursive spawning)
|
|
3496
|
+
- Dynamic agents are ephemeral — they are not saved or reusable across sessions
|
|
3497
|
+
|
|
3498
|
+
**Example uses:**
|
|
3499
|
+
- Create a security auditor agent with specific review criteria
|
|
3500
|
+
- Create a migration agent that follows a custom checklist
|
|
3501
|
+
- Create a specialized code generator with domain-specific instructions`,
|
|
3502
|
+
parameters: {
|
|
3503
|
+
type: "object",
|
|
3504
|
+
properties: {
|
|
3505
|
+
task: {
|
|
3506
|
+
type: "string",
|
|
3507
|
+
description: "Clear description of what you want the dynamic agent to accomplish."
|
|
3508
|
+
},
|
|
3509
|
+
name: {
|
|
3510
|
+
type: "string",
|
|
3511
|
+
description: "Unique name for this dynamic agent (e.g., \"security-auditor\", \"migration-helper\"). Used for logging and identification."
|
|
3512
|
+
},
|
|
3513
|
+
systemPrompt: {
|
|
3514
|
+
type: "string",
|
|
3515
|
+
description: "Custom system prompt for the agent. This defines the agent's role, capabilities, and constraints. Use $TASK to reference the task parameter."
|
|
3516
|
+
},
|
|
3517
|
+
description: {
|
|
3518
|
+
type: "string",
|
|
3519
|
+
description: "Short description of the agent's purpose (for logging)."
|
|
3520
|
+
},
|
|
3521
|
+
model: {
|
|
3522
|
+
type: "string",
|
|
3523
|
+
description: "Model to use for this agent (e.g., \"claude-sonnet-4-5-20250929\"). Defaults to the agent system default."
|
|
3524
|
+
},
|
|
3525
|
+
allowedTools: {
|
|
3526
|
+
type: "array",
|
|
3527
|
+
items: { type: "string" },
|
|
3528
|
+
description: "Whitelist of tool name patterns the agent can use. Supports wildcards (e.g., \"file_*\", \"mcp__github__*\"). If omitted, all non-denied tools are available."
|
|
3529
|
+
},
|
|
3530
|
+
deniedTools: {
|
|
3531
|
+
type: "array",
|
|
3532
|
+
items: { type: "string" },
|
|
3533
|
+
description: "Blacklist of tool name patterns the agent cannot use. agent_delegate and create_dynamic_agent are always denied."
|
|
3534
|
+
},
|
|
3535
|
+
thoroughness: {
|
|
3536
|
+
type: "string",
|
|
3537
|
+
enum: [
|
|
3538
|
+
"quick",
|
|
3539
|
+
"medium",
|
|
3540
|
+
"very_thorough"
|
|
3541
|
+
],
|
|
3542
|
+
description: `How thoroughly to execute:
|
|
3543
|
+
- quick: Fast, 1-2 iterations
|
|
3544
|
+
- medium: Balanced, 3-5 iterations (default)
|
|
3545
|
+
- very_thorough: Comprehensive, 8-10+ iterations`
|
|
3546
|
+
},
|
|
3547
|
+
variables: {
|
|
3548
|
+
type: "object",
|
|
3549
|
+
additionalProperties: { type: "string" },
|
|
3550
|
+
description: "Variables to substitute in the system prompt. For example: { \"DOMAIN\": \"auth\" } replaces $DOMAIN in the prompt."
|
|
3551
|
+
},
|
|
3552
|
+
run_in_background: {
|
|
3553
|
+
type: "boolean",
|
|
3554
|
+
description: "Run the agent in the background (non-blocking). Returns a job ID immediately. Use check_agent_status to poll for results."
|
|
3555
|
+
},
|
|
3556
|
+
group_description: {
|
|
3557
|
+
type: "string",
|
|
3558
|
+
description: "Short description of what this group of background agents is working on. Only needed for the first background agent in a group."
|
|
3559
|
+
}
|
|
3560
|
+
},
|
|
3561
|
+
required: [
|
|
3562
|
+
"task",
|
|
3563
|
+
"name",
|
|
3564
|
+
"systemPrompt"
|
|
3565
|
+
]
|
|
3566
|
+
}
|
|
3567
|
+
}
|
|
3568
|
+
};
|
|
3569
|
+
}
|
|
3570
|
+
//#endregion
|
|
3571
|
+
//#region src/tools/deferredToolRegistry.ts
|
|
3572
|
+
/**
|
|
3573
|
+
* Registry of tool schemas that are NOT loaded into the model's initial tool
|
|
3574
|
+
* list. The model sees only the names (via the system prompt directory) and
|
|
3575
|
+
* must call the `tool_search` meta-tool to load schemas on demand.
|
|
3576
|
+
*
|
|
3577
|
+
* This mirrors Claude Code's deferred-tool pattern. The win is large for
|
|
3578
|
+
* heavy MCP integrations (e.g. 41 GitHub MCP tools at ~250-350 tokens of
|
|
3579
|
+
* JSONSchema each = ~10-15k tokens per turn that's now ~1-1.5k of names).
|
|
3580
|
+
*/
|
|
3581
|
+
var DeferredToolRegistry = class {
|
|
3582
|
+
constructor() {
|
|
3583
|
+
this.byName = /* @__PURE__ */ new Map();
|
|
3397
3584
|
}
|
|
3398
|
-
/**
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3585
|
+
/** Replace registry contents with the supplied tools. Idempotent. */
|
|
3586
|
+
register(tools) {
|
|
3587
|
+
this.byName.clear();
|
|
3588
|
+
for (const tool of tools) this.byName.set(tool.toolSchema.name, tool);
|
|
3589
|
+
logger.debug(`[DeferredToolRegistry] Registered ${tools.length} deferred tool(s)`);
|
|
3403
3590
|
}
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
*/
|
|
3407
|
-
async stopSession(sessionId) {
|
|
3408
|
-
await this.request("DELETE", `/api/sessions/${sessionId}`);
|
|
3591
|
+
clear() {
|
|
3592
|
+
this.byName.clear();
|
|
3409
3593
|
}
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
*/
|
|
3413
|
-
getKernelWebSocketUrl(kernelId) {
|
|
3414
|
-
const httpUrl = new URL(this.serverUrl);
|
|
3415
|
-
const wsUrl = `${httpUrl.protocol === "https:" ? "wss:" : "ws:"}//${httpUrl.host}/api/kernels/${kernelId}/channels`;
|
|
3416
|
-
if (this.token) return `${wsUrl}?token=${this.token}`;
|
|
3417
|
-
return wsUrl;
|
|
3594
|
+
size() {
|
|
3595
|
+
return this.byName.size;
|
|
3418
3596
|
}
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
return
|
|
3597
|
+
has(name) {
|
|
3598
|
+
return this.byName.has(name);
|
|
3599
|
+
}
|
|
3600
|
+
get(name) {
|
|
3601
|
+
return this.byName.get(name);
|
|
3602
|
+
}
|
|
3603
|
+
getAll() {
|
|
3604
|
+
return Array.from(this.byName.values());
|
|
3605
|
+
}
|
|
3606
|
+
/** Return tools whose names appear in the supplied list, in input order. */
|
|
3607
|
+
getByNames(names) {
|
|
3608
|
+
const found = [];
|
|
3609
|
+
for (const name of names) {
|
|
3610
|
+
const tool = this.byName.get(name);
|
|
3611
|
+
if (tool) found.push(tool);
|
|
3612
|
+
}
|
|
3613
|
+
return found;
|
|
3424
3614
|
}
|
|
3425
3615
|
/**
|
|
3426
|
-
*
|
|
3427
|
-
*
|
|
3428
|
-
* Connects to the kernel's channels WebSocket, sends an execute_request,
|
|
3429
|
-
* and collects outputs until the kernel returns to idle state.
|
|
3430
|
-
*
|
|
3431
|
-
* @param kernelId - The kernel ID to execute code in
|
|
3432
|
-
* @param code - The code to execute
|
|
3433
|
-
* @param timeoutMs - Execution timeout in milliseconds (default: 30000)
|
|
3616
|
+
* Rank-search deferred tools by query terms. Name matches outrank
|
|
3617
|
+
* description matches; exact substring on name wins ties.
|
|
3434
3618
|
*/
|
|
3435
|
-
|
|
3436
|
-
const
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
let
|
|
3443
|
-
const
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
if (ws.readyState === WsWebSocket.OPEN || ws.readyState === WsWebSocket.CONNECTING) ws.close();
|
|
3451
|
-
};
|
|
3452
|
-
ws.on("error", (err) => {
|
|
3453
|
-
cleanup();
|
|
3454
|
-
reject(new JupyterClientError(`WebSocket error: ${err.message}`));
|
|
3455
|
-
});
|
|
3456
|
-
ws.on("open", () => {
|
|
3457
|
-
const executeRequest = {
|
|
3458
|
-
header: {
|
|
3459
|
-
msg_id: msgId,
|
|
3460
|
-
msg_type: "execute_request",
|
|
3461
|
-
username: "b4m-cli",
|
|
3462
|
-
session: this.generateMsgId(),
|
|
3463
|
-
date: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3464
|
-
version: "5.3"
|
|
3465
|
-
},
|
|
3466
|
-
parent_header: {},
|
|
3467
|
-
metadata: {},
|
|
3468
|
-
content: {
|
|
3469
|
-
code,
|
|
3470
|
-
silent: false,
|
|
3471
|
-
store_history: true,
|
|
3472
|
-
user_expressions: {},
|
|
3473
|
-
allow_stdin: false,
|
|
3474
|
-
stop_on_error: true
|
|
3475
|
-
},
|
|
3476
|
-
buffers: [],
|
|
3477
|
-
channel: "shell"
|
|
3478
|
-
};
|
|
3479
|
-
ws.send(JSON.stringify(executeRequest));
|
|
3619
|
+
searchByKeywords(query, maxResults) {
|
|
3620
|
+
const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
|
|
3621
|
+
if (terms.length === 0) return [];
|
|
3622
|
+
const scored = [];
|
|
3623
|
+
for (const tool of this.byName.values()) {
|
|
3624
|
+
const name = tool.toolSchema.name.toLowerCase();
|
|
3625
|
+
const desc = (tool.toolSchema.description || "").toLowerCase();
|
|
3626
|
+
let score = 0;
|
|
3627
|
+
for (const term of terms) {
|
|
3628
|
+
if (name.includes(term)) score += 10;
|
|
3629
|
+
if (desc.includes(term)) score += 1;
|
|
3630
|
+
}
|
|
3631
|
+
if (score > 0) scored.push({
|
|
3632
|
+
tool,
|
|
3633
|
+
score
|
|
3480
3634
|
});
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
if (msg.parent_header?.msg_id !== msgId) return;
|
|
3485
|
-
switch (msg.header?.msg_type || msg.msg_type) {
|
|
3486
|
-
case "stream":
|
|
3487
|
-
outputs.push({
|
|
3488
|
-
output_type: "stream",
|
|
3489
|
-
name: msg.content.name,
|
|
3490
|
-
text: msg.content.text
|
|
3491
|
-
});
|
|
3492
|
-
break;
|
|
3493
|
-
case "execute_result":
|
|
3494
|
-
executionCount = msg.content.execution_count;
|
|
3495
|
-
outputs.push({
|
|
3496
|
-
output_type: "execute_result",
|
|
3497
|
-
data: msg.content.data,
|
|
3498
|
-
execution_count: msg.content.execution_count,
|
|
3499
|
-
metadata: msg.content.metadata
|
|
3500
|
-
});
|
|
3501
|
-
break;
|
|
3502
|
-
case "display_data":
|
|
3503
|
-
outputs.push({
|
|
3504
|
-
output_type: "display_data",
|
|
3505
|
-
data: msg.content.data,
|
|
3506
|
-
metadata: msg.content.metadata
|
|
3507
|
-
});
|
|
3508
|
-
break;
|
|
3509
|
-
case "error":
|
|
3510
|
-
hasError = true;
|
|
3511
|
-
errorInfo = {
|
|
3512
|
-
ename: msg.content.ename,
|
|
3513
|
-
evalue: msg.content.evalue,
|
|
3514
|
-
traceback: msg.content.traceback
|
|
3515
|
-
};
|
|
3516
|
-
outputs.push({
|
|
3517
|
-
output_type: "error",
|
|
3518
|
-
ename: msg.content.ename,
|
|
3519
|
-
evalue: msg.content.evalue,
|
|
3520
|
-
traceback: msg.content.traceback
|
|
3521
|
-
});
|
|
3522
|
-
break;
|
|
3523
|
-
case "execute_reply":
|
|
3524
|
-
if (msg.content.status === "ok" || msg.content.status === "error") {
|
|
3525
|
-
if (msg.content.execution_count !== void 0) executionCount = msg.content.execution_count;
|
|
3526
|
-
cleanup();
|
|
3527
|
-
resolve({
|
|
3528
|
-
success: !hasError,
|
|
3529
|
-
outputs,
|
|
3530
|
-
executionCount,
|
|
3531
|
-
error: errorInfo
|
|
3532
|
-
});
|
|
3533
|
-
}
|
|
3534
|
-
break;
|
|
3535
|
-
case "status": break;
|
|
3536
|
-
}
|
|
3537
|
-
} catch {}
|
|
3538
|
-
});
|
|
3539
|
-
ws.on("close", () => {
|
|
3540
|
-
clearTimeout(timeoutHandle);
|
|
3541
|
-
});
|
|
3542
|
-
});
|
|
3543
|
-
}
|
|
3544
|
-
/**
|
|
3545
|
-
* @deprecated Use executeCell instead. This method exists for backwards compatibility.
|
|
3546
|
-
*/
|
|
3547
|
-
async executeCode(kernelId, code) {
|
|
3548
|
-
return this.executeCell(kernelId, code);
|
|
3549
|
-
}
|
|
3550
|
-
/**
|
|
3551
|
-
* Interrupt a running kernel
|
|
3552
|
-
*/
|
|
3553
|
-
async interruptKernel(kernelId) {
|
|
3554
|
-
await this.request("POST", `/api/kernels/${kernelId}/interrupt`);
|
|
3635
|
+
}
|
|
3636
|
+
scored.sort((a, b) => b.score - a.score);
|
|
3637
|
+
return scored.slice(0, maxResults).map((s) => s.tool);
|
|
3555
3638
|
}
|
|
3556
|
-
/**
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
async restartKernel(kernelId) {
|
|
3560
|
-
return this.request("POST", `/api/kernels/${kernelId}/restart`);
|
|
3639
|
+
/** Return the directory entries used to render the system-prompt reminder. */
|
|
3640
|
+
getDirectoryNames() {
|
|
3641
|
+
return Array.from(this.byName.keys()).sort();
|
|
3561
3642
|
}
|
|
3562
3643
|
};
|
|
3563
|
-
|
|
3564
|
-
* Create a JupyterClient from environment variables or config
|
|
3565
|
-
*/
|
|
3566
|
-
function createJupyterClientFromEnv() {
|
|
3567
|
-
const serverUrl = process.env.JUPYTER_SERVER_URL;
|
|
3568
|
-
const token = process.env.JUPYTER_TOKEN;
|
|
3569
|
-
if (!serverUrl) return null;
|
|
3570
|
-
return new JupyterClient({
|
|
3571
|
-
serverUrl,
|
|
3572
|
-
token
|
|
3573
|
-
});
|
|
3574
|
-
}
|
|
3644
|
+
const deferredToolRegistry = new DeferredToolRegistry();
|
|
3575
3645
|
//#endregion
|
|
3576
|
-
//#region src/
|
|
3646
|
+
//#region src/tools/toolSearchTool.ts
|
|
3577
3647
|
/**
|
|
3578
|
-
*
|
|
3579
|
-
*
|
|
3580
|
-
*
|
|
3581
|
-
* Orchestrates cell-by-cell execution of a notebook via local Jupyter server.
|
|
3648
|
+
* Default number of tools returned for a keyword search. Matches Claude
|
|
3649
|
+
* Code's ToolSearch convention. 5 keeps the response payload small while
|
|
3650
|
+
* surfacing enough alternatives for the model to refine its query.
|
|
3582
3651
|
*/
|
|
3652
|
+
const DEFAULT_MAX_RESULTS = 5;
|
|
3583
3653
|
/**
|
|
3584
|
-
*
|
|
3585
|
-
*
|
|
3654
|
+
* Runtime validation for tool_search params. The LLM produces these
|
|
3655
|
+
* values, so we validate at this boundary rather than trusting the
|
|
3656
|
+
* shape. Coerces `max_results` from string→number for models that emit
|
|
3657
|
+
* numeric-looking strings.
|
|
3586
3658
|
*/
|
|
3587
|
-
const
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
kernelName: z.string().default("python3"),
|
|
3591
|
-
timeoutPerCell: z.number().default(3e4)
|
|
3659
|
+
const ToolSearchParamsSchema = z.object({
|
|
3660
|
+
query: z.string().min(1, "query must be a non-empty string"),
|
|
3661
|
+
max_results: z.coerce.number().int().min(1).max(20).optional()
|
|
3592
3662
|
});
|
|
3593
3663
|
/**
|
|
3594
|
-
*
|
|
3664
|
+
* Parse the query string. Two forms:
|
|
3665
|
+
* - `select:name1,name2,...` — exact-name selection
|
|
3666
|
+
* - free text — keyword search across name + description
|
|
3595
3667
|
*/
|
|
3596
|
-
function
|
|
3597
|
-
|
|
3668
|
+
function parseQuery(query) {
|
|
3669
|
+
const trimmed = query.trim();
|
|
3670
|
+
const selectMatch = trimmed.match(/^select:(.+)$/i);
|
|
3671
|
+
if (selectMatch) return {
|
|
3672
|
+
mode: "select",
|
|
3673
|
+
names: selectMatch[1].split(",").map((n) => n.trim()).filter((n) => n.length > 0)
|
|
3674
|
+
};
|
|
3675
|
+
return {
|
|
3676
|
+
mode: "search",
|
|
3677
|
+
text: trimmed
|
|
3678
|
+
};
|
|
3598
3679
|
}
|
|
3599
3680
|
/**
|
|
3600
|
-
*
|
|
3681
|
+
* Format the loaded-tools response. Mirrors Claude Code's convention:
|
|
3682
|
+
* one <function>{...}</function> line per matched tool. The model has
|
|
3683
|
+
* already seen this format in its tool-registration system messages, so
|
|
3684
|
+
* it parses without additional explanation.
|
|
3601
3685
|
*
|
|
3602
|
-
*
|
|
3603
|
-
*
|
|
3604
|
-
*
|
|
3686
|
+
* Note: the schemas are *also* injected into context.tools by the caller,
|
|
3687
|
+
* so on the next iteration the model gets them as native tool definitions.
|
|
3688
|
+
* The text response here is for in-turn awareness and audit trail.
|
|
3605
3689
|
*/
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
} catch (parseErr) {
|
|
3614
|
-
throw new Error(`Failed to parse notebook JSON: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`);
|
|
3615
|
-
}
|
|
3616
|
-
const totalCodeCells = notebook.cells.filter((c) => c.cell_type === "code").length;
|
|
3617
|
-
logger.info(`[Keep] Starting notebook execution: ${totalCodeCells} code cells, kernel: ${kernelName}`);
|
|
3618
|
-
const tempNotebookPath = `/tmp/b4m-notebook-${randomUUID()}.ipynb`;
|
|
3619
|
-
await promises.writeFile(tempNotebookPath, notebookJson, "utf-8");
|
|
3620
|
-
let jupyterSession = null;
|
|
3621
|
-
let cellsExecuted = 0;
|
|
3622
|
-
let cellsFailed = 0;
|
|
3623
|
-
try {
|
|
3624
|
-
logger.info(`[Keep] Starting Jupyter kernel: ${kernelName}`);
|
|
3625
|
-
jupyterSession = await jupyterClient.startSession(tempNotebookPath, kernelName);
|
|
3626
|
-
const kernelId = jupyterSession.kernel.id;
|
|
3627
|
-
const jupyterSessionId = jupyterSession.id;
|
|
3628
|
-
logger.info(`[Keep] Kernel started: session=${jupyterSessionId}, kernel=${kernelId}`);
|
|
3629
|
-
let codeCellIndex = 0;
|
|
3630
|
-
for (let i = 0; i < notebook.cells.length; i++) {
|
|
3631
|
-
const cell = notebook.cells[i];
|
|
3632
|
-
if (cell.cell_type !== "code") continue;
|
|
3633
|
-
const cellCode = getCellSource(cell);
|
|
3634
|
-
if (!cellCode.trim()) {
|
|
3635
|
-
codeCellIndex++;
|
|
3636
|
-
continue;
|
|
3637
|
-
}
|
|
3638
|
-
logger.info(`[Keep] Executing cell ${codeCellIndex + 1}/${totalCodeCells}`);
|
|
3639
|
-
wsManager?.send({
|
|
3640
|
-
action: "jupyter_cell_output",
|
|
3641
|
-
requestId,
|
|
3642
|
-
sessionId,
|
|
3643
|
-
jupyterSessionId,
|
|
3644
|
-
cellIndex: codeCellIndex,
|
|
3645
|
-
outputType: "stream",
|
|
3646
|
-
content: {
|
|
3647
|
-
text: `Executing cell ${codeCellIndex + 1}/${totalCodeCells}...`,
|
|
3648
|
-
name: "stdout"
|
|
3649
|
-
},
|
|
3650
|
-
executionCount: null,
|
|
3651
|
-
isComplete: false
|
|
3652
|
-
});
|
|
3653
|
-
try {
|
|
3654
|
-
const cellResult = await jupyterClient.executeCell(kernelId, cellCode, timeoutPerCell);
|
|
3655
|
-
cell.outputs = cellResult.outputs;
|
|
3656
|
-
cell.execution_count = cellResult.executionCount;
|
|
3657
|
-
if (cellResult.success) {
|
|
3658
|
-
cellsExecuted++;
|
|
3659
|
-
wsManager?.send({
|
|
3660
|
-
action: "jupyter_cell_output",
|
|
3661
|
-
requestId,
|
|
3662
|
-
sessionId,
|
|
3663
|
-
jupyterSessionId,
|
|
3664
|
-
cellIndex: codeCellIndex,
|
|
3665
|
-
outputType: "execute_result",
|
|
3666
|
-
content: {
|
|
3667
|
-
text: cellResult.outputs.map((o) => {
|
|
3668
|
-
if (o.text) return Array.isArray(o.text) ? o.text.join("") : o.text;
|
|
3669
|
-
if (o.data && typeof o.data === "object" && "text/plain" in o.data) {
|
|
3670
|
-
const textPlain = o.data["text/plain"];
|
|
3671
|
-
return Array.isArray(textPlain) ? textPlain.join("") : String(textPlain);
|
|
3672
|
-
}
|
|
3673
|
-
return "";
|
|
3674
|
-
}).join(""),
|
|
3675
|
-
data: cellResult.outputs.find((o) => o.data)?.data
|
|
3676
|
-
},
|
|
3677
|
-
executionCount: cellResult.executionCount,
|
|
3678
|
-
isComplete: true
|
|
3679
|
-
});
|
|
3680
|
-
} else {
|
|
3681
|
-
cellsFailed++;
|
|
3682
|
-
wsManager?.send({
|
|
3683
|
-
action: "jupyter_cell_output",
|
|
3684
|
-
requestId,
|
|
3685
|
-
sessionId,
|
|
3686
|
-
jupyterSessionId,
|
|
3687
|
-
cellIndex: codeCellIndex,
|
|
3688
|
-
outputType: "error",
|
|
3689
|
-
content: {
|
|
3690
|
-
ename: cellResult.error?.ename || "ExecutionError",
|
|
3691
|
-
evalue: cellResult.error?.evalue || "Cell execution failed",
|
|
3692
|
-
traceback: cellResult.error?.traceback || []
|
|
3693
|
-
},
|
|
3694
|
-
executionCount: cellResult.executionCount,
|
|
3695
|
-
isComplete: true
|
|
3696
|
-
});
|
|
3697
|
-
logger.warn(`[Keep] Cell ${codeCellIndex + 1} failed: ${cellResult.error?.ename}: ${cellResult.error?.evalue}`);
|
|
3698
|
-
}
|
|
3699
|
-
} catch (cellErr) {
|
|
3700
|
-
cellsFailed++;
|
|
3701
|
-
const errMsg = cellErr instanceof Error ? cellErr.message : String(cellErr);
|
|
3702
|
-
wsManager?.send({
|
|
3703
|
-
action: "jupyter_cell_output",
|
|
3704
|
-
requestId,
|
|
3705
|
-
sessionId,
|
|
3706
|
-
jupyterSessionId,
|
|
3707
|
-
cellIndex: codeCellIndex,
|
|
3708
|
-
outputType: "error",
|
|
3709
|
-
content: {
|
|
3710
|
-
ename: "ExecutionError",
|
|
3711
|
-
evalue: errMsg,
|
|
3712
|
-
traceback: []
|
|
3713
|
-
},
|
|
3714
|
-
executionCount: null,
|
|
3715
|
-
isComplete: true
|
|
3716
|
-
});
|
|
3717
|
-
logger.error(`[Keep] Cell ${codeCellIndex + 1} threw error: ${errMsg}`);
|
|
3718
|
-
}
|
|
3719
|
-
codeCellIndex++;
|
|
3720
|
-
}
|
|
3721
|
-
return {
|
|
3722
|
-
success: cellsFailed === 0,
|
|
3723
|
-
cellsExecuted,
|
|
3724
|
-
cellsFailed,
|
|
3725
|
-
totalCodeCells,
|
|
3726
|
-
executedNotebook: JSON.stringify(notebook)
|
|
3690
|
+
function renderToolsBlock(tools) {
|
|
3691
|
+
if (tools.length === 0) return "";
|
|
3692
|
+
return `<functions>\n${tools.map((tool) => {
|
|
3693
|
+
const schema = {
|
|
3694
|
+
description: tool.toolSchema.description,
|
|
3695
|
+
name: tool.toolSchema.name,
|
|
3696
|
+
parameters: tool.toolSchema.parameters
|
|
3727
3697
|
};
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3698
|
+
return `<function>${JSON.stringify(schema)}</function>`;
|
|
3699
|
+
}).join("\n")}\n</functions>`;
|
|
3700
|
+
}
|
|
3701
|
+
/**
|
|
3702
|
+
* Build the tool_search meta-tool. The returned tool has a closure over
|
|
3703
|
+
* the supplied `toolListAccessor`, which it uses to push newly-resolved
|
|
3704
|
+
* tool schemas into the live agent context.
|
|
3705
|
+
*
|
|
3706
|
+
* Idempotent: re-loading a tool that's already in the context is a no-op.
|
|
3707
|
+
*/
|
|
3708
|
+
function createToolSearchTool(toolListAccessor) {
|
|
3709
|
+
return {
|
|
3710
|
+
toolSchema: {
|
|
3711
|
+
name: "tool_search",
|
|
3712
|
+
description: "Fetches full schema definitions for deferred tools so they can be called. Deferred tools appear by name only in a system reminder; their parameter schemas are NOT loaded by default. Use this tool to load schemas on demand. Query forms: 'select:name1,name2' for exact selection, or free-text keywords to search by name and description. Once a tool's schema is returned, it becomes callable in subsequent turns.",
|
|
3713
|
+
parameters: {
|
|
3714
|
+
type: "object",
|
|
3715
|
+
properties: {
|
|
3716
|
+
query: {
|
|
3717
|
+
type: "string",
|
|
3718
|
+
description: "Either 'select:<comma-separated names>' to fetch specific tools, or free-text keywords (e.g. 'github pull request') to rank-search deferred tools."
|
|
3719
|
+
},
|
|
3720
|
+
max_results: {
|
|
3721
|
+
type: "number",
|
|
3722
|
+
description: `Maximum number of tools to return for keyword search. Defaults to ${DEFAULT_MAX_RESULTS}. Ignored for 'select:' queries.`
|
|
3723
|
+
}
|
|
3724
|
+
},
|
|
3725
|
+
required: ["query"]
|
|
3726
|
+
}
|
|
3727
|
+
},
|
|
3728
|
+
toolFn: async (params) => {
|
|
3729
|
+
const parsedParams = ToolSearchParamsSchema.safeParse(params ?? {});
|
|
3730
|
+
if (!parsedParams.success) {
|
|
3731
|
+
const issue = parsedParams.error.issues[0];
|
|
3732
|
+
return `tool_search: invalid parameters — ${issue.path.join(".") || "params"}: ${issue.message}`;
|
|
3733
|
+
}
|
|
3734
|
+
const { query, max_results } = parsedParams.data;
|
|
3735
|
+
const parsed = parseQuery(query);
|
|
3736
|
+
let matched;
|
|
3737
|
+
let unmatched = [];
|
|
3738
|
+
if (parsed.mode === "select") {
|
|
3739
|
+
matched = deferredToolRegistry.getByNames(parsed.names);
|
|
3740
|
+
const foundNames = new Set(matched.map((t) => t.toolSchema.name));
|
|
3741
|
+
unmatched = parsed.names.filter((n) => !foundNames.has(n));
|
|
3742
|
+
} else {
|
|
3743
|
+
const max = max_results ?? DEFAULT_MAX_RESULTS;
|
|
3744
|
+
matched = deferredToolRegistry.searchByKeywords(parsed.text, max);
|
|
3745
|
+
}
|
|
3746
|
+
if (matched.length === 0) return parsed.mode === "select" ? `tool_search: no deferred tools matched ${parsed.names.join(", ")}. Use a free-text query to search.` : `tool_search: no deferred tools matched query "${parsed.text}".`;
|
|
3747
|
+
const liveTools = toolListAccessor();
|
|
3748
|
+
const liveNames = new Set(liveTools.map((t) => t.toolSchema.name));
|
|
3749
|
+
let added = 0;
|
|
3750
|
+
for (const tool of matched) if (!liveNames.has(tool.toolSchema.name)) {
|
|
3751
|
+
liveTools.push(tool);
|
|
3752
|
+
added++;
|
|
3753
|
+
}
|
|
3754
|
+
logger.debug(`[tool_search] query="${query}" matched=${matched.length} added=${added} alreadyLoaded=${matched.length - added}`);
|
|
3755
|
+
const block = renderToolsBlock(matched);
|
|
3756
|
+
return `${`Loaded ${added} new tool schema(s)${added < matched.length ? ` (${matched.length - added} already loaded)` : ""}. These are now callable in your next message.${unmatched.length > 0 ? `\n\nNot found: ${unmatched.join(", ")}` : ""}`}\n\n${block}`;
|
|
3734
3757
|
}
|
|
3735
|
-
|
|
3736
|
-
await promises.unlink(tempNotebookPath);
|
|
3737
|
-
} catch {}
|
|
3738
|
-
}
|
|
3758
|
+
};
|
|
3739
3759
|
}
|
|
3740
3760
|
//#endregion
|
|
3741
|
-
//#region src/
|
|
3761
|
+
//#region src/features/FeatureModuleRegistry.ts
|
|
3742
3762
|
/**
|
|
3743
|
-
*
|
|
3744
|
-
* into the message array before each completion call.
|
|
3763
|
+
* Manages the lifecycle of opt-in CLI feature modules.
|
|
3745
3764
|
*
|
|
3746
|
-
*
|
|
3747
|
-
*
|
|
3748
|
-
* in context — no polling required.
|
|
3765
|
+
* Created during bootstrap, modules are conditionally registered based on config,
|
|
3766
|
+
* then the registry's outputs are fed into tool generation and prompt building.
|
|
3749
3767
|
*/
|
|
3750
|
-
var
|
|
3751
|
-
constructor(
|
|
3752
|
-
this.
|
|
3753
|
-
this.backgroundManager = backgroundManager;
|
|
3768
|
+
var FeatureModuleRegistry = class {
|
|
3769
|
+
constructor() {
|
|
3770
|
+
this.modules = [];
|
|
3754
3771
|
}
|
|
3755
|
-
|
|
3756
|
-
|
|
3772
|
+
/** Register a feature module */
|
|
3773
|
+
register(module) {
|
|
3774
|
+
if (this.modules.some((m) => m.name === module.name)) throw new Error(`Feature module '${module.name}' is already registered`);
|
|
3775
|
+
this.modules.push(module);
|
|
3757
3776
|
}
|
|
3758
|
-
|
|
3759
|
-
|
|
3777
|
+
/** Collect all tools from all registered modules */
|
|
3778
|
+
getAllTools() {
|
|
3779
|
+
return this.modules.flatMap((m) => m.getTools());
|
|
3760
3780
|
}
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3781
|
+
/** Get all tool names from all registered modules */
|
|
3782
|
+
getAllToolNames() {
|
|
3783
|
+
return this.getAllTools().map((t) => t.toolSchema.name);
|
|
3784
|
+
}
|
|
3785
|
+
/** Build combined system prompt section from all modules */
|
|
3786
|
+
getSystemPromptSections() {
|
|
3787
|
+
const sections = this.modules.map((m) => m.getSystemPromptSection()).filter((s) => s.length > 0);
|
|
3788
|
+
return sections.length > 0 ? "\n\n" + sections.join("\n\n") : "";
|
|
3789
|
+
}
|
|
3790
|
+
/** Register all WS handlers from all modules */
|
|
3791
|
+
registerAllWsHandlers(wsManager) {
|
|
3792
|
+
for (const module of this.modules) module.registerWsHandlers?.(wsManager);
|
|
3793
|
+
}
|
|
3794
|
+
/** Collect all slash commands from all registered modules */
|
|
3795
|
+
getAllCommands() {
|
|
3796
|
+
return this.modules.flatMap((m) => m.getCommands?.() ?? []);
|
|
3797
|
+
}
|
|
3798
|
+
/** Try to execute a slash command. Returns true if handled. */
|
|
3799
|
+
executeCommand(name, args) {
|
|
3800
|
+
for (const module of this.modules) {
|
|
3801
|
+
const command = (module.getCommands?.() ?? []).find((c) => c.name === name);
|
|
3802
|
+
if (command) {
|
|
3803
|
+
command.execute(args);
|
|
3804
|
+
return true;
|
|
3805
|
+
}
|
|
3770
3806
|
}
|
|
3771
|
-
return
|
|
3807
|
+
return false;
|
|
3772
3808
|
}
|
|
3773
|
-
|
|
3774
|
-
|
|
3809
|
+
/** Cleanup all modules */
|
|
3810
|
+
disposeAll() {
|
|
3811
|
+
for (const module of this.modules) module.dispose?.();
|
|
3775
3812
|
}
|
|
3776
|
-
|
|
3777
|
-
|
|
3813
|
+
/** Get names of all registered modules */
|
|
3814
|
+
getModuleNames() {
|
|
3815
|
+
return this.modules.map((m) => m.name);
|
|
3816
|
+
}
|
|
3817
|
+
/** Check if any modules are registered */
|
|
3818
|
+
get hasModules() {
|
|
3819
|
+
return this.modules.length > 0;
|
|
3778
3820
|
}
|
|
3779
3821
|
};
|
|
3780
3822
|
//#endregion
|
|
3781
|
-
//#region src/
|
|
3823
|
+
//#region src/features/tavern/TavernService.ts
|
|
3782
3824
|
/**
|
|
3783
|
-
*
|
|
3784
|
-
*
|
|
3825
|
+
* HTTP implementation of ITavernService using the shared ApiClient.
|
|
3826
|
+
*
|
|
3827
|
+
* Each method maps to one /api/tavern/* endpoint.
|
|
3828
|
+
* Pure transport — no business logic.
|
|
3785
3829
|
*/
|
|
3786
|
-
var
|
|
3787
|
-
constructor(
|
|
3788
|
-
this.
|
|
3789
|
-
this.ollamaBackend = ollamaBackend;
|
|
3790
|
-
this.serverModels = serverModels;
|
|
3791
|
-
this.ollamaModels = ollamaModels;
|
|
3792
|
-
this.currentModel = initialModel;
|
|
3793
|
-
this.ollamaModelIds = new Set(ollamaModels.map((m) => m.id));
|
|
3830
|
+
var TavernService = class {
|
|
3831
|
+
constructor(apiClient) {
|
|
3832
|
+
this.apiClient = apiClient;
|
|
3794
3833
|
}
|
|
3795
|
-
|
|
3796
|
-
return this.
|
|
3834
|
+
async listAgents() {
|
|
3835
|
+
return this.apiClient.get("/api/agents?limit=100");
|
|
3797
3836
|
}
|
|
3798
|
-
async
|
|
3799
|
-
return
|
|
3837
|
+
async createAgent(request) {
|
|
3838
|
+
return this.apiClient.post("/api/agents", request);
|
|
3800
3839
|
}
|
|
3801
|
-
|
|
3802
|
-
this.
|
|
3840
|
+
async updateAgent(agentId, request) {
|
|
3841
|
+
return this.apiClient.put(`/api/agents/${encodeURIComponent(agentId)}`, request);
|
|
3803
3842
|
}
|
|
3804
|
-
async
|
|
3805
|
-
|
|
3843
|
+
async deleteAgent(agentId) {
|
|
3844
|
+
await this.apiClient.delete(`/api/agents/${encodeURIComponent(agentId)}`);
|
|
3845
|
+
}
|
|
3846
|
+
async mentionAgent(agentName, message, config) {
|
|
3847
|
+
return this.apiClient.post("/api/tavern/mention", {
|
|
3848
|
+
agentName,
|
|
3849
|
+
message,
|
|
3850
|
+
config
|
|
3851
|
+
});
|
|
3852
|
+
}
|
|
3853
|
+
async listQuests() {
|
|
3854
|
+
return this.apiClient.get("/api/tavern/quests");
|
|
3855
|
+
}
|
|
3856
|
+
async postQuest(request) {
|
|
3857
|
+
return this.apiClient.post("/api/tavern/quests", request);
|
|
3858
|
+
}
|
|
3859
|
+
async deleteQuest(questId) {
|
|
3860
|
+
await this.apiClient.delete(`/api/tavern/quests`, { data: { questId } });
|
|
3861
|
+
}
|
|
3862
|
+
async getAgentNotebook(agentId, limit = 50) {
|
|
3863
|
+
return this.apiClient.get(`/api/tavern/agent-notebook?agentId=${encodeURIComponent(agentId)}&limit=${limit}`);
|
|
3864
|
+
}
|
|
3865
|
+
async listGates() {
|
|
3866
|
+
return this.apiClient.get("/api/tavern/gates");
|
|
3867
|
+
}
|
|
3868
|
+
async resolveGate(gateId, resolution) {
|
|
3869
|
+
return this.apiClient.post("/api/tavern/gate-resolve", {
|
|
3870
|
+
gateId,
|
|
3871
|
+
resolution
|
|
3872
|
+
});
|
|
3873
|
+
}
|
|
3874
|
+
async toggleHeartbeats(enabled) {
|
|
3875
|
+
return this.apiClient.post("/api/tavern/toggle-heartbeats", { enabled });
|
|
3876
|
+
}
|
|
3877
|
+
async triggerHeartbeat(config) {
|
|
3878
|
+
return this.apiClient.post("/api/tavern/trigger-heartbeat", { config });
|
|
3879
|
+
}
|
|
3880
|
+
async abortHeartbeats() {
|
|
3881
|
+
await this.apiClient.post("/api/tavern/abort-heartbeats", { abort: true });
|
|
3882
|
+
}
|
|
3883
|
+
async getQuestPlan(planId) {
|
|
3884
|
+
return this.apiClient.get(`/api/quest-master-plans/${encodeURIComponent(planId)}`);
|
|
3885
|
+
}
|
|
3886
|
+
async updateReviewGate(planId, questId, subQuestId, reviewStatus, reviewNote) {
|
|
3887
|
+
return this.apiClient.post(`/api/quest-master-plans/${encodeURIComponent(planId)}/review-gate`, {
|
|
3888
|
+
questId,
|
|
3889
|
+
subQuestId,
|
|
3890
|
+
reviewStatus,
|
|
3891
|
+
reviewNote
|
|
3892
|
+
});
|
|
3893
|
+
}
|
|
3894
|
+
async updateSubQuestProgress(planId, questId, subQuestId, updates) {
|
|
3895
|
+
return this.apiClient.post(`/api/quest-master-plans/${encodeURIComponent(planId)}/subquest-progress`, {
|
|
3896
|
+
questId,
|
|
3897
|
+
subQuestId,
|
|
3898
|
+
...updates
|
|
3899
|
+
});
|
|
3900
|
+
}
|
|
3901
|
+
async updateHandoff(planId, handoff) {
|
|
3902
|
+
return this.apiClient.post(`/api/quest-master-plans/${encodeURIComponent(planId)}/handoff`, handoff);
|
|
3806
3903
|
}
|
|
3807
3904
|
};
|
|
3808
3905
|
//#endregion
|
|
3809
|
-
//#region src/
|
|
3906
|
+
//#region src/features/tavern/types.ts
|
|
3810
3907
|
/**
|
|
3811
|
-
*
|
|
3812
|
-
*
|
|
3813
|
-
* peon-ping plays game-character voice lines + on-screen banners when an AI
|
|
3814
|
-
* coding agent finishes a turn or needs attention. It consumes a small JSON
|
|
3815
|
-
* event on stdin (the "CESP" hook contract shared by Claude Code and every
|
|
3816
|
-
* peon-ping adapter):
|
|
3817
|
-
*
|
|
3818
|
-
* { hook_event_name, notification_type, cwd, session_id, permission_mode }
|
|
3819
|
-
*
|
|
3820
|
-
* This module is the b4m CLI's equivalent of the shell adapters peon-ping
|
|
3821
|
-
* ships for other IDEs (see `adapters/openclaw.sh`), except it runs in-process
|
|
3822
|
-
* and is wired directly to the CLI's lifecycle via a store subscription.
|
|
3823
|
-
*
|
|
3824
|
-
* Behaviour is auto-detect: if a `peon.sh` is found on disk it is enabled,
|
|
3825
|
-
* otherwise every call is a silent no-op. Set `B4M_PEON_PING=0` to force it
|
|
3826
|
-
* off even when installed.
|
|
3827
|
-
*/
|
|
3828
|
-
/**
|
|
3829
|
-
* Resolve the path to `peon.sh`, checking the same locations peon-ping's own
|
|
3830
|
-
* adapters use. Returns null when peon-ping is not installed. Resolved once and
|
|
3831
|
-
* cached: `null` means "looked and found nothing".
|
|
3832
|
-
*/
|
|
3833
|
-
let resolvedScript;
|
|
3834
|
-
function findPeonScript() {
|
|
3835
|
-
if (resolvedScript !== void 0) return resolvedScript;
|
|
3836
|
-
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), ".claude");
|
|
3837
|
-
resolvedScript = [
|
|
3838
|
-
process.env.CLAUDE_PEON_DIR ? path.join(process.env.CLAUDE_PEON_DIR, "peon.sh") : null,
|
|
3839
|
-
path.join(claudeDir, "hooks", "peon-ping", "peon.sh"),
|
|
3840
|
-
path.join(homedir(), ".openpeon", "peon.sh")
|
|
3841
|
-
].filter((p) => p !== null).find((p) => existsSync(p)) ?? null;
|
|
3842
|
-
return resolvedScript;
|
|
3843
|
-
}
|
|
3844
|
-
/** Explicit off-switch via env, even when peon-ping is installed. */
|
|
3845
|
-
function isDisabledByEnv() {
|
|
3846
|
-
const v = process.env.B4M_PEON_PING?.toLowerCase();
|
|
3847
|
-
return v === "0" || v === "false" || v === "off";
|
|
3848
|
-
}
|
|
3849
|
-
/** True when peon-ping is installed and not disabled. */
|
|
3850
|
-
function isPeonAvailable() {
|
|
3851
|
-
return !isDisabledByEnv() && findPeonScript() !== null;
|
|
3852
|
-
}
|
|
3853
|
-
/**
|
|
3854
|
-
* Fire a single peon-ping event. Fire-and-forget and fail-safe: the child is
|
|
3855
|
-
* detached and unref'd, all output is discarded, and any spawn error is logged
|
|
3856
|
-
* to the debug log but never surfaced — the CLI must never break because of a
|
|
3857
|
-
* missing or misbehaving peon install.
|
|
3858
|
-
*/
|
|
3859
|
-
function notifyPeon(event, options = {}) {
|
|
3860
|
-
if (isDisabledByEnv()) return;
|
|
3861
|
-
const script = findPeonScript();
|
|
3862
|
-
if (!script) return;
|
|
3863
|
-
const payload = JSON.stringify({
|
|
3864
|
-
hook_event_name: event,
|
|
3865
|
-
notification_type: options.notificationType ?? "",
|
|
3866
|
-
cwd: process.cwd(),
|
|
3867
|
-
session_id: useCliStore.getState().session?.id ?? `b4m-${process.pid}`,
|
|
3868
|
-
permission_mode: ""
|
|
3869
|
-
});
|
|
3870
|
-
try {
|
|
3871
|
-
const child = spawn("bash", [script], {
|
|
3872
|
-
stdio: [
|
|
3873
|
-
"pipe",
|
|
3874
|
-
"ignore",
|
|
3875
|
-
"ignore"
|
|
3876
|
-
],
|
|
3877
|
-
detached: true
|
|
3878
|
-
});
|
|
3879
|
-
child.on("error", (err) => logger.debug(`peon-ping spawn failed: ${err.message}`));
|
|
3880
|
-
child.stdin?.on("error", () => {});
|
|
3881
|
-
child.stdin?.end(payload);
|
|
3882
|
-
child.unref();
|
|
3883
|
-
} catch (err) {
|
|
3884
|
-
logger.debug(`peon-ping notify error: ${err.message}`);
|
|
3885
|
-
}
|
|
3886
|
-
}
|
|
3887
|
-
/**
|
|
3888
|
-
* Subscribe to CLI lifecycle and emit peon-ping events:
|
|
3889
|
-
* - `Stop` when the agent finishes a turn (isThinking true → false)
|
|
3890
|
-
* - `Notification` (permission_prompt) when a permission / user-question /
|
|
3891
|
-
* review-gate prompt first appears and the user needs to act
|
|
3892
|
-
*
|
|
3893
|
-
* Fires `SessionStart` immediately. Returns an unsubscribe function; call it
|
|
3894
|
-
* (and pass `emitSessionEnd`) on exit to emit `SessionEnd`.
|
|
3895
|
-
*/
|
|
3896
|
-
function startPeonNotifier() {
|
|
3897
|
-
if (!isPeonAvailable()) return () => {};
|
|
3898
|
-
notifyPeon("SessionStart");
|
|
3899
|
-
return useCliStore.subscribe((state, prev) => {
|
|
3900
|
-
if (prev.isThinking && !state.isThinking) notifyPeon("Stop");
|
|
3901
|
-
if (!prev.permissionPrompt && !!state.permissionPrompt || !prev.userQuestionPrompt && !!state.userQuestionPrompt || !prev.reviewGatePrompt && !!state.reviewGatePrompt) notifyPeon("Notification", { notificationType: "permission_prompt" });
|
|
3902
|
-
});
|
|
3903
|
-
}
|
|
3904
|
-
/** Emit `SessionEnd`. Call on CLI exit. */
|
|
3905
|
-
function emitPeonSessionEnd() {
|
|
3906
|
-
notifyPeon("SessionEnd");
|
|
3907
|
-
}
|
|
3908
|
-
//#endregion
|
|
3909
|
-
//#region src/agents/dynamicAgentTool.ts
|
|
3910
|
-
/**
|
|
3911
|
-
* Create the create_dynamic_agent tool
|
|
3912
|
-
*
|
|
3913
|
-
* This tool allows the main agent to create and spawn a one-off agent at runtime
|
|
3914
|
-
* with a custom system prompt, model, and tool restrictions. Unlike agent_delegate,
|
|
3915
|
-
* this does not require a pre-defined agent markdown file.
|
|
3916
|
-
*
|
|
3917
|
-
* @param orchestrator - The SubagentOrchestrator instance
|
|
3918
|
-
* @param parentSessionId - Current session ID
|
|
3919
|
-
* @param backgroundManager - Optional background manager for async execution
|
|
3920
|
-
* @returns Tool definition compatible with agent tools
|
|
3921
|
-
*/
|
|
3922
|
-
function createDynamicAgentTool(orchestrator, parentSessionId, backgroundManager) {
|
|
3923
|
-
return {
|
|
3924
|
-
toolFn: async (args) => {
|
|
3925
|
-
const params = args;
|
|
3926
|
-
if (!params.task) throw new Error("create_dynamic_agent: task parameter is required");
|
|
3927
|
-
if (!params.name) throw new Error("create_dynamic_agent: name parameter is required");
|
|
3928
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(params.name)) throw new Error("create_dynamic_agent: name must contain only alphanumeric characters, hyphens, and underscores");
|
|
3929
|
-
if (!params.systemPrompt) throw new Error("create_dynamic_agent: systemPrompt parameter is required");
|
|
3930
|
-
const deniedTools = [...params.deniedTools || [], ...ALWAYS_DENIED_FOR_AGENTS];
|
|
3931
|
-
const agentDefinition = {
|
|
3932
|
-
description: params.description || `Dynamic agent: ${params.name}`,
|
|
3933
|
-
model: params.model || DEFAULT_AGENT_MODEL,
|
|
3934
|
-
modelResolved: true,
|
|
3935
|
-
systemPrompt: params.systemPrompt,
|
|
3936
|
-
allowedTools: params.allowedTools,
|
|
3937
|
-
deniedTools,
|
|
3938
|
-
maxIterations: { ...DEFAULT_MAX_ITERATIONS },
|
|
3939
|
-
defaultThoroughness: DEFAULT_THOROUGHNESS,
|
|
3940
|
-
defaultVariables: params.variables,
|
|
3941
|
-
retry: { ...DEFAULT_RETRY_CONFIG }
|
|
3942
|
-
};
|
|
3943
|
-
const spawnOptions = {
|
|
3944
|
-
task: params.task,
|
|
3945
|
-
agentName: params.name,
|
|
3946
|
-
thoroughness: params.thoroughness,
|
|
3947
|
-
variables: params.variables,
|
|
3948
|
-
parentSessionId,
|
|
3949
|
-
model: params.model,
|
|
3950
|
-
allowedTools: params.allowedTools,
|
|
3951
|
-
agentDefinition
|
|
3952
|
-
};
|
|
3953
|
-
if (params.run_in_background && backgroundManager) {
|
|
3954
|
-
const jobId = backgroundManager.spawn({
|
|
3955
|
-
...spawnOptions,
|
|
3956
|
-
groupDescription: params.group_description
|
|
3957
|
-
});
|
|
3958
|
-
return `Dynamic background agent "${params.name}" started. Job ID: ${jobId}. Use check_agent_status tool with this job ID to retrieve results when ready.`;
|
|
3959
|
-
}
|
|
3960
|
-
return (await orchestrator.delegateToAgent(spawnOptions)).summary;
|
|
3961
|
-
},
|
|
3962
|
-
toolSchema: {
|
|
3963
|
-
name: "create_dynamic_agent",
|
|
3964
|
-
description: `Create and spawn a one-off agent at runtime with a custom system prompt.
|
|
3965
|
-
|
|
3966
|
-
Unlike agent_delegate (which uses pre-defined agents), this tool lets you compose a new agent on the fly with custom instructions, model, and tool restrictions.
|
|
3967
|
-
|
|
3968
|
-
**When to use this tool:**
|
|
3969
|
-
- When no existing agent fits the task at hand
|
|
3970
|
-
- When you need a specialized agent with custom instructions for a one-off task
|
|
3971
|
-
- When you need fine-grained control over what tools the agent can access
|
|
3972
|
-
|
|
3973
|
-
**Constraints:**
|
|
3974
|
-
- Dynamic agents CANNOT call agent_delegate or create_dynamic_agent (no recursive spawning)
|
|
3975
|
-
- Dynamic agents are ephemeral — they are not saved or reusable across sessions
|
|
3976
|
-
|
|
3977
|
-
**Example uses:**
|
|
3978
|
-
- Create a security auditor agent with specific review criteria
|
|
3979
|
-
- Create a migration agent that follows a custom checklist
|
|
3980
|
-
- Create a specialized code generator with domain-specific instructions`,
|
|
3981
|
-
parameters: {
|
|
3982
|
-
type: "object",
|
|
3983
|
-
properties: {
|
|
3984
|
-
task: {
|
|
3985
|
-
type: "string",
|
|
3986
|
-
description: "Clear description of what you want the dynamic agent to accomplish."
|
|
3987
|
-
},
|
|
3988
|
-
name: {
|
|
3989
|
-
type: "string",
|
|
3990
|
-
description: "Unique name for this dynamic agent (e.g., \"security-auditor\", \"migration-helper\"). Used for logging and identification."
|
|
3991
|
-
},
|
|
3992
|
-
systemPrompt: {
|
|
3993
|
-
type: "string",
|
|
3994
|
-
description: "Custom system prompt for the agent. This defines the agent's role, capabilities, and constraints. Use $TASK to reference the task parameter."
|
|
3995
|
-
},
|
|
3996
|
-
description: {
|
|
3997
|
-
type: "string",
|
|
3998
|
-
description: "Short description of the agent's purpose (for logging)."
|
|
3999
|
-
},
|
|
4000
|
-
model: {
|
|
4001
|
-
type: "string",
|
|
4002
|
-
description: "Model to use for this agent (e.g., \"claude-sonnet-4-5-20250929\"). Defaults to the agent system default."
|
|
4003
|
-
},
|
|
4004
|
-
allowedTools: {
|
|
4005
|
-
type: "array",
|
|
4006
|
-
items: { type: "string" },
|
|
4007
|
-
description: "Whitelist of tool name patterns the agent can use. Supports wildcards (e.g., \"file_*\", \"mcp__github__*\"). If omitted, all non-denied tools are available."
|
|
4008
|
-
},
|
|
4009
|
-
deniedTools: {
|
|
4010
|
-
type: "array",
|
|
4011
|
-
items: { type: "string" },
|
|
4012
|
-
description: "Blacklist of tool name patterns the agent cannot use. agent_delegate and create_dynamic_agent are always denied."
|
|
4013
|
-
},
|
|
4014
|
-
thoroughness: {
|
|
4015
|
-
type: "string",
|
|
4016
|
-
enum: [
|
|
4017
|
-
"quick",
|
|
4018
|
-
"medium",
|
|
4019
|
-
"very_thorough"
|
|
4020
|
-
],
|
|
4021
|
-
description: `How thoroughly to execute:
|
|
4022
|
-
- quick: Fast, 1-2 iterations
|
|
4023
|
-
- medium: Balanced, 3-5 iterations (default)
|
|
4024
|
-
- very_thorough: Comprehensive, 8-10+ iterations`
|
|
4025
|
-
},
|
|
4026
|
-
variables: {
|
|
4027
|
-
type: "object",
|
|
4028
|
-
additionalProperties: { type: "string" },
|
|
4029
|
-
description: "Variables to substitute in the system prompt. For example: { \"DOMAIN\": \"auth\" } replaces $DOMAIN in the prompt."
|
|
4030
|
-
},
|
|
4031
|
-
run_in_background: {
|
|
4032
|
-
type: "boolean",
|
|
4033
|
-
description: "Run the agent in the background (non-blocking). Returns a job ID immediately. Use check_agent_status to poll for results."
|
|
4034
|
-
},
|
|
4035
|
-
group_description: {
|
|
4036
|
-
type: "string",
|
|
4037
|
-
description: "Short description of what this group of background agents is working on. Only needed for the first background agent in a group."
|
|
4038
|
-
}
|
|
4039
|
-
},
|
|
4040
|
-
required: [
|
|
4041
|
-
"task",
|
|
4042
|
-
"name",
|
|
4043
|
-
"systemPrompt"
|
|
4044
|
-
]
|
|
4045
|
-
}
|
|
4046
|
-
}
|
|
4047
|
-
};
|
|
4048
|
-
}
|
|
4049
|
-
//#endregion
|
|
4050
|
-
//#region src/tools/deferredToolRegistry.ts
|
|
4051
|
-
/**
|
|
4052
|
-
* Registry of tool schemas that are NOT loaded into the model's initial tool
|
|
4053
|
-
* list. The model sees only the names (via the system prompt directory) and
|
|
4054
|
-
* must call the `tool_search` meta-tool to load schemas on demand.
|
|
4055
|
-
*
|
|
4056
|
-
* This mirrors Claude Code's deferred-tool pattern. The win is large for
|
|
4057
|
-
* heavy MCP integrations (e.g. 41 GitHub MCP tools at ~250-350 tokens of
|
|
4058
|
-
* JSONSchema each = ~10-15k tokens per turn that's now ~1-1.5k of names).
|
|
4059
|
-
*/
|
|
4060
|
-
var DeferredToolRegistry = class {
|
|
4061
|
-
constructor() {
|
|
4062
|
-
this.byName = /* @__PURE__ */ new Map();
|
|
4063
|
-
}
|
|
4064
|
-
/** Replace registry contents with the supplied tools. Idempotent. */
|
|
4065
|
-
register(tools) {
|
|
4066
|
-
this.byName.clear();
|
|
4067
|
-
for (const tool of tools) this.byName.set(tool.toolSchema.name, tool);
|
|
4068
|
-
logger.debug(`[DeferredToolRegistry] Registered ${tools.length} deferred tool(s)`);
|
|
4069
|
-
}
|
|
4070
|
-
clear() {
|
|
4071
|
-
this.byName.clear();
|
|
4072
|
-
}
|
|
4073
|
-
size() {
|
|
4074
|
-
return this.byName.size;
|
|
4075
|
-
}
|
|
4076
|
-
has(name) {
|
|
4077
|
-
return this.byName.has(name);
|
|
4078
|
-
}
|
|
4079
|
-
get(name) {
|
|
4080
|
-
return this.byName.get(name);
|
|
4081
|
-
}
|
|
4082
|
-
getAll() {
|
|
4083
|
-
return Array.from(this.byName.values());
|
|
4084
|
-
}
|
|
4085
|
-
/** Return tools whose names appear in the supplied list, in input order. */
|
|
4086
|
-
getByNames(names) {
|
|
4087
|
-
const found = [];
|
|
4088
|
-
for (const name of names) {
|
|
4089
|
-
const tool = this.byName.get(name);
|
|
4090
|
-
if (tool) found.push(tool);
|
|
4091
|
-
}
|
|
4092
|
-
return found;
|
|
4093
|
-
}
|
|
4094
|
-
/**
|
|
4095
|
-
* Rank-search deferred tools by query terms. Name matches outrank
|
|
4096
|
-
* description matches; exact substring on name wins ties.
|
|
4097
|
-
*/
|
|
4098
|
-
searchByKeywords(query, maxResults) {
|
|
4099
|
-
const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
|
|
4100
|
-
if (terms.length === 0) return [];
|
|
4101
|
-
const scored = [];
|
|
4102
|
-
for (const tool of this.byName.values()) {
|
|
4103
|
-
const name = tool.toolSchema.name.toLowerCase();
|
|
4104
|
-
const desc = (tool.toolSchema.description || "").toLowerCase();
|
|
4105
|
-
let score = 0;
|
|
4106
|
-
for (const term of terms) {
|
|
4107
|
-
if (name.includes(term)) score += 10;
|
|
4108
|
-
if (desc.includes(term)) score += 1;
|
|
4109
|
-
}
|
|
4110
|
-
if (score > 0) scored.push({
|
|
4111
|
-
tool,
|
|
4112
|
-
score
|
|
4113
|
-
});
|
|
4114
|
-
}
|
|
4115
|
-
scored.sort((a, b) => b.score - a.score);
|
|
4116
|
-
return scored.slice(0, maxResults).map((s) => s.tool);
|
|
4117
|
-
}
|
|
4118
|
-
/** Return the directory entries used to render the system-prompt reminder. */
|
|
4119
|
-
getDirectoryNames() {
|
|
4120
|
-
return Array.from(this.byName.keys()).sort();
|
|
4121
|
-
}
|
|
4122
|
-
};
|
|
4123
|
-
const deferredToolRegistry = new DeferredToolRegistry();
|
|
4124
|
-
//#endregion
|
|
4125
|
-
//#region src/tools/toolSearchTool.ts
|
|
4126
|
-
/**
|
|
4127
|
-
* Default number of tools returned for a keyword search. Matches Claude
|
|
4128
|
-
* Code's ToolSearch convention. 5 keeps the response payload small while
|
|
4129
|
-
* surfacing enough alternatives for the model to refine its query.
|
|
4130
|
-
*/
|
|
4131
|
-
const DEFAULT_MAX_RESULTS = 5;
|
|
4132
|
-
/**
|
|
4133
|
-
* Runtime validation for tool_search params. The LLM produces these
|
|
4134
|
-
* values, so we validate at this boundary rather than trusting the
|
|
4135
|
-
* shape. Coerces `max_results` from string→number for models that emit
|
|
4136
|
-
* numeric-looking strings.
|
|
4137
|
-
*/
|
|
4138
|
-
const ToolSearchParamsSchema = z.object({
|
|
4139
|
-
query: z.string().min(1, "query must be a non-empty string"),
|
|
4140
|
-
max_results: z.coerce.number().int().min(1).max(20).optional()
|
|
4141
|
-
});
|
|
4142
|
-
/**
|
|
4143
|
-
* Parse the query string. Two forms:
|
|
4144
|
-
* - `select:name1,name2,...` — exact-name selection
|
|
4145
|
-
* - free text — keyword search across name + description
|
|
4146
|
-
*/
|
|
4147
|
-
function parseQuery(query) {
|
|
4148
|
-
const trimmed = query.trim();
|
|
4149
|
-
const selectMatch = trimmed.match(/^select:(.+)$/i);
|
|
4150
|
-
if (selectMatch) return {
|
|
4151
|
-
mode: "select",
|
|
4152
|
-
names: selectMatch[1].split(",").map((n) => n.trim()).filter((n) => n.length > 0)
|
|
4153
|
-
};
|
|
4154
|
-
return {
|
|
4155
|
-
mode: "search",
|
|
4156
|
-
text: trimmed
|
|
4157
|
-
};
|
|
4158
|
-
}
|
|
4159
|
-
/**
|
|
4160
|
-
* Format the loaded-tools response. Mirrors Claude Code's convention:
|
|
4161
|
-
* one <function>{...}</function> line per matched tool. The model has
|
|
4162
|
-
* already seen this format in its tool-registration system messages, so
|
|
4163
|
-
* it parses without additional explanation.
|
|
4164
|
-
*
|
|
4165
|
-
* Note: the schemas are *also* injected into context.tools by the caller,
|
|
4166
|
-
* so on the next iteration the model gets them as native tool definitions.
|
|
4167
|
-
* The text response here is for in-turn awareness and audit trail.
|
|
4168
|
-
*/
|
|
4169
|
-
function renderToolsBlock(tools) {
|
|
4170
|
-
if (tools.length === 0) return "";
|
|
4171
|
-
return `<functions>\n${tools.map((tool) => {
|
|
4172
|
-
const schema = {
|
|
4173
|
-
description: tool.toolSchema.description,
|
|
4174
|
-
name: tool.toolSchema.name,
|
|
4175
|
-
parameters: tool.toolSchema.parameters
|
|
4176
|
-
};
|
|
4177
|
-
return `<function>${JSON.stringify(schema)}</function>`;
|
|
4178
|
-
}).join("\n")}\n</functions>`;
|
|
4179
|
-
}
|
|
4180
|
-
/**
|
|
4181
|
-
* Build the tool_search meta-tool. The returned tool has a closure over
|
|
4182
|
-
* the supplied `toolListAccessor`, which it uses to push newly-resolved
|
|
4183
|
-
* tool schemas into the live agent context.
|
|
4184
|
-
*
|
|
4185
|
-
* Idempotent: re-loading a tool that's already in the context is a no-op.
|
|
4186
|
-
*/
|
|
4187
|
-
function createToolSearchTool(toolListAccessor) {
|
|
4188
|
-
return {
|
|
4189
|
-
toolSchema: {
|
|
4190
|
-
name: "tool_search",
|
|
4191
|
-
description: "Fetches full schema definitions for deferred tools so they can be called. Deferred tools appear by name only in a system reminder; their parameter schemas are NOT loaded by default. Use this tool to load schemas on demand. Query forms: 'select:name1,name2' for exact selection, or free-text keywords to search by name and description. Once a tool's schema is returned, it becomes callable in subsequent turns.",
|
|
4192
|
-
parameters: {
|
|
4193
|
-
type: "object",
|
|
4194
|
-
properties: {
|
|
4195
|
-
query: {
|
|
4196
|
-
type: "string",
|
|
4197
|
-
description: "Either 'select:<comma-separated names>' to fetch specific tools, or free-text keywords (e.g. 'github pull request') to rank-search deferred tools."
|
|
4198
|
-
},
|
|
4199
|
-
max_results: {
|
|
4200
|
-
type: "number",
|
|
4201
|
-
description: `Maximum number of tools to return for keyword search. Defaults to ${DEFAULT_MAX_RESULTS}. Ignored for 'select:' queries.`
|
|
4202
|
-
}
|
|
4203
|
-
},
|
|
4204
|
-
required: ["query"]
|
|
4205
|
-
}
|
|
4206
|
-
},
|
|
4207
|
-
toolFn: async (params) => {
|
|
4208
|
-
const parsedParams = ToolSearchParamsSchema.safeParse(params ?? {});
|
|
4209
|
-
if (!parsedParams.success) {
|
|
4210
|
-
const issue = parsedParams.error.issues[0];
|
|
4211
|
-
return `tool_search: invalid parameters — ${issue.path.join(".") || "params"}: ${issue.message}`;
|
|
4212
|
-
}
|
|
4213
|
-
const { query, max_results } = parsedParams.data;
|
|
4214
|
-
const parsed = parseQuery(query);
|
|
4215
|
-
let matched;
|
|
4216
|
-
let unmatched = [];
|
|
4217
|
-
if (parsed.mode === "select") {
|
|
4218
|
-
matched = deferredToolRegistry.getByNames(parsed.names);
|
|
4219
|
-
const foundNames = new Set(matched.map((t) => t.toolSchema.name));
|
|
4220
|
-
unmatched = parsed.names.filter((n) => !foundNames.has(n));
|
|
4221
|
-
} else {
|
|
4222
|
-
const max = max_results ?? DEFAULT_MAX_RESULTS;
|
|
4223
|
-
matched = deferredToolRegistry.searchByKeywords(parsed.text, max);
|
|
4224
|
-
}
|
|
4225
|
-
if (matched.length === 0) return parsed.mode === "select" ? `tool_search: no deferred tools matched ${parsed.names.join(", ")}. Use a free-text query to search.` : `tool_search: no deferred tools matched query "${parsed.text}".`;
|
|
4226
|
-
const liveTools = toolListAccessor();
|
|
4227
|
-
const liveNames = new Set(liveTools.map((t) => t.toolSchema.name));
|
|
4228
|
-
let added = 0;
|
|
4229
|
-
for (const tool of matched) if (!liveNames.has(tool.toolSchema.name)) {
|
|
4230
|
-
liveTools.push(tool);
|
|
4231
|
-
added++;
|
|
4232
|
-
}
|
|
4233
|
-
logger.debug(`[tool_search] query="${query}" matched=${matched.length} added=${added} alreadyLoaded=${matched.length - added}`);
|
|
4234
|
-
const block = renderToolsBlock(matched);
|
|
4235
|
-
return `${`Loaded ${added} new tool schema(s)${added < matched.length ? ` (${matched.length - added} already loaded)` : ""}. These are now callable in your next message.${unmatched.length > 0 ? `\n\nNot found: ${unmatched.join(", ")}` : ""}`}\n\n${block}`;
|
|
4236
|
-
}
|
|
4237
|
-
};
|
|
4238
|
-
}
|
|
4239
|
-
//#endregion
|
|
4240
|
-
//#region src/features/FeatureModuleRegistry.ts
|
|
4241
|
-
/**
|
|
4242
|
-
* Manages the lifecycle of opt-in CLI feature modules.
|
|
4243
|
-
*
|
|
4244
|
-
* Created during bootstrap, modules are conditionally registered based on config,
|
|
4245
|
-
* then the registry's outputs are fed into tool generation and prompt building.
|
|
4246
|
-
*/
|
|
4247
|
-
var FeatureModuleRegistry = class {
|
|
4248
|
-
constructor() {
|
|
4249
|
-
this.modules = [];
|
|
4250
|
-
}
|
|
4251
|
-
/** Register a feature module */
|
|
4252
|
-
register(module) {
|
|
4253
|
-
if (this.modules.some((m) => m.name === module.name)) throw new Error(`Feature module '${module.name}' is already registered`);
|
|
4254
|
-
this.modules.push(module);
|
|
4255
|
-
}
|
|
4256
|
-
/** Collect all tools from all registered modules */
|
|
4257
|
-
getAllTools() {
|
|
4258
|
-
return this.modules.flatMap((m) => m.getTools());
|
|
4259
|
-
}
|
|
4260
|
-
/** Get all tool names from all registered modules */
|
|
4261
|
-
getAllToolNames() {
|
|
4262
|
-
return this.getAllTools().map((t) => t.toolSchema.name);
|
|
4263
|
-
}
|
|
4264
|
-
/** Build combined system prompt section from all modules */
|
|
4265
|
-
getSystemPromptSections() {
|
|
4266
|
-
const sections = this.modules.map((m) => m.getSystemPromptSection()).filter((s) => s.length > 0);
|
|
4267
|
-
return sections.length > 0 ? "\n\n" + sections.join("\n\n") : "";
|
|
4268
|
-
}
|
|
4269
|
-
/** Register all WS handlers from all modules */
|
|
4270
|
-
registerAllWsHandlers(wsManager) {
|
|
4271
|
-
for (const module of this.modules) module.registerWsHandlers?.(wsManager);
|
|
4272
|
-
}
|
|
4273
|
-
/** Collect all slash commands from all registered modules */
|
|
4274
|
-
getAllCommands() {
|
|
4275
|
-
return this.modules.flatMap((m) => m.getCommands?.() ?? []);
|
|
4276
|
-
}
|
|
4277
|
-
/** Try to execute a slash command. Returns true if handled. */
|
|
4278
|
-
executeCommand(name, args) {
|
|
4279
|
-
for (const module of this.modules) {
|
|
4280
|
-
const command = (module.getCommands?.() ?? []).find((c) => c.name === name);
|
|
4281
|
-
if (command) {
|
|
4282
|
-
command.execute(args);
|
|
4283
|
-
return true;
|
|
4284
|
-
}
|
|
4285
|
-
}
|
|
4286
|
-
return false;
|
|
4287
|
-
}
|
|
4288
|
-
/** Cleanup all modules */
|
|
4289
|
-
disposeAll() {
|
|
4290
|
-
for (const module of this.modules) module.dispose?.();
|
|
4291
|
-
}
|
|
4292
|
-
/** Get names of all registered modules */
|
|
4293
|
-
getModuleNames() {
|
|
4294
|
-
return this.modules.map((m) => m.name);
|
|
4295
|
-
}
|
|
4296
|
-
/** Check if any modules are registered */
|
|
4297
|
-
get hasModules() {
|
|
4298
|
-
return this.modules.length > 0;
|
|
4299
|
-
}
|
|
4300
|
-
};
|
|
4301
|
-
//#endregion
|
|
4302
|
-
//#region src/features/tavern/TavernService.ts
|
|
4303
|
-
/**
|
|
4304
|
-
* HTTP implementation of ITavernService using the shared ApiClient.
|
|
4305
|
-
*
|
|
4306
|
-
* Each method maps to one /api/tavern/* endpoint.
|
|
4307
|
-
* Pure transport — no business logic.
|
|
4308
|
-
*/
|
|
4309
|
-
var TavernService = class {
|
|
4310
|
-
constructor(apiClient) {
|
|
4311
|
-
this.apiClient = apiClient;
|
|
4312
|
-
}
|
|
4313
|
-
async listAgents() {
|
|
4314
|
-
return this.apiClient.get("/api/agents?limit=100");
|
|
4315
|
-
}
|
|
4316
|
-
async createAgent(request) {
|
|
4317
|
-
return this.apiClient.post("/api/agents", request);
|
|
4318
|
-
}
|
|
4319
|
-
async updateAgent(agentId, request) {
|
|
4320
|
-
return this.apiClient.put(`/api/agents/${encodeURIComponent(agentId)}`, request);
|
|
4321
|
-
}
|
|
4322
|
-
async deleteAgent(agentId) {
|
|
4323
|
-
await this.apiClient.delete(`/api/agents/${encodeURIComponent(agentId)}`);
|
|
4324
|
-
}
|
|
4325
|
-
async mentionAgent(agentName, message, config) {
|
|
4326
|
-
return this.apiClient.post("/api/tavern/mention", {
|
|
4327
|
-
agentName,
|
|
4328
|
-
message,
|
|
4329
|
-
config
|
|
4330
|
-
});
|
|
4331
|
-
}
|
|
4332
|
-
async listQuests() {
|
|
4333
|
-
return this.apiClient.get("/api/tavern/quests");
|
|
4334
|
-
}
|
|
4335
|
-
async postQuest(request) {
|
|
4336
|
-
return this.apiClient.post("/api/tavern/quests", request);
|
|
4337
|
-
}
|
|
4338
|
-
async deleteQuest(questId) {
|
|
4339
|
-
await this.apiClient.delete(`/api/tavern/quests`, { data: { questId } });
|
|
4340
|
-
}
|
|
4341
|
-
async getAgentNotebook(agentId, limit = 50) {
|
|
4342
|
-
return this.apiClient.get(`/api/tavern/agent-notebook?agentId=${encodeURIComponent(agentId)}&limit=${limit}`);
|
|
4343
|
-
}
|
|
4344
|
-
async listGates() {
|
|
4345
|
-
return this.apiClient.get("/api/tavern/gates");
|
|
4346
|
-
}
|
|
4347
|
-
async resolveGate(gateId, resolution) {
|
|
4348
|
-
return this.apiClient.post("/api/tavern/gate-resolve", {
|
|
4349
|
-
gateId,
|
|
4350
|
-
resolution
|
|
4351
|
-
});
|
|
4352
|
-
}
|
|
4353
|
-
async toggleHeartbeats(enabled) {
|
|
4354
|
-
return this.apiClient.post("/api/tavern/toggle-heartbeats", { enabled });
|
|
4355
|
-
}
|
|
4356
|
-
async triggerHeartbeat(config) {
|
|
4357
|
-
return this.apiClient.post("/api/tavern/trigger-heartbeat", { config });
|
|
4358
|
-
}
|
|
4359
|
-
async abortHeartbeats() {
|
|
4360
|
-
await this.apiClient.post("/api/tavern/abort-heartbeats", { abort: true });
|
|
4361
|
-
}
|
|
4362
|
-
async getQuestPlan(planId) {
|
|
4363
|
-
return this.apiClient.get(`/api/quest-master-plans/${encodeURIComponent(planId)}`);
|
|
4364
|
-
}
|
|
4365
|
-
async updateReviewGate(planId, questId, subQuestId, reviewStatus, reviewNote) {
|
|
4366
|
-
return this.apiClient.post(`/api/quest-master-plans/${encodeURIComponent(planId)}/review-gate`, {
|
|
4367
|
-
questId,
|
|
4368
|
-
subQuestId,
|
|
4369
|
-
reviewStatus,
|
|
4370
|
-
reviewNote
|
|
4371
|
-
});
|
|
4372
|
-
}
|
|
4373
|
-
async updateSubQuestProgress(planId, questId, subQuestId, updates) {
|
|
4374
|
-
return this.apiClient.post(`/api/quest-master-plans/${encodeURIComponent(planId)}/subquest-progress`, {
|
|
4375
|
-
questId,
|
|
4376
|
-
subQuestId,
|
|
4377
|
-
...updates
|
|
4378
|
-
});
|
|
4379
|
-
}
|
|
4380
|
-
async updateHandoff(planId, handoff) {
|
|
4381
|
-
return this.apiClient.post(`/api/quest-master-plans/${encodeURIComponent(planId)}/handoff`, handoff);
|
|
4382
|
-
}
|
|
4383
|
-
};
|
|
4384
|
-
//#endregion
|
|
4385
|
-
//#region src/features/tavern/types.ts
|
|
4386
|
-
/**
|
|
4387
|
-
* Zod schemas and TypeScript types for the Tavern CLI integration.
|
|
3908
|
+
* Zod schemas and TypeScript types for the Tavern CLI integration.
|
|
4388
3909
|
*
|
|
4389
3910
|
* These map to the REST API shapes in apps/client/pages/api/tavern/*.
|
|
4390
3911
|
*/
|
|
@@ -5207,713 +4728,1697 @@ function createToggleHeartbeatsTool(service) {
|
|
|
5207
4728
|
}
|
|
5208
4729
|
},
|
|
5209
4730
|
toolFn: async (params) => {
|
|
5210
|
-
const { enabled } = params;
|
|
5211
|
-
const result = await service.toggleHeartbeats(enabled);
|
|
4731
|
+
const { enabled } = params;
|
|
4732
|
+
const result = await service.toggleHeartbeats(enabled);
|
|
4733
|
+
return JSON.stringify(result);
|
|
4734
|
+
}
|
|
4735
|
+
};
|
|
4736
|
+
}
|
|
4737
|
+
function createTriggerHeartbeatTool(service) {
|
|
4738
|
+
return {
|
|
4739
|
+
toolSchema: {
|
|
4740
|
+
name: "tavern_trigger_heartbeat",
|
|
4741
|
+
description: "Manually trigger a heartbeat cycle for all Tavern agents. Useful for testing or forcing immediate agent activity.",
|
|
4742
|
+
parameters: {
|
|
4743
|
+
type: "object",
|
|
4744
|
+
properties: { config: {
|
|
4745
|
+
type: "object",
|
|
4746
|
+
description: "Optional configuration overrides for the heartbeat. Known keys: preferredModelId (string — model ID to use), costMode (\"low\" | \"normal\" | \"high\")",
|
|
4747
|
+
additionalProperties: true
|
|
4748
|
+
} }
|
|
4749
|
+
}
|
|
4750
|
+
},
|
|
4751
|
+
toolFn: async (params) => {
|
|
4752
|
+
const { config } = params ?? {};
|
|
4753
|
+
const result = await service.triggerHeartbeat(config);
|
|
4754
|
+
return JSON.stringify(result);
|
|
4755
|
+
}
|
|
4756
|
+
};
|
|
4757
|
+
}
|
|
4758
|
+
function createAbortHeartbeatsTool(service) {
|
|
4759
|
+
return {
|
|
4760
|
+
toolSchema: {
|
|
4761
|
+
name: "tavern_abort_heartbeats",
|
|
4762
|
+
description: "Emergency stop — abort all in-flight Tavern agent heartbeats immediately.",
|
|
4763
|
+
parameters: {
|
|
4764
|
+
type: "object",
|
|
4765
|
+
properties: {}
|
|
4766
|
+
}
|
|
4767
|
+
},
|
|
4768
|
+
toolFn: async () => {
|
|
4769
|
+
await service.abortHeartbeats();
|
|
4770
|
+
return JSON.stringify({
|
|
4771
|
+
success: true,
|
|
4772
|
+
message: "All in-flight heartbeats aborted"
|
|
4773
|
+
});
|
|
4774
|
+
}
|
|
4775
|
+
};
|
|
4776
|
+
}
|
|
4777
|
+
function createStatusTool(service) {
|
|
4778
|
+
return {
|
|
4779
|
+
toolSchema: {
|
|
4780
|
+
name: "tavern_status",
|
|
4781
|
+
description: "Get a quick overview of the Tavern: agent count, heartbeat status, active quests, and pending gates. Use this for situational awareness before taking action.",
|
|
4782
|
+
parameters: {
|
|
4783
|
+
type: "object",
|
|
4784
|
+
properties: {}
|
|
4785
|
+
}
|
|
4786
|
+
},
|
|
4787
|
+
toolFn: async () => {
|
|
4788
|
+
const [agents, quests, gates] = await Promise.all([
|
|
4789
|
+
service.listAgents(),
|
|
4790
|
+
service.listQuests(),
|
|
4791
|
+
service.listGates()
|
|
4792
|
+
]);
|
|
4793
|
+
const agentList = agents.data ?? [];
|
|
4794
|
+
const heartbeatEnabled = agentList.filter((a) => a.heartbeatConfig?.enabled);
|
|
4795
|
+
return JSON.stringify({
|
|
4796
|
+
agents: {
|
|
4797
|
+
total: agentList.length,
|
|
4798
|
+
withHeartbeats: heartbeatEnabled.length,
|
|
4799
|
+
names: agentList.map((a) => ({
|
|
4800
|
+
name: a.name,
|
|
4801
|
+
heartbeat: a.heartbeatConfig?.enabled ?? false
|
|
4802
|
+
}))
|
|
4803
|
+
},
|
|
4804
|
+
quests: {
|
|
4805
|
+
total: quests.quests.length,
|
|
4806
|
+
byStatus: quests.quests.reduce((acc, q) => {
|
|
4807
|
+
acc[q.status] = (acc[q.status] ?? 0) + 1;
|
|
4808
|
+
return acc;
|
|
4809
|
+
}, {})
|
|
4810
|
+
},
|
|
4811
|
+
gates: { pending: gates.gates.length }
|
|
4812
|
+
});
|
|
4813
|
+
}
|
|
4814
|
+
};
|
|
4815
|
+
}
|
|
4816
|
+
function createGetQuestPlanTool(service) {
|
|
4817
|
+
return {
|
|
4818
|
+
toolSchema: {
|
|
4819
|
+
name: "tavern_get_quest_plan",
|
|
4820
|
+
description: "Fetch a quest master plan by ID. Returns the full plan with all quests, sub-quests, review gate status, handoff state, and progress metrics. Use this to check which sub-quests have review gates and their current status.",
|
|
4821
|
+
parameters: {
|
|
4822
|
+
type: "object",
|
|
4823
|
+
properties: { plan_id: {
|
|
4824
|
+
type: "string",
|
|
4825
|
+
description: "The MongoDB ObjectId of the quest master plan"
|
|
4826
|
+
} },
|
|
4827
|
+
required: ["plan_id"]
|
|
4828
|
+
}
|
|
4829
|
+
},
|
|
4830
|
+
toolFn: async (params) => {
|
|
4831
|
+
const { plan_id } = GetQuestPlanParamsSchema.parse(params);
|
|
4832
|
+
const result = await service.getQuestPlan(plan_id);
|
|
4833
|
+
return JSON.stringify(result);
|
|
4834
|
+
}
|
|
4835
|
+
};
|
|
4836
|
+
}
|
|
4837
|
+
function createUpdateReviewGateTool(service) {
|
|
4838
|
+
return {
|
|
4839
|
+
toolSchema: {
|
|
4840
|
+
name: "tavern_update_review_gate",
|
|
4841
|
+
description: "Approve or reject a review gate on a sub-quest. Review gates are human approval checkpoints — when a sub-quest has reviewGate: true, the AI must stop and wait for human approval before proceeding.",
|
|
4842
|
+
parameters: {
|
|
4843
|
+
type: "object",
|
|
4844
|
+
properties: {
|
|
4845
|
+
plan_id: {
|
|
4846
|
+
type: "string",
|
|
4847
|
+
description: "The MongoDB ObjectId of the quest master plan"
|
|
4848
|
+
},
|
|
4849
|
+
quest_id: {
|
|
4850
|
+
type: "string",
|
|
4851
|
+
description: "The ID of the parent quest"
|
|
4852
|
+
},
|
|
4853
|
+
sub_quest_id: {
|
|
4854
|
+
type: "string",
|
|
4855
|
+
description: "The ID of the sub-quest with the review gate"
|
|
4856
|
+
},
|
|
4857
|
+
review_status: {
|
|
4858
|
+
type: "string",
|
|
4859
|
+
description: "The review decision",
|
|
4860
|
+
enum: [
|
|
4861
|
+
"pending",
|
|
4862
|
+
"approved",
|
|
4863
|
+
"rejected"
|
|
4864
|
+
]
|
|
4865
|
+
},
|
|
4866
|
+
review_note: {
|
|
4867
|
+
type: "string",
|
|
4868
|
+
description: "Optional note explaining the review decision"
|
|
4869
|
+
}
|
|
4870
|
+
},
|
|
4871
|
+
required: [
|
|
4872
|
+
"plan_id",
|
|
4873
|
+
"quest_id",
|
|
4874
|
+
"sub_quest_id",
|
|
4875
|
+
"review_status"
|
|
4876
|
+
]
|
|
4877
|
+
}
|
|
4878
|
+
},
|
|
4879
|
+
toolFn: async (params) => {
|
|
4880
|
+
const { plan_id, quest_id, sub_quest_id, review_status, review_note } = UpdateReviewGateParamsSchema.parse(params);
|
|
4881
|
+
const result = await service.updateReviewGate(plan_id, quest_id, sub_quest_id, review_status, review_note);
|
|
4882
|
+
return JSON.stringify(result);
|
|
4883
|
+
}
|
|
4884
|
+
};
|
|
4885
|
+
}
|
|
4886
|
+
function createUpdateQuestProgressTool(service) {
|
|
4887
|
+
return {
|
|
4888
|
+
toolSchema: {
|
|
4889
|
+
name: "tavern_update_quest_progress",
|
|
4890
|
+
description: "Update a sub-quest's progress. Set status to track completion, add evidence of what was accomplished, and optionally record time spent. Setting status to \"in_progress\" auto-resumes a paused plan.",
|
|
4891
|
+
parameters: {
|
|
4892
|
+
type: "object",
|
|
4893
|
+
properties: {
|
|
4894
|
+
plan_id: {
|
|
4895
|
+
type: "string",
|
|
4896
|
+
description: "The MongoDB ObjectId of the quest master plan"
|
|
4897
|
+
},
|
|
4898
|
+
quest_id: {
|
|
4899
|
+
type: "string",
|
|
4900
|
+
description: "The ID of the parent quest"
|
|
4901
|
+
},
|
|
4902
|
+
sub_quest_id: {
|
|
4903
|
+
type: "string",
|
|
4904
|
+
description: "The ID of the sub-quest to update"
|
|
4905
|
+
},
|
|
4906
|
+
status: {
|
|
4907
|
+
type: "string",
|
|
4908
|
+
description: "New status for the sub-quest",
|
|
4909
|
+
enum: [
|
|
4910
|
+
"not_started",
|
|
4911
|
+
"in_progress",
|
|
4912
|
+
"completed",
|
|
4913
|
+
"skipped",
|
|
4914
|
+
"deleted"
|
|
4915
|
+
]
|
|
4916
|
+
},
|
|
4917
|
+
evidence: {
|
|
4918
|
+
type: "string",
|
|
4919
|
+
description: "Evidence of completion — links to artifacts, descriptions of output, or references to results"
|
|
4920
|
+
},
|
|
4921
|
+
time_spent: {
|
|
4922
|
+
type: "number",
|
|
4923
|
+
description: "Time spent on this sub-quest in milliseconds"
|
|
4924
|
+
}
|
|
4925
|
+
},
|
|
4926
|
+
required: [
|
|
4927
|
+
"plan_id",
|
|
4928
|
+
"quest_id",
|
|
4929
|
+
"sub_quest_id"
|
|
4930
|
+
]
|
|
4931
|
+
}
|
|
4932
|
+
},
|
|
4933
|
+
toolFn: async (params) => {
|
|
4934
|
+
const { plan_id, quest_id, sub_quest_id, status, evidence, time_spent } = UpdateQuestProgressParamsSchema.parse(params);
|
|
4935
|
+
const updates = {};
|
|
4936
|
+
if (status !== void 0) updates.status = status;
|
|
4937
|
+
if (evidence !== void 0) updates.evidence = evidence;
|
|
4938
|
+
if (time_spent !== void 0) updates.timeSpent = time_spent;
|
|
4939
|
+
const result = await service.updateSubQuestProgress(plan_id, quest_id, sub_quest_id, updates);
|
|
5212
4940
|
return JSON.stringify(result);
|
|
5213
4941
|
}
|
|
5214
4942
|
};
|
|
5215
4943
|
}
|
|
5216
|
-
function
|
|
4944
|
+
function createWriteHandoffTool(service) {
|
|
5217
4945
|
return {
|
|
5218
4946
|
toolSchema: {
|
|
5219
|
-
name: "
|
|
5220
|
-
description: "
|
|
4947
|
+
name: "tavern_write_handoff",
|
|
4948
|
+
description: "Write a handoff state for session continuity. Called when ending a session so the next session can resume with full context. Includes a summary of progress, next steps, pending decisions, and blockers.",
|
|
5221
4949
|
parameters: {
|
|
5222
4950
|
type: "object",
|
|
5223
|
-
properties: {
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
4951
|
+
properties: {
|
|
4952
|
+
plan_id: {
|
|
4953
|
+
type: "string",
|
|
4954
|
+
description: "The MongoDB ObjectId of the quest master plan"
|
|
4955
|
+
},
|
|
4956
|
+
summary: {
|
|
4957
|
+
type: "string",
|
|
4958
|
+
description: "Summary of what was accomplished in this session"
|
|
4959
|
+
},
|
|
4960
|
+
next_steps: {
|
|
4961
|
+
type: "array",
|
|
4962
|
+
items: { type: "string" },
|
|
4963
|
+
description: "List of next steps for the following session"
|
|
4964
|
+
},
|
|
4965
|
+
pending_decisions: {
|
|
4966
|
+
type: "array",
|
|
4967
|
+
items: { type: "string" },
|
|
4968
|
+
description: "Decisions that still need to be made"
|
|
4969
|
+
},
|
|
4970
|
+
blockers: {
|
|
4971
|
+
type: "array",
|
|
4972
|
+
items: { type: "string" },
|
|
4973
|
+
description: "Current blockers preventing progress"
|
|
4974
|
+
}
|
|
4975
|
+
},
|
|
4976
|
+
required: [
|
|
4977
|
+
"plan_id",
|
|
4978
|
+
"summary",
|
|
4979
|
+
"next_steps",
|
|
4980
|
+
"pending_decisions",
|
|
4981
|
+
"blockers"
|
|
4982
|
+
]
|
|
5228
4983
|
}
|
|
5229
4984
|
},
|
|
5230
4985
|
toolFn: async (params) => {
|
|
5231
|
-
const {
|
|
5232
|
-
const result = await service.
|
|
4986
|
+
const { plan_id, summary, next_steps, pending_decisions, blockers } = WriteHandoffParamsSchema.parse(params);
|
|
4987
|
+
const result = await service.updateHandoff(plan_id, {
|
|
4988
|
+
summary,
|
|
4989
|
+
nextSteps: next_steps,
|
|
4990
|
+
pendingDecisions: pending_decisions,
|
|
4991
|
+
blockers
|
|
4992
|
+
});
|
|
5233
4993
|
return JSON.stringify(result);
|
|
5234
4994
|
}
|
|
5235
4995
|
};
|
|
5236
4996
|
}
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5243
|
-
|
|
5244
|
-
|
|
4997
|
+
//#endregion
|
|
4998
|
+
//#region src/features/tavern/TavernModule.ts
|
|
4999
|
+
/** Icons for heartbeat log actions shown in /tavern command */
|
|
5000
|
+
const ACTION_ICONS = {
|
|
5001
|
+
speech: "💬",
|
|
5002
|
+
thought: "💭",
|
|
5003
|
+
memory: "🧠",
|
|
5004
|
+
move: "🚶",
|
|
5005
|
+
reply: "📩",
|
|
5006
|
+
post_quest: "📜",
|
|
5007
|
+
claim_quest: "⚔️",
|
|
5008
|
+
complete_quest: "✅",
|
|
5009
|
+
tool_use: "🔧",
|
|
5010
|
+
email: "📧",
|
|
5011
|
+
gate_paused: "⏸️",
|
|
5012
|
+
gate_timed: "⏳",
|
|
5013
|
+
gate_proceed: "▶️",
|
|
5014
|
+
idle: "💤",
|
|
5015
|
+
intent: "🎯",
|
|
5016
|
+
report: "📋",
|
|
5017
|
+
credits: "🪙",
|
|
5018
|
+
move_decoration: "🎨",
|
|
5019
|
+
yolo_override: "⚡"
|
|
5020
|
+
};
|
|
5021
|
+
/**
|
|
5022
|
+
* ICliFeatureModule implementation for the Tavern.
|
|
5023
|
+
*
|
|
5024
|
+
* Composes the service, tool adapters, WS activity stream, and slash
|
|
5025
|
+
* commands into a single module that the FeatureModuleRegistry can manage.
|
|
5026
|
+
*/
|
|
5027
|
+
var TavernModule = class {
|
|
5028
|
+
constructor(apiClient, onLogEntry, getActivityLog) {
|
|
5029
|
+
this.name = "tavern";
|
|
5030
|
+
this.description = "Interact with autonomous AI agents in the B4M Tavern";
|
|
5031
|
+
this.service = new TavernService(apiClient);
|
|
5032
|
+
this.activityStream = new TavernActivityStream(onLogEntry);
|
|
5033
|
+
this.getActivityLog = getActivityLog;
|
|
5034
|
+
}
|
|
5035
|
+
getTools() {
|
|
5036
|
+
return createTavernTools(this.service);
|
|
5037
|
+
}
|
|
5038
|
+
getSystemPromptSection() {
|
|
5039
|
+
return `## Tavern Integration
|
|
5040
|
+
You can interact with autonomous AI agents in the B4M Tavern using tavern_* tools.
|
|
5041
|
+
|
|
5042
|
+
IMPORTANT: Many tools require agent IDs (MongoDB ObjectIds like "6540b58d1f703ade3ea1e82b"). Always use tavern_list_agents FIRST to discover agent names and their IDs before using tools that need an agent_id.
|
|
5043
|
+
|
|
5044
|
+
Available actions:
|
|
5045
|
+
- **tavern_list_agents**: List all agents with their IDs, names, and status — USE THIS FIRST
|
|
5046
|
+
- **tavern_create_agent**: Create a new agent with a personality (heartbeats disabled by default)
|
|
5047
|
+
- **tavern_edit_agent**: Update an agent's personality, system prompt, or heartbeat config (per-agent toggle)
|
|
5048
|
+
- **tavern_delete_agent**: Permanently delete an agent
|
|
5049
|
+
- **tavern_mention**: Talk to a specific agent by name or broadcast to all agents
|
|
5050
|
+
- **tavern_list_quests**: View the quest board
|
|
5051
|
+
- **tavern_post_quest**: Post a new quest for agents to claim
|
|
5052
|
+
- **tavern_delete_quest**: Remove a quest from the board
|
|
5053
|
+
- **tavern_read_notebook**: Read an agent's activity history (requires agent ID from tavern_list_agents)
|
|
5054
|
+
- **tavern_list_gates**: See pending confidence gates awaiting human approval
|
|
5055
|
+
- **tavern_resolve_gate**: Approve or reject a confidence gate
|
|
5056
|
+
- **tavern_toggle_heartbeats**: Enable/disable agent background heartbeats
|
|
5057
|
+
- **tavern_trigger_heartbeat**: Manually trigger a heartbeat cycle
|
|
5058
|
+
- **tavern_abort_heartbeats**: Emergency stop all in-flight heartbeats
|
|
5059
|
+
- **tavern_status**: Quick overview of agents, quests, and gates — good for situational awareness
|
|
5060
|
+
- **tavern_get_quest_plan**: Fetch a quest master plan with review gate status and handoff state
|
|
5061
|
+
- **tavern_update_review_gate**: Approve or reject a review gate on a sub-quest
|
|
5062
|
+
- **tavern_update_quest_progress**: Update sub-quest status and record evidence of completion
|
|
5063
|
+
- **tavern_write_handoff**: Write session handoff for continuity across sessions
|
|
5064
|
+
|
|
5065
|
+
When the user mentions talking to agents, checking the quest board, or managing the tavern, use these tools.
|
|
5066
|
+
Agents have personalities, moods, quests, and memories — they are autonomous entities, not chatbots.
|
|
5067
|
+
|
|
5068
|
+
## Quest Workflow (Review Gates)
|
|
5069
|
+
When working on a quest plan with review gates (reviewGate: true on sub-quests), you MUST:
|
|
5070
|
+
1. Check review gate status with tavern_get_quest_plan before proceeding past a gated step
|
|
5071
|
+
2. If the next sub-quest has reviewGate: true and reviewStatus is not 'approved', STOP and inform the user
|
|
5072
|
+
3. Record evidence of completion with tavern_update_quest_progress when finishing a sub-quest
|
|
5073
|
+
4. Write a handoff with tavern_write_handoff before ending a session with an active quest plan
|
|
5074
|
+
|
|
5075
|
+
## Session Handoff & Resume
|
|
5076
|
+
When the user runs /quest resume <plan_id>, read the handoff context and continue from where the previous session left off. The handoff contains: summary of prior work, next steps, pending decisions, and blockers.`;
|
|
5077
|
+
}
|
|
5078
|
+
getCommands() {
|
|
5079
|
+
return [{
|
|
5080
|
+
name: "tavern",
|
|
5081
|
+
description: "Show recent Tavern agent activity stream",
|
|
5082
|
+
execute: () => {
|
|
5083
|
+
const activityLog = this.getActivityLog();
|
|
5084
|
+
if (activityLog.length === 0) {
|
|
5085
|
+
console.log("\nTavern Activity: No activity yet.");
|
|
5086
|
+
console.log(" Agents broadcast activity during heartbeats.");
|
|
5087
|
+
console.log(" Try: \"trigger a heartbeat cycle\" to generate activity.\n");
|
|
5088
|
+
return;
|
|
5089
|
+
}
|
|
5090
|
+
const recentEntries = activityLog.slice(-20);
|
|
5091
|
+
console.log(`\nTavern Activity (last ${recentEntries.length} of ${activityLog.length} entries):\n`);
|
|
5092
|
+
for (const entry of recentEntries) {
|
|
5093
|
+
const time = new Date(entry.timestamp).toLocaleTimeString();
|
|
5094
|
+
const icon = ACTION_ICONS[entry.action] ?? "·";
|
|
5095
|
+
const target = entry.targetAgentName ? ` \u2192 ${entry.targetAgentName}` : "";
|
|
5096
|
+
const text = entry.text ? `: ${entry.text.slice(0, 120)}${entry.text.length > 120 ? "..." : ""}` : "";
|
|
5097
|
+
console.log(` ${time} ${icon} ${entry.agentName}${target} [${entry.action}]${text}`);
|
|
5098
|
+
}
|
|
5099
|
+
console.log("");
|
|
5100
|
+
}
|
|
5101
|
+
}, {
|
|
5102
|
+
name: "quest",
|
|
5103
|
+
description: "Quest workflow commands: review gates, resume from handoff",
|
|
5104
|
+
execute: async (args) => {
|
|
5105
|
+
const subCommand = args[0];
|
|
5106
|
+
if (subCommand === "resume") {
|
|
5107
|
+
const planId = args[1];
|
|
5108
|
+
if (!planId) {
|
|
5109
|
+
console.log("\nUsage: /quest resume <plan_id>");
|
|
5110
|
+
console.log(" Loads the quest plan and displays the session handoff for continuity.\n");
|
|
5111
|
+
return;
|
|
5112
|
+
}
|
|
5113
|
+
try {
|
|
5114
|
+
const plan = await this.service.getQuestPlan(planId);
|
|
5115
|
+
console.log(`\nQuest Plan: ${plan.goal}`);
|
|
5116
|
+
console.log(`State: ${plan.state ?? "unknown"}`);
|
|
5117
|
+
if (plan.metrics) {
|
|
5118
|
+
const pct = Math.round(plan.metrics.completionRate * 100);
|
|
5119
|
+
console.log(`Progress: ${plan.metrics.subQuestsCompleted}/${plan.metrics.subQuestsTotal} sub-quests (${pct}%)`);
|
|
5120
|
+
}
|
|
5121
|
+
if (!plan.handoff) {
|
|
5122
|
+
console.log("\n No handoff found — this plan has no saved session context.");
|
|
5123
|
+
console.log(" The AI can still read the plan via tavern_get_quest_plan.\n");
|
|
5124
|
+
return;
|
|
5125
|
+
}
|
|
5126
|
+
const { handoff } = plan;
|
|
5127
|
+
console.log(`\nSession Handoff (${new Date(handoff.updatedAt).toLocaleString()}):`);
|
|
5128
|
+
console.log(`\n Summary: ${handoff.summary}`);
|
|
5129
|
+
if (handoff.nextSteps.length > 0) {
|
|
5130
|
+
console.log("\n Next Steps:");
|
|
5131
|
+
for (const step of handoff.nextSteps) console.log(` \u2022 ${step}`);
|
|
5132
|
+
}
|
|
5133
|
+
if (handoff.pendingDecisions.length > 0) {
|
|
5134
|
+
console.log("\n Pending Decisions:");
|
|
5135
|
+
for (const decision of handoff.pendingDecisions) console.log(` \u2753 ${decision}`);
|
|
5136
|
+
}
|
|
5137
|
+
if (handoff.blockers.length > 0) {
|
|
5138
|
+
console.log("\n Blockers:");
|
|
5139
|
+
for (const blocker of handoff.blockers) console.log(` \u{1F6D1} ${blocker}`);
|
|
5140
|
+
}
|
|
5141
|
+
console.log("");
|
|
5142
|
+
} catch (error) {
|
|
5143
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5144
|
+
console.log(`\nError fetching quest plan: ${message}\n`);
|
|
5145
|
+
}
|
|
5146
|
+
return;
|
|
5147
|
+
}
|
|
5148
|
+
if (subCommand === "review") {
|
|
5149
|
+
const planId = args[1];
|
|
5150
|
+
if (!planId) {
|
|
5151
|
+
console.log("\nUsage: /quest review <plan_id>");
|
|
5152
|
+
console.log(" Fetches the quest plan and shows sub-quests with pending review gates.\n");
|
|
5153
|
+
return;
|
|
5154
|
+
}
|
|
5155
|
+
try {
|
|
5156
|
+
const plan = await this.service.getQuestPlan(planId);
|
|
5157
|
+
const pendingGates = [];
|
|
5158
|
+
for (const quest of plan.quests) for (const sq of quest.subQuests) if (sq.reviewGate) pendingGates.push({
|
|
5159
|
+
questTitle: quest.title,
|
|
5160
|
+
questId: quest.id,
|
|
5161
|
+
subQuestTitle: sq.title,
|
|
5162
|
+
subQuestId: sq.id,
|
|
5163
|
+
status: sq.reviewStatus ?? "pending"
|
|
5164
|
+
});
|
|
5165
|
+
if (pendingGates.length === 0) {
|
|
5166
|
+
console.log(`\nQuest Plan: ${plan.goal}`);
|
|
5167
|
+
console.log(" No review gates configured in this plan.\n");
|
|
5168
|
+
return;
|
|
5169
|
+
}
|
|
5170
|
+
console.log(`\nQuest Plan: ${plan.goal}`);
|
|
5171
|
+
console.log(`State: ${plan.state ?? "unknown"}\n`);
|
|
5172
|
+
console.log("Review Gates:");
|
|
5173
|
+
for (const gate of pendingGates) {
|
|
5174
|
+
const icon = gate.status === "approved" ? "✅" : gate.status === "rejected" ? "❌" : "⏸️";
|
|
5175
|
+
console.log(` ${icon} [${gate.status}] ${gate.questTitle} > ${gate.subQuestTitle}`);
|
|
5176
|
+
console.log(` quest_id: ${gate.questId} sub_quest_id: ${gate.subQuestId}`);
|
|
5177
|
+
}
|
|
5178
|
+
console.log("\n To approve: ask the AI to approve a review gate, or use tavern_update_review_gate tool.\n");
|
|
5179
|
+
} catch (error) {
|
|
5180
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5181
|
+
console.log(`\nError fetching quest plan: ${message}\n`);
|
|
5182
|
+
}
|
|
5183
|
+
return;
|
|
5184
|
+
}
|
|
5185
|
+
console.log("\nUsage:");
|
|
5186
|
+
console.log(" /quest review <plan_id> — Show review gates and their status");
|
|
5187
|
+
console.log(" /quest resume <plan_id> — Load handoff and resume from where you left off");
|
|
5188
|
+
console.log("");
|
|
5245
5189
|
}
|
|
5246
|
-
}
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
5190
|
+
}];
|
|
5191
|
+
}
|
|
5192
|
+
registerWsHandlers(wsManager) {
|
|
5193
|
+
this.activityStream.registerHandlers(wsManager);
|
|
5194
|
+
}
|
|
5195
|
+
dispose() {
|
|
5196
|
+
this.activityStream.dispose();
|
|
5197
|
+
}
|
|
5198
|
+
};
|
|
5199
|
+
//#endregion
|
|
5200
|
+
//#region src/features/bridgePresence/BridgePresence.ts
|
|
5201
|
+
const DEFAULT_PORT = Number(process.env.CC_BRIDGE_PORT ?? 48732);
|
|
5202
|
+
const CONFIG_PATH = join(homedir(), ".b4m", "cc-bridge.json");
|
|
5203
|
+
const ANNOUNCE_TIMEOUT_MS = 2e3;
|
|
5204
|
+
async function readBridgeConfig() {
|
|
5205
|
+
try {
|
|
5206
|
+
const raw = await promises.readFile(CONFIG_PATH, "utf8");
|
|
5207
|
+
const parsed = JSON.parse(raw);
|
|
5208
|
+
if (typeof parsed.hookSecret !== "string" || !parsed.hookSecret) return null;
|
|
5209
|
+
return {
|
|
5210
|
+
port: typeof parsed.port === "number" ? parsed.port : DEFAULT_PORT,
|
|
5211
|
+
hookSecret: parsed.hookSecret
|
|
5212
|
+
};
|
|
5213
|
+
} catch {
|
|
5214
|
+
return null;
|
|
5215
|
+
}
|
|
5255
5216
|
}
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
5264
|
-
|
|
5265
|
-
|
|
5266
|
-
|
|
5267
|
-
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5217
|
+
var BridgePresence = class {
|
|
5218
|
+
constructor() {
|
|
5219
|
+
this.config = null;
|
|
5220
|
+
this.instanceId = null;
|
|
5221
|
+
this.ws = null;
|
|
5222
|
+
this.callbacks = {};
|
|
5223
|
+
this.started = false;
|
|
5224
|
+
this.stopped = false;
|
|
5225
|
+
this.reconnectTimer = null;
|
|
5226
|
+
this.reconnectAttempts = 0;
|
|
5227
|
+
this.announceRetryTimer = null;
|
|
5228
|
+
this.announceAttempts = 0;
|
|
5229
|
+
this.startOpts = null;
|
|
5230
|
+
this.pendingWorkspaceName = null;
|
|
5231
|
+
this.pendingCapabilities = null;
|
|
5232
|
+
this.pendingSource = null;
|
|
5233
|
+
this.emitQueue = Promise.resolve();
|
|
5234
|
+
}
|
|
5235
|
+
setCallbacks(cbs) {
|
|
5236
|
+
this.callbacks = cbs;
|
|
5237
|
+
}
|
|
5238
|
+
/**
|
|
5239
|
+
* Probe the local bridge and, if present, announce this CLI session.
|
|
5240
|
+
* Returns true iff the announce succeeded (tavern presence is active).
|
|
5241
|
+
* Safe to call multiple times — second call no-ops.
|
|
5242
|
+
*
|
|
5243
|
+
* If announce fails (bridge absent or not yet up), a background retry
|
|
5244
|
+
* loop keeps trying with bounded backoff so the sprite appears when the
|
|
5245
|
+
* bridge comes online later in the CLI's lifetime.
|
|
5246
|
+
*/
|
|
5247
|
+
async start(opts) {
|
|
5248
|
+
if (this.started) return this.instanceId !== null;
|
|
5249
|
+
this.stopped = false;
|
|
5250
|
+
this.started = true;
|
|
5251
|
+
const config = await readBridgeConfig();
|
|
5252
|
+
if (!config) {
|
|
5253
|
+
logger.debug("[tavern] cc-bridge not configured; CLI runs without tavern presence");
|
|
5254
|
+
return false;
|
|
5292
5255
|
}
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
|
|
5309
|
-
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
|
|
5256
|
+
this.config = config;
|
|
5257
|
+
this.startOpts = opts;
|
|
5258
|
+
this.pendingWorkspaceName = opts.workspaceName ?? (basename(opts.workspacePath) || "workspace");
|
|
5259
|
+
this.pendingCapabilities = opts.capabilities ?? ["interactive"];
|
|
5260
|
+
this.pendingSource = opts.source ?? "b4m-cli";
|
|
5261
|
+
return this.attemptAnnounce();
|
|
5262
|
+
}
|
|
5263
|
+
/** One announce attempt. Schedules a retry on failure; wires up the
|
|
5264
|
+
* command WS + initial status on success. Idempotent: re-entering after
|
|
5265
|
+
* a successful announce short-circuits at the instanceId guard. */
|
|
5266
|
+
async attemptAnnounce() {
|
|
5267
|
+
if (this.stopped || !this.config || !this.startOpts) return false;
|
|
5268
|
+
if (this.instanceId) return true;
|
|
5269
|
+
const instanceId = v4();
|
|
5270
|
+
const workspaceName = this.pendingWorkspaceName;
|
|
5271
|
+
const capabilities = this.pendingCapabilities;
|
|
5272
|
+
const source = this.pendingSource;
|
|
5273
|
+
if (!await this.announce({
|
|
5274
|
+
instanceId,
|
|
5275
|
+
source,
|
|
5276
|
+
workspaceName,
|
|
5277
|
+
workspacePath: this.startOpts.workspacePath,
|
|
5278
|
+
capabilities
|
|
5279
|
+
})) {
|
|
5280
|
+
this.scheduleAnnounceRetry();
|
|
5281
|
+
return false;
|
|
5313
5282
|
}
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
|
|
5319
|
-
|
|
5320
|
-
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
|
|
5331
|
-
|
|
5332
|
-
|
|
5333
|
-
|
|
5334
|
-
|
|
5335
|
-
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
5340
|
-
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
|
|
5344
|
-
|
|
5345
|
-
|
|
5346
|
-
|
|
5347
|
-
|
|
5348
|
-
|
|
5349
|
-
|
|
5350
|
-
|
|
5351
|
-
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
5361
|
-
return JSON.stringify(result);
|
|
5283
|
+
this.instanceId = instanceId;
|
|
5284
|
+
this.announceAttempts = 0;
|
|
5285
|
+
logger.info(`[tavern] announced ${workspaceName} to cc-bridge on 127.0.0.1:${this.config.port ?? DEFAULT_PORT}`);
|
|
5286
|
+
this.connectCommandWs();
|
|
5287
|
+
this.emitEvent({
|
|
5288
|
+
type: "status",
|
|
5289
|
+
status: "idle"
|
|
5290
|
+
});
|
|
5291
|
+
return true;
|
|
5292
|
+
}
|
|
5293
|
+
scheduleAnnounceRetry() {
|
|
5294
|
+
if (this.stopped || this.announceRetryTimer) return;
|
|
5295
|
+
this.announceAttempts += 1;
|
|
5296
|
+
const delay = Math.min(1e3 * 2 ** (this.announceAttempts - 1), 3e4);
|
|
5297
|
+
this.announceRetryTimer = setTimeout(() => {
|
|
5298
|
+
this.announceRetryTimer = null;
|
|
5299
|
+
this.attemptAnnounce();
|
|
5300
|
+
}, delay);
|
|
5301
|
+
}
|
|
5302
|
+
/** Emit an event for this session. No-op if the bridge isn't up. Events
|
|
5303
|
+
* leave in strict order — see `emitQueue` comment. */
|
|
5304
|
+
async emitEvent(event) {
|
|
5305
|
+
if (!this.config || !this.instanceId) return;
|
|
5306
|
+
const task = () => this.post("/event", {
|
|
5307
|
+
instanceId: this.instanceId,
|
|
5308
|
+
event
|
|
5309
|
+
}).catch((err) => logger.info(`[tavern] emitEvent ${event.type} failed: ${err.message}`));
|
|
5310
|
+
this.emitQueue = this.emitQueue.then(task, task);
|
|
5311
|
+
return this.emitQueue;
|
|
5312
|
+
}
|
|
5313
|
+
/**
|
|
5314
|
+
* Tear down the tavern presence cleanly. Halts the announce-retry and
|
|
5315
|
+
* command-WS reconnect loops, closes the socket, and best-effort signals
|
|
5316
|
+
* disconnect to the bridge.
|
|
5317
|
+
*
|
|
5318
|
+
* After this resolves the instance is fully reset, so a later `start()`
|
|
5319
|
+
* re-announces — the same singleton can be toggled off (Tavern feature
|
|
5320
|
+
* disabled at runtime) and back on without restarting the CLI. The
|
|
5321
|
+
* `stopped` latch is left true here purely so any straggler retry callback
|
|
5322
|
+
* already queued short-circuits; `start()` clears it.
|
|
5323
|
+
*/
|
|
5324
|
+
async stop(reason = "cli_exit") {
|
|
5325
|
+
if (this.stopped || !this.started) return;
|
|
5326
|
+
this.stopped = true;
|
|
5327
|
+
if (this.reconnectTimer) {
|
|
5328
|
+
clearTimeout(this.reconnectTimer);
|
|
5329
|
+
this.reconnectTimer = null;
|
|
5362
5330
|
}
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
5366
|
-
return {
|
|
5367
|
-
toolSchema: {
|
|
5368
|
-
name: "tavern_update_quest_progress",
|
|
5369
|
-
description: "Update a sub-quest's progress. Set status to track completion, add evidence of what was accomplished, and optionally record time spent. Setting status to \"in_progress\" auto-resumes a paused plan.",
|
|
5370
|
-
parameters: {
|
|
5371
|
-
type: "object",
|
|
5372
|
-
properties: {
|
|
5373
|
-
plan_id: {
|
|
5374
|
-
type: "string",
|
|
5375
|
-
description: "The MongoDB ObjectId of the quest master plan"
|
|
5376
|
-
},
|
|
5377
|
-
quest_id: {
|
|
5378
|
-
type: "string",
|
|
5379
|
-
description: "The ID of the parent quest"
|
|
5380
|
-
},
|
|
5381
|
-
sub_quest_id: {
|
|
5382
|
-
type: "string",
|
|
5383
|
-
description: "The ID of the sub-quest to update"
|
|
5384
|
-
},
|
|
5385
|
-
status: {
|
|
5386
|
-
type: "string",
|
|
5387
|
-
description: "New status for the sub-quest",
|
|
5388
|
-
enum: [
|
|
5389
|
-
"not_started",
|
|
5390
|
-
"in_progress",
|
|
5391
|
-
"completed",
|
|
5392
|
-
"skipped",
|
|
5393
|
-
"deleted"
|
|
5394
|
-
]
|
|
5395
|
-
},
|
|
5396
|
-
evidence: {
|
|
5397
|
-
type: "string",
|
|
5398
|
-
description: "Evidence of completion — links to artifacts, descriptions of output, or references to results"
|
|
5399
|
-
},
|
|
5400
|
-
time_spent: {
|
|
5401
|
-
type: "number",
|
|
5402
|
-
description: "Time spent on this sub-quest in milliseconds"
|
|
5403
|
-
}
|
|
5404
|
-
},
|
|
5405
|
-
required: [
|
|
5406
|
-
"plan_id",
|
|
5407
|
-
"quest_id",
|
|
5408
|
-
"sub_quest_id"
|
|
5409
|
-
]
|
|
5410
|
-
}
|
|
5411
|
-
},
|
|
5412
|
-
toolFn: async (params) => {
|
|
5413
|
-
const { plan_id, quest_id, sub_quest_id, status, evidence, time_spent } = UpdateQuestProgressParamsSchema.parse(params);
|
|
5414
|
-
const updates = {};
|
|
5415
|
-
if (status !== void 0) updates.status = status;
|
|
5416
|
-
if (evidence !== void 0) updates.evidence = evidence;
|
|
5417
|
-
if (time_spent !== void 0) updates.timeSpent = time_spent;
|
|
5418
|
-
const result = await service.updateSubQuestProgress(plan_id, quest_id, sub_quest_id, updates);
|
|
5419
|
-
return JSON.stringify(result);
|
|
5331
|
+
if (this.announceRetryTimer) {
|
|
5332
|
+
clearTimeout(this.announceRetryTimer);
|
|
5333
|
+
this.announceRetryTimer = null;
|
|
5420
5334
|
}
|
|
5421
|
-
|
|
5422
|
-
|
|
5423
|
-
|
|
5424
|
-
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
|
|
5428
|
-
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
|
|
5443
|
-
|
|
5444
|
-
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
5448
|
-
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5335
|
+
if (this.ws) {
|
|
5336
|
+
try {
|
|
5337
|
+
this.ws.close();
|
|
5338
|
+
} catch {}
|
|
5339
|
+
this.ws = null;
|
|
5340
|
+
}
|
|
5341
|
+
if (this.config && this.instanceId) await this.post("/disconnect", {
|
|
5342
|
+
instanceId: this.instanceId,
|
|
5343
|
+
reason
|
|
5344
|
+
}).catch(() => {});
|
|
5345
|
+
this.started = false;
|
|
5346
|
+
this.instanceId = null;
|
|
5347
|
+
this.config = null;
|
|
5348
|
+
this.startOpts = null;
|
|
5349
|
+
this.pendingWorkspaceName = null;
|
|
5350
|
+
this.pendingCapabilities = null;
|
|
5351
|
+
this.pendingSource = null;
|
|
5352
|
+
this.announceAttempts = 0;
|
|
5353
|
+
this.reconnectAttempts = 0;
|
|
5354
|
+
this.emitQueue = Promise.resolve();
|
|
5355
|
+
}
|
|
5356
|
+
async announce(body) {
|
|
5357
|
+
try {
|
|
5358
|
+
await this.post("/announce", body);
|
|
5359
|
+
return true;
|
|
5360
|
+
} catch (err) {
|
|
5361
|
+
logger.info(`[tavern] bridge announce failed: ${err.message}`);
|
|
5362
|
+
return false;
|
|
5363
|
+
}
|
|
5364
|
+
}
|
|
5365
|
+
async post(path, body) {
|
|
5366
|
+
if (!this.config) throw new Error("bridge config not loaded");
|
|
5367
|
+
const url = `http://127.0.0.1:${this.config.port ?? DEFAULT_PORT}${path}?secret=${encodeURIComponent(this.config.hookSecret)}`;
|
|
5368
|
+
const res = await fetch(url, {
|
|
5369
|
+
method: "POST",
|
|
5370
|
+
headers: { "Content-Type": "application/json" },
|
|
5371
|
+
body: JSON.stringify(body),
|
|
5372
|
+
signal: AbortSignal.timeout(ANNOUNCE_TIMEOUT_MS)
|
|
5373
|
+
});
|
|
5374
|
+
if (!res.ok) throw new Error(`bridge ${path} -> ${res.status}`);
|
|
5375
|
+
}
|
|
5376
|
+
connectCommandWs() {
|
|
5377
|
+
if (this.stopped || !this.config || !this.instanceId) return;
|
|
5378
|
+
const url = `ws://127.0.0.1:${this.config.port ?? DEFAULT_PORT}/commands?instanceId=${encodeURIComponent(this.instanceId)}&secret=${encodeURIComponent(this.config.hookSecret)}`;
|
|
5379
|
+
let ws;
|
|
5380
|
+
try {
|
|
5381
|
+
ws = new WsWebSocket(url);
|
|
5382
|
+
} catch (err) {
|
|
5383
|
+
logger.debug(`[tavern] command WS construct failed: ${err.message}`);
|
|
5384
|
+
this.scheduleReconnect();
|
|
5385
|
+
return;
|
|
5386
|
+
}
|
|
5387
|
+
this.ws = ws;
|
|
5388
|
+
ws.on("open", () => {
|
|
5389
|
+
this.reconnectAttempts = 0;
|
|
5390
|
+
logger.debug("[tavern] command WS open");
|
|
5391
|
+
});
|
|
5392
|
+
ws.on("message", (raw) => {
|
|
5393
|
+
let frame = null;
|
|
5394
|
+
try {
|
|
5395
|
+
frame = JSON.parse(raw.toString());
|
|
5396
|
+
} catch {
|
|
5397
|
+
logger.debug("[tavern] malformed command frame; ignored");
|
|
5398
|
+
return;
|
|
5462
5399
|
}
|
|
5463
|
-
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
5469
|
-
|
|
5470
|
-
|
|
5471
|
-
|
|
5472
|
-
|
|
5400
|
+
if (!frame?.command) return;
|
|
5401
|
+
this.dispatchCommand(frame.command).catch((err) => logger.warn(`[tavern] command dispatch threw: ${err.message}`));
|
|
5402
|
+
});
|
|
5403
|
+
ws.on("close", () => {
|
|
5404
|
+
this.ws = null;
|
|
5405
|
+
if (this.stopped) return;
|
|
5406
|
+
logger.debug("[tavern] command WS closed; reconnecting");
|
|
5407
|
+
this.scheduleReconnect();
|
|
5408
|
+
});
|
|
5409
|
+
ws.on("error", (err) => {
|
|
5410
|
+
logger.debug(`[tavern] command WS error: ${err.message}`);
|
|
5411
|
+
});
|
|
5412
|
+
}
|
|
5413
|
+
scheduleReconnect() {
|
|
5414
|
+
if (this.stopped || this.reconnectTimer) return;
|
|
5415
|
+
this.reconnectAttempts += 1;
|
|
5416
|
+
const delay = Math.min(500 * 2 ** (this.reconnectAttempts - 1), 1e4);
|
|
5417
|
+
this.reconnectTimer = setTimeout(() => {
|
|
5418
|
+
this.reconnectTimer = null;
|
|
5419
|
+
this.connectCommandWs();
|
|
5420
|
+
}, delay);
|
|
5421
|
+
}
|
|
5422
|
+
async dispatchCommand(command) {
|
|
5423
|
+
switch (command.type) {
|
|
5424
|
+
case "send_prompt":
|
|
5425
|
+
if (this.callbacks.onSendPrompt) await this.callbacks.onSendPrompt(command.text);
|
|
5426
|
+
break;
|
|
5427
|
+
case "resolve_permission":
|
|
5428
|
+
if (this.callbacks.onResolvePermission) await this.callbacks.onResolvePermission(command.requestId, command.allow);
|
|
5429
|
+
break;
|
|
5430
|
+
case "abort":
|
|
5431
|
+
if (this.callbacks.onAbort) await this.callbacks.onAbort();
|
|
5432
|
+
break;
|
|
5473
5433
|
}
|
|
5474
|
-
}
|
|
5475
|
-
}
|
|
5434
|
+
}
|
|
5435
|
+
};
|
|
5436
|
+
/** Process-wide singleton — the CLI only ever has one tavern presence per run. */
|
|
5437
|
+
const bridgePresence = new BridgePresence();
|
|
5476
5438
|
//#endregion
|
|
5477
|
-
//#region src/
|
|
5478
|
-
/**
|
|
5479
|
-
|
|
5480
|
-
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
|
|
5492
|
-
|
|
5493
|
-
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
|
|
5439
|
+
//#region src/llm/MultiLlmBackend.ts
|
|
5440
|
+
/**
|
|
5441
|
+
* Routes completions between B4M server and a local Ollama instance
|
|
5442
|
+
* based on the selected model's backend type.
|
|
5443
|
+
*/
|
|
5444
|
+
var MultiLlmBackend = class {
|
|
5445
|
+
constructor(serverBackend, ollamaBackend, serverModels, ollamaModels, initialModel) {
|
|
5446
|
+
this.serverBackend = serverBackend;
|
|
5447
|
+
this.ollamaBackend = ollamaBackend;
|
|
5448
|
+
this.serverModels = serverModels;
|
|
5449
|
+
this.ollamaModels = ollamaModels;
|
|
5450
|
+
this.currentModel = initialModel;
|
|
5451
|
+
this.ollamaModelIds = new Set(ollamaModels.map((m) => m.id));
|
|
5452
|
+
}
|
|
5453
|
+
get activeBackend() {
|
|
5454
|
+
return this.ollamaModelIds.has(this.currentModel) ? this.ollamaBackend : this.serverBackend;
|
|
5455
|
+
}
|
|
5456
|
+
async complete(model, messages, options, callback) {
|
|
5457
|
+
return (this.ollamaModelIds.has(model) ? this.ollamaBackend : this.serverBackend).complete(model, messages, options, callback);
|
|
5458
|
+
}
|
|
5459
|
+
pushToolMessages(messages, tool, result, thinkingBlocks) {
|
|
5460
|
+
this.activeBackend.pushToolMessages(messages, tool, result, thinkingBlocks);
|
|
5461
|
+
}
|
|
5462
|
+
async getModelInfo() {
|
|
5463
|
+
return [...this.serverModels, ...this.ollamaModels];
|
|
5464
|
+
}
|
|
5499
5465
|
};
|
|
5466
|
+
//#endregion
|
|
5467
|
+
//#region src/utils/jupyterClient.ts
|
|
5500
5468
|
/**
|
|
5501
|
-
*
|
|
5469
|
+
* Jupyter Server REST API Client
|
|
5502
5470
|
*
|
|
5503
|
-
*
|
|
5504
|
-
* commands
|
|
5471
|
+
* Provides methods for interacting with a local Jupyter Server instance.
|
|
5472
|
+
* Used by Keep commands to execute notebooks on the user's local machine.
|
|
5473
|
+
*
|
|
5474
|
+
* @see https://jupyter-server.readthedocs.io/en/latest/developers/rest-api.html
|
|
5475
|
+
* @see https://jupyter-client.readthedocs.io/en/latest/messaging.html
|
|
5505
5476
|
*/
|
|
5506
|
-
var
|
|
5507
|
-
constructor(
|
|
5508
|
-
|
|
5509
|
-
this.
|
|
5510
|
-
this.
|
|
5511
|
-
this.
|
|
5512
|
-
this.getActivityLog = getActivityLog;
|
|
5477
|
+
var JupyterClientError = class extends Error {
|
|
5478
|
+
constructor(message, statusCode, response) {
|
|
5479
|
+
super(message);
|
|
5480
|
+
this.statusCode = statusCode;
|
|
5481
|
+
this.response = response;
|
|
5482
|
+
this.name = "JupyterClientError";
|
|
5513
5483
|
}
|
|
5514
|
-
|
|
5515
|
-
|
|
5484
|
+
};
|
|
5485
|
+
/**
|
|
5486
|
+
* Validate Jupyter server URL
|
|
5487
|
+
*/
|
|
5488
|
+
function validateServerUrl(url) {
|
|
5489
|
+
if (!url || typeof url !== "string") throw new JupyterClientError("Server URL is required");
|
|
5490
|
+
let parsed;
|
|
5491
|
+
try {
|
|
5492
|
+
parsed = new URL(url);
|
|
5493
|
+
} catch {
|
|
5494
|
+
throw new JupyterClientError(`Invalid server URL: ${url}`);
|
|
5516
5495
|
}
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
5527
|
-
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
- **tavern_status**: Quick overview of agents, quests, and gates — good for situational awareness
|
|
5539
|
-
- **tavern_get_quest_plan**: Fetch a quest master plan with review gate status and handoff state
|
|
5540
|
-
- **tavern_update_review_gate**: Approve or reject a review gate on a sub-quest
|
|
5541
|
-
- **tavern_update_quest_progress**: Update sub-quest status and record evidence of completion
|
|
5542
|
-
- **tavern_write_handoff**: Write session handoff for continuity across sessions
|
|
5543
|
-
|
|
5544
|
-
When the user mentions talking to agents, checking the quest board, or managing the tavern, use these tools.
|
|
5545
|
-
Agents have personalities, moods, quests, and memories — they are autonomous entities, not chatbots.
|
|
5546
|
-
|
|
5547
|
-
## Quest Workflow (Review Gates)
|
|
5548
|
-
When working on a quest plan with review gates (reviewGate: true on sub-quests), you MUST:
|
|
5549
|
-
1. Check review gate status with tavern_get_quest_plan before proceeding past a gated step
|
|
5550
|
-
2. If the next sub-quest has reviewGate: true and reviewStatus is not 'approved', STOP and inform the user
|
|
5551
|
-
3. Record evidence of completion with tavern_update_quest_progress when finishing a sub-quest
|
|
5552
|
-
4. Write a handoff with tavern_write_handoff before ending a session with an active quest plan
|
|
5553
|
-
|
|
5554
|
-
## Session Handoff & Resume
|
|
5555
|
-
When the user runs /quest resume <plan_id>, read the handoff context and continue from where the previous session left off. The handoff contains: summary of prior work, next steps, pending decisions, and blockers.`;
|
|
5496
|
+
if (!["http:", "https:"].includes(parsed.protocol)) throw new JupyterClientError(`Invalid protocol: ${parsed.protocol}. Only http and https are allowed`);
|
|
5497
|
+
}
|
|
5498
|
+
/**
|
|
5499
|
+
* Validate notebook path to prevent path traversal (throws on invalid)
|
|
5500
|
+
*/
|
|
5501
|
+
function validateNotebookPath(path) {
|
|
5502
|
+
const result = validateNotebookPath$1(path);
|
|
5503
|
+
if (!result.valid) throw new JupyterClientError(result.error || "Invalid notebook path");
|
|
5504
|
+
}
|
|
5505
|
+
/**
|
|
5506
|
+
* Validate kernel name against whitelist (throws on invalid)
|
|
5507
|
+
*/
|
|
5508
|
+
function validateKernelName(name) {
|
|
5509
|
+
const result = validateJupyterKernelName(name);
|
|
5510
|
+
if (!result.valid) throw new JupyterClientError(result.error || "Invalid kernel name");
|
|
5511
|
+
}
|
|
5512
|
+
var JupyterClient = class {
|
|
5513
|
+
constructor(config) {
|
|
5514
|
+
validateServerUrl(config.serverUrl);
|
|
5515
|
+
this.serverUrl = config.serverUrl.replace(/\/$/, "");
|
|
5516
|
+
this.token = config.token;
|
|
5556
5517
|
}
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
console.log(` ${time} ${icon} ${entry.agentName}${target} [${entry.action}]${text}`);
|
|
5577
|
-
}
|
|
5578
|
-
console.log("");
|
|
5579
|
-
}
|
|
5580
|
-
}, {
|
|
5581
|
-
name: "quest",
|
|
5582
|
-
description: "Quest workflow commands: review gates, resume from handoff",
|
|
5583
|
-
execute: async (args) => {
|
|
5584
|
-
const subCommand = args[0];
|
|
5585
|
-
if (subCommand === "resume") {
|
|
5586
|
-
const planId = args[1];
|
|
5587
|
-
if (!planId) {
|
|
5588
|
-
console.log("\nUsage: /quest resume <plan_id>");
|
|
5589
|
-
console.log(" Loads the quest plan and displays the session handoff for continuity.\n");
|
|
5590
|
-
return;
|
|
5591
|
-
}
|
|
5592
|
-
try {
|
|
5593
|
-
const plan = await this.service.getQuestPlan(planId);
|
|
5594
|
-
console.log(`\nQuest Plan: ${plan.goal}`);
|
|
5595
|
-
console.log(`State: ${plan.state ?? "unknown"}`);
|
|
5596
|
-
if (plan.metrics) {
|
|
5597
|
-
const pct = Math.round(plan.metrics.completionRate * 100);
|
|
5598
|
-
console.log(`Progress: ${plan.metrics.subQuestsCompleted}/${plan.metrics.subQuestsTotal} sub-quests (${pct}%)`);
|
|
5599
|
-
}
|
|
5600
|
-
if (!plan.handoff) {
|
|
5601
|
-
console.log("\n No handoff found — this plan has no saved session context.");
|
|
5602
|
-
console.log(" The AI can still read the plan via tavern_get_quest_plan.\n");
|
|
5603
|
-
return;
|
|
5604
|
-
}
|
|
5605
|
-
const { handoff } = plan;
|
|
5606
|
-
console.log(`\nSession Handoff (${new Date(handoff.updatedAt).toLocaleString()}):`);
|
|
5607
|
-
console.log(`\n Summary: ${handoff.summary}`);
|
|
5608
|
-
if (handoff.nextSteps.length > 0) {
|
|
5609
|
-
console.log("\n Next Steps:");
|
|
5610
|
-
for (const step of handoff.nextSteps) console.log(` \u2022 ${step}`);
|
|
5611
|
-
}
|
|
5612
|
-
if (handoff.pendingDecisions.length > 0) {
|
|
5613
|
-
console.log("\n Pending Decisions:");
|
|
5614
|
-
for (const decision of handoff.pendingDecisions) console.log(` \u2753 ${decision}`);
|
|
5615
|
-
}
|
|
5616
|
-
if (handoff.blockers.length > 0) {
|
|
5617
|
-
console.log("\n Blockers:");
|
|
5618
|
-
for (const blocker of handoff.blockers) console.log(` \u{1F6D1} ${blocker}`);
|
|
5619
|
-
}
|
|
5620
|
-
console.log("");
|
|
5621
|
-
} catch (error) {
|
|
5622
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
5623
|
-
console.log(`\nError fetching quest plan: ${message}\n`);
|
|
5624
|
-
}
|
|
5625
|
-
return;
|
|
5626
|
-
}
|
|
5627
|
-
if (subCommand === "review") {
|
|
5628
|
-
const planId = args[1];
|
|
5629
|
-
if (!planId) {
|
|
5630
|
-
console.log("\nUsage: /quest review <plan_id>");
|
|
5631
|
-
console.log(" Fetches the quest plan and shows sub-quests with pending review gates.\n");
|
|
5632
|
-
return;
|
|
5633
|
-
}
|
|
5634
|
-
try {
|
|
5635
|
-
const plan = await this.service.getQuestPlan(planId);
|
|
5636
|
-
const pendingGates = [];
|
|
5637
|
-
for (const quest of plan.quests) for (const sq of quest.subQuests) if (sq.reviewGate) pendingGates.push({
|
|
5638
|
-
questTitle: quest.title,
|
|
5639
|
-
questId: quest.id,
|
|
5640
|
-
subQuestTitle: sq.title,
|
|
5641
|
-
subQuestId: sq.id,
|
|
5642
|
-
status: sq.reviewStatus ?? "pending"
|
|
5643
|
-
});
|
|
5644
|
-
if (pendingGates.length === 0) {
|
|
5645
|
-
console.log(`\nQuest Plan: ${plan.goal}`);
|
|
5646
|
-
console.log(" No review gates configured in this plan.\n");
|
|
5647
|
-
return;
|
|
5648
|
-
}
|
|
5649
|
-
console.log(`\nQuest Plan: ${plan.goal}`);
|
|
5650
|
-
console.log(`State: ${plan.state ?? "unknown"}\n`);
|
|
5651
|
-
console.log("Review Gates:");
|
|
5652
|
-
for (const gate of pendingGates) {
|
|
5653
|
-
const icon = gate.status === "approved" ? "✅" : gate.status === "rejected" ? "❌" : "⏸️";
|
|
5654
|
-
console.log(` ${icon} [${gate.status}] ${gate.questTitle} > ${gate.subQuestTitle}`);
|
|
5655
|
-
console.log(` quest_id: ${gate.questId} sub_quest_id: ${gate.subQuestId}`);
|
|
5656
|
-
}
|
|
5657
|
-
console.log("\n To approve: ask the AI to approve a review gate, or use tavern_update_review_gate tool.\n");
|
|
5658
|
-
} catch (error) {
|
|
5659
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
5660
|
-
console.log(`\nError fetching quest plan: ${message}\n`);
|
|
5661
|
-
}
|
|
5662
|
-
return;
|
|
5663
|
-
}
|
|
5664
|
-
console.log("\nUsage:");
|
|
5665
|
-
console.log(" /quest review <plan_id> — Show review gates and their status");
|
|
5666
|
-
console.log(" /quest resume <plan_id> — Load handoff and resume from where you left off");
|
|
5667
|
-
console.log("");
|
|
5518
|
+
getHeaders() {
|
|
5519
|
+
const headers = { "Content-Type": "application/json" };
|
|
5520
|
+
if (this.token) headers["Authorization"] = `token ${this.token}`;
|
|
5521
|
+
return headers;
|
|
5522
|
+
}
|
|
5523
|
+
async request(method, path, body) {
|
|
5524
|
+
const url = `${this.serverUrl}${path}`;
|
|
5525
|
+
const options = {
|
|
5526
|
+
method,
|
|
5527
|
+
headers: this.getHeaders()
|
|
5528
|
+
};
|
|
5529
|
+
if (body) options.body = JSON.stringify(body);
|
|
5530
|
+
const response = await fetch(url, options);
|
|
5531
|
+
if (!response.ok) {
|
|
5532
|
+
let errorBody;
|
|
5533
|
+
try {
|
|
5534
|
+
errorBody = await response.json();
|
|
5535
|
+
} catch {
|
|
5536
|
+
errorBody = await response.text();
|
|
5668
5537
|
}
|
|
5669
|
-
|
|
5538
|
+
throw new JupyterClientError(`Jupyter API error: ${response.status} ${response.statusText}`, response.status, errorBody);
|
|
5539
|
+
}
|
|
5540
|
+
if (response.status === 204) return {};
|
|
5541
|
+
return response.json();
|
|
5670
5542
|
}
|
|
5671
|
-
|
|
5672
|
-
|
|
5543
|
+
/**
|
|
5544
|
+
* Check if the Jupyter server is running and accessible
|
|
5545
|
+
*/
|
|
5546
|
+
async checkStatus() {
|
|
5547
|
+
return this.request("GET", "/api/status");
|
|
5548
|
+
}
|
|
5549
|
+
/**
|
|
5550
|
+
* Get available kernel specifications
|
|
5551
|
+
*/
|
|
5552
|
+
async getKernelSpecs() {
|
|
5553
|
+
return this.request("GET", "/api/kernelspecs");
|
|
5673
5554
|
}
|
|
5674
|
-
|
|
5675
|
-
|
|
5555
|
+
/**
|
|
5556
|
+
* List all active sessions
|
|
5557
|
+
*/
|
|
5558
|
+
async listSessions() {
|
|
5559
|
+
return this.request("GET", "/api/sessions");
|
|
5676
5560
|
}
|
|
5677
|
-
|
|
5678
|
-
|
|
5679
|
-
|
|
5680
|
-
|
|
5681
|
-
|
|
5682
|
-
const
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
hookSecret: parsed.hookSecret
|
|
5691
|
-
};
|
|
5692
|
-
} catch {
|
|
5693
|
-
return null;
|
|
5561
|
+
/**
|
|
5562
|
+
* Start a new kernel session for a notebook
|
|
5563
|
+
*/
|
|
5564
|
+
async startSession(notebookPath, kernelName) {
|
|
5565
|
+
validateNotebookPath(notebookPath);
|
|
5566
|
+
const kernel = kernelName || "python3";
|
|
5567
|
+
validateKernelName(kernel);
|
|
5568
|
+
return this.request("POST", "/api/sessions", {
|
|
5569
|
+
path: notebookPath,
|
|
5570
|
+
type: "notebook",
|
|
5571
|
+
name: notebookPath.split("/").pop() || "Untitled",
|
|
5572
|
+
kernel: { name: kernel }
|
|
5573
|
+
});
|
|
5694
5574
|
}
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
5699
|
-
this.
|
|
5700
|
-
this.ws = null;
|
|
5701
|
-
this.callbacks = {};
|
|
5702
|
-
this.started = false;
|
|
5703
|
-
this.stopped = false;
|
|
5704
|
-
this.reconnectTimer = null;
|
|
5705
|
-
this.reconnectAttempts = 0;
|
|
5706
|
-
this.announceRetryTimer = null;
|
|
5707
|
-
this.announceAttempts = 0;
|
|
5708
|
-
this.startOpts = null;
|
|
5709
|
-
this.pendingWorkspaceName = null;
|
|
5710
|
-
this.pendingCapabilities = null;
|
|
5711
|
-
this.pendingSource = null;
|
|
5712
|
-
this.emitQueue = Promise.resolve();
|
|
5575
|
+
/**
|
|
5576
|
+
* Get a session by ID
|
|
5577
|
+
*/
|
|
5578
|
+
async getSession(sessionId) {
|
|
5579
|
+
return this.request("GET", `/api/sessions/${sessionId}`);
|
|
5713
5580
|
}
|
|
5714
|
-
|
|
5715
|
-
|
|
5581
|
+
/**
|
|
5582
|
+
* Stop a kernel session
|
|
5583
|
+
*/
|
|
5584
|
+
async stopSession(sessionId) {
|
|
5585
|
+
await this.request("DELETE", `/api/sessions/${sessionId}`);
|
|
5716
5586
|
}
|
|
5717
5587
|
/**
|
|
5718
|
-
*
|
|
5719
|
-
* Returns true iff the announce succeeded (tavern presence is active).
|
|
5720
|
-
* Safe to call multiple times — second call no-ops.
|
|
5721
|
-
*
|
|
5722
|
-
* If announce fails (bridge absent or not yet up), a background retry
|
|
5723
|
-
* loop keeps trying with bounded backoff so the sprite appears when the
|
|
5724
|
-
* bridge comes online later in the CLI's lifetime.
|
|
5588
|
+
* Get WebSocket URL for kernel channels
|
|
5725
5589
|
*/
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
this.
|
|
5730
|
-
|
|
5731
|
-
if (!config) {
|
|
5732
|
-
logger.debug("[tavern] cc-bridge not configured; CLI runs without tavern presence");
|
|
5733
|
-
return false;
|
|
5734
|
-
}
|
|
5735
|
-
this.config = config;
|
|
5736
|
-
this.startOpts = opts;
|
|
5737
|
-
this.pendingWorkspaceName = opts.workspaceName ?? (basename(opts.workspacePath) || "workspace");
|
|
5738
|
-
this.pendingCapabilities = opts.capabilities ?? ["interactive"];
|
|
5739
|
-
this.pendingSource = opts.source ?? "b4m-cli";
|
|
5740
|
-
return this.attemptAnnounce();
|
|
5590
|
+
getKernelWebSocketUrl(kernelId) {
|
|
5591
|
+
const httpUrl = new URL(this.serverUrl);
|
|
5592
|
+
const wsUrl = `${httpUrl.protocol === "https:" ? "wss:" : "ws:"}//${httpUrl.host}/api/kernels/${kernelId}/channels`;
|
|
5593
|
+
if (this.token) return `${wsUrl}?token=${this.token}`;
|
|
5594
|
+
return wsUrl;
|
|
5741
5595
|
}
|
|
5742
|
-
/**
|
|
5743
|
-
*
|
|
5744
|
-
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
if (this.instanceId) return true;
|
|
5748
|
-
const instanceId = v4();
|
|
5749
|
-
const workspaceName = this.pendingWorkspaceName;
|
|
5750
|
-
const capabilities = this.pendingCapabilities;
|
|
5751
|
-
const source = this.pendingSource;
|
|
5752
|
-
if (!await this.announce({
|
|
5753
|
-
instanceId,
|
|
5754
|
-
source,
|
|
5755
|
-
workspaceName,
|
|
5756
|
-
workspacePath: this.startOpts.workspacePath,
|
|
5757
|
-
capabilities
|
|
5758
|
-
})) {
|
|
5759
|
-
this.scheduleAnnounceRetry();
|
|
5760
|
-
return false;
|
|
5761
|
-
}
|
|
5762
|
-
this.instanceId = instanceId;
|
|
5763
|
-
this.announceAttempts = 0;
|
|
5764
|
-
logger.info(`[tavern] announced ${workspaceName} to cc-bridge on 127.0.0.1:${this.config.port ?? DEFAULT_PORT}`);
|
|
5765
|
-
this.connectCommandWs();
|
|
5766
|
-
this.emitEvent({
|
|
5767
|
-
type: "status",
|
|
5768
|
-
status: "idle"
|
|
5769
|
-
});
|
|
5770
|
-
return true;
|
|
5596
|
+
/**
|
|
5597
|
+
* Generate a unique message ID for Jupyter protocol
|
|
5598
|
+
*/
|
|
5599
|
+
generateMsgId() {
|
|
5600
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
5771
5601
|
}
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
|
|
5776
|
-
|
|
5777
|
-
|
|
5778
|
-
|
|
5779
|
-
|
|
5602
|
+
/**
|
|
5603
|
+
* Execute code in a kernel using the Jupyter WebSocket protocol.
|
|
5604
|
+
*
|
|
5605
|
+
* Connects to the kernel's channels WebSocket, sends an execute_request,
|
|
5606
|
+
* and collects outputs until the kernel returns to idle state.
|
|
5607
|
+
*
|
|
5608
|
+
* @param kernelId - The kernel ID to execute code in
|
|
5609
|
+
* @param code - The code to execute
|
|
5610
|
+
* @param timeoutMs - Execution timeout in milliseconds (default: 30000)
|
|
5611
|
+
*/
|
|
5612
|
+
async executeCell(kernelId, code, timeoutMs = 3e4) {
|
|
5613
|
+
const wsUrl = this.getKernelWebSocketUrl(kernelId);
|
|
5614
|
+
const msgId = this.generateMsgId();
|
|
5615
|
+
return new Promise((resolve, reject) => {
|
|
5616
|
+
const outputs = [];
|
|
5617
|
+
let executionCount = null;
|
|
5618
|
+
let hasError = false;
|
|
5619
|
+
let errorInfo;
|
|
5620
|
+
const ws = new WsWebSocket(wsUrl);
|
|
5621
|
+
const timeoutHandle = setTimeout(() => {
|
|
5622
|
+
cleanup();
|
|
5623
|
+
reject(new JupyterClientError(`Cell execution timed out after ${timeoutMs}ms`));
|
|
5624
|
+
}, timeoutMs);
|
|
5625
|
+
const cleanup = () => {
|
|
5626
|
+
clearTimeout(timeoutHandle);
|
|
5627
|
+
if (ws.readyState === WsWebSocket.OPEN || ws.readyState === WsWebSocket.CONNECTING) ws.close();
|
|
5628
|
+
};
|
|
5629
|
+
ws.on("error", (err) => {
|
|
5630
|
+
cleanup();
|
|
5631
|
+
reject(new JupyterClientError(`WebSocket error: ${err.message}`));
|
|
5632
|
+
});
|
|
5633
|
+
ws.on("open", () => {
|
|
5634
|
+
const executeRequest = {
|
|
5635
|
+
header: {
|
|
5636
|
+
msg_id: msgId,
|
|
5637
|
+
msg_type: "execute_request",
|
|
5638
|
+
username: "b4m-cli",
|
|
5639
|
+
session: this.generateMsgId(),
|
|
5640
|
+
date: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5641
|
+
version: "5.3"
|
|
5642
|
+
},
|
|
5643
|
+
parent_header: {},
|
|
5644
|
+
metadata: {},
|
|
5645
|
+
content: {
|
|
5646
|
+
code,
|
|
5647
|
+
silent: false,
|
|
5648
|
+
store_history: true,
|
|
5649
|
+
user_expressions: {},
|
|
5650
|
+
allow_stdin: false,
|
|
5651
|
+
stop_on_error: true
|
|
5652
|
+
},
|
|
5653
|
+
buffers: [],
|
|
5654
|
+
channel: "shell"
|
|
5655
|
+
};
|
|
5656
|
+
ws.send(JSON.stringify(executeRequest));
|
|
5657
|
+
});
|
|
5658
|
+
ws.on("message", (data) => {
|
|
5659
|
+
try {
|
|
5660
|
+
const msg = JSON.parse(data.toString());
|
|
5661
|
+
if (msg.parent_header?.msg_id !== msgId) return;
|
|
5662
|
+
switch (msg.header?.msg_type || msg.msg_type) {
|
|
5663
|
+
case "stream":
|
|
5664
|
+
outputs.push({
|
|
5665
|
+
output_type: "stream",
|
|
5666
|
+
name: msg.content.name,
|
|
5667
|
+
text: msg.content.text
|
|
5668
|
+
});
|
|
5669
|
+
break;
|
|
5670
|
+
case "execute_result":
|
|
5671
|
+
executionCount = msg.content.execution_count;
|
|
5672
|
+
outputs.push({
|
|
5673
|
+
output_type: "execute_result",
|
|
5674
|
+
data: msg.content.data,
|
|
5675
|
+
execution_count: msg.content.execution_count,
|
|
5676
|
+
metadata: msg.content.metadata
|
|
5677
|
+
});
|
|
5678
|
+
break;
|
|
5679
|
+
case "display_data":
|
|
5680
|
+
outputs.push({
|
|
5681
|
+
output_type: "display_data",
|
|
5682
|
+
data: msg.content.data,
|
|
5683
|
+
metadata: msg.content.metadata
|
|
5684
|
+
});
|
|
5685
|
+
break;
|
|
5686
|
+
case "error":
|
|
5687
|
+
hasError = true;
|
|
5688
|
+
errorInfo = {
|
|
5689
|
+
ename: msg.content.ename,
|
|
5690
|
+
evalue: msg.content.evalue,
|
|
5691
|
+
traceback: msg.content.traceback
|
|
5692
|
+
};
|
|
5693
|
+
outputs.push({
|
|
5694
|
+
output_type: "error",
|
|
5695
|
+
ename: msg.content.ename,
|
|
5696
|
+
evalue: msg.content.evalue,
|
|
5697
|
+
traceback: msg.content.traceback
|
|
5698
|
+
});
|
|
5699
|
+
break;
|
|
5700
|
+
case "execute_reply":
|
|
5701
|
+
if (msg.content.status === "ok" || msg.content.status === "error") {
|
|
5702
|
+
if (msg.content.execution_count !== void 0) executionCount = msg.content.execution_count;
|
|
5703
|
+
cleanup();
|
|
5704
|
+
resolve({
|
|
5705
|
+
success: !hasError,
|
|
5706
|
+
outputs,
|
|
5707
|
+
executionCount,
|
|
5708
|
+
error: errorInfo
|
|
5709
|
+
});
|
|
5710
|
+
}
|
|
5711
|
+
break;
|
|
5712
|
+
case "status": break;
|
|
5713
|
+
}
|
|
5714
|
+
} catch {}
|
|
5715
|
+
});
|
|
5716
|
+
ws.on("close", () => {
|
|
5717
|
+
clearTimeout(timeoutHandle);
|
|
5718
|
+
});
|
|
5719
|
+
});
|
|
5780
5720
|
}
|
|
5781
|
-
/**
|
|
5782
|
-
*
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
instanceId: this.instanceId,
|
|
5787
|
-
event
|
|
5788
|
-
}).catch((err) => logger.info(`[tavern] emitEvent ${event.type} failed: ${err.message}`));
|
|
5789
|
-
this.emitQueue = this.emitQueue.then(task, task);
|
|
5790
|
-
return this.emitQueue;
|
|
5721
|
+
/**
|
|
5722
|
+
* @deprecated Use executeCell instead. This method exists for backwards compatibility.
|
|
5723
|
+
*/
|
|
5724
|
+
async executeCode(kernelId, code) {
|
|
5725
|
+
return this.executeCell(kernelId, code);
|
|
5791
5726
|
}
|
|
5792
5727
|
/**
|
|
5793
|
-
*
|
|
5794
|
-
* command-WS reconnect loops, closes the socket, and best-effort signals
|
|
5795
|
-
* disconnect to the bridge.
|
|
5796
|
-
*
|
|
5797
|
-
* After this resolves the instance is fully reset, so a later `start()`
|
|
5798
|
-
* re-announces — the same singleton can be toggled off (Tavern feature
|
|
5799
|
-
* disabled at runtime) and back on without restarting the CLI. The
|
|
5800
|
-
* `stopped` latch is left true here purely so any straggler retry callback
|
|
5801
|
-
* already queued short-circuits; `start()` clears it.
|
|
5728
|
+
* Interrupt a running kernel
|
|
5802
5729
|
*/
|
|
5803
|
-
async
|
|
5804
|
-
|
|
5805
|
-
|
|
5806
|
-
|
|
5807
|
-
|
|
5808
|
-
|
|
5809
|
-
|
|
5810
|
-
|
|
5811
|
-
|
|
5812
|
-
|
|
5813
|
-
|
|
5814
|
-
|
|
5730
|
+
async interruptKernel(kernelId) {
|
|
5731
|
+
await this.request("POST", `/api/kernels/${kernelId}/interrupt`);
|
|
5732
|
+
}
|
|
5733
|
+
/**
|
|
5734
|
+
* Restart a kernel
|
|
5735
|
+
*/
|
|
5736
|
+
async restartKernel(kernelId) {
|
|
5737
|
+
return this.request("POST", `/api/kernels/${kernelId}/restart`);
|
|
5738
|
+
}
|
|
5739
|
+
};
|
|
5740
|
+
/**
|
|
5741
|
+
* Create a JupyterClient from environment variables or config
|
|
5742
|
+
*/
|
|
5743
|
+
function createJupyterClientFromEnv() {
|
|
5744
|
+
const serverUrl = process.env.JUPYTER_SERVER_URL;
|
|
5745
|
+
const token = process.env.JUPYTER_TOKEN;
|
|
5746
|
+
if (!serverUrl) return null;
|
|
5747
|
+
return new JupyterClient({
|
|
5748
|
+
serverUrl,
|
|
5749
|
+
token
|
|
5750
|
+
});
|
|
5751
|
+
}
|
|
5752
|
+
//#endregion
|
|
5753
|
+
//#region src/commands/executeNotebookHandler.ts
|
|
5754
|
+
/**
|
|
5755
|
+
* Jupyter Notebook Execution Handler
|
|
5756
|
+
*
|
|
5757
|
+
* Handles the jupyter_execute_notebook Keep command.
|
|
5758
|
+
* Orchestrates cell-by-cell execution of a notebook via local Jupyter server.
|
|
5759
|
+
*/
|
|
5760
|
+
/**
|
|
5761
|
+
* Zod schema for validating execute notebook parameters.
|
|
5762
|
+
* Replaces unsafe `as` casts with proper runtime validation.
|
|
5763
|
+
*/
|
|
5764
|
+
const ExecuteNotebookParams = z.object({
|
|
5765
|
+
notebookJson: z.string().min(1, "notebookJson is required"),
|
|
5766
|
+
sessionId: z.string().min(1, "sessionId is required"),
|
|
5767
|
+
kernelName: z.string().default("python3"),
|
|
5768
|
+
timeoutPerCell: z.number().default(3e4)
|
|
5769
|
+
});
|
|
5770
|
+
/**
|
|
5771
|
+
* Get cell source as string (handles both string and string[] formats).
|
|
5772
|
+
*/
|
|
5773
|
+
function getCellSource(cell) {
|
|
5774
|
+
return Array.isArray(cell.source) ? cell.source.join("") : cell.source;
|
|
5775
|
+
}
|
|
5776
|
+
/**
|
|
5777
|
+
* Execute a Jupyter notebook cell by cell.
|
|
5778
|
+
*
|
|
5779
|
+
* @param params - Validated notebook execution parameters
|
|
5780
|
+
* @param deps - Dependencies (jupyter client, websocket manager, logger)
|
|
5781
|
+
* @returns Execution result with success status and executed notebook
|
|
5782
|
+
*/
|
|
5783
|
+
async function executeNotebook(params, deps) {
|
|
5784
|
+
const { jupyterClient, wsManager, logger, requestId } = deps;
|
|
5785
|
+
const { notebookJson, sessionId, kernelName, timeoutPerCell } = ExecuteNotebookParams.parse(params);
|
|
5786
|
+
let notebook;
|
|
5787
|
+
try {
|
|
5788
|
+
notebook = JSON.parse(notebookJson);
|
|
5789
|
+
if (!notebook.cells || !Array.isArray(notebook.cells)) throw new Error("Invalid notebook: missing cells array");
|
|
5790
|
+
} catch (parseErr) {
|
|
5791
|
+
throw new Error(`Failed to parse notebook JSON: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`);
|
|
5792
|
+
}
|
|
5793
|
+
const totalCodeCells = notebook.cells.filter((c) => c.cell_type === "code").length;
|
|
5794
|
+
logger.info(`[Keep] Starting notebook execution: ${totalCodeCells} code cells, kernel: ${kernelName}`);
|
|
5795
|
+
const tempNotebookPath = `/tmp/b4m-notebook-${randomUUID()}.ipynb`;
|
|
5796
|
+
await promises.writeFile(tempNotebookPath, notebookJson, "utf-8");
|
|
5797
|
+
let jupyterSession = null;
|
|
5798
|
+
let cellsExecuted = 0;
|
|
5799
|
+
let cellsFailed = 0;
|
|
5800
|
+
try {
|
|
5801
|
+
logger.info(`[Keep] Starting Jupyter kernel: ${kernelName}`);
|
|
5802
|
+
jupyterSession = await jupyterClient.startSession(tempNotebookPath, kernelName);
|
|
5803
|
+
const kernelId = jupyterSession.kernel.id;
|
|
5804
|
+
const jupyterSessionId = jupyterSession.id;
|
|
5805
|
+
logger.info(`[Keep] Kernel started: session=${jupyterSessionId}, kernel=${kernelId}`);
|
|
5806
|
+
let codeCellIndex = 0;
|
|
5807
|
+
for (let i = 0; i < notebook.cells.length; i++) {
|
|
5808
|
+
const cell = notebook.cells[i];
|
|
5809
|
+
if (cell.cell_type !== "code") continue;
|
|
5810
|
+
const cellCode = getCellSource(cell);
|
|
5811
|
+
if (!cellCode.trim()) {
|
|
5812
|
+
codeCellIndex++;
|
|
5813
|
+
continue;
|
|
5814
|
+
}
|
|
5815
|
+
logger.info(`[Keep] Executing cell ${codeCellIndex + 1}/${totalCodeCells}`);
|
|
5816
|
+
wsManager?.send({
|
|
5817
|
+
action: "jupyter_cell_output",
|
|
5818
|
+
requestId,
|
|
5819
|
+
sessionId,
|
|
5820
|
+
jupyterSessionId,
|
|
5821
|
+
cellIndex: codeCellIndex,
|
|
5822
|
+
outputType: "stream",
|
|
5823
|
+
content: {
|
|
5824
|
+
text: `Executing cell ${codeCellIndex + 1}/${totalCodeCells}...`,
|
|
5825
|
+
name: "stdout"
|
|
5826
|
+
},
|
|
5827
|
+
executionCount: null,
|
|
5828
|
+
isComplete: false
|
|
5829
|
+
});
|
|
5815
5830
|
try {
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
|
|
5831
|
+
const cellResult = await jupyterClient.executeCell(kernelId, cellCode, timeoutPerCell);
|
|
5832
|
+
cell.outputs = cellResult.outputs;
|
|
5833
|
+
cell.execution_count = cellResult.executionCount;
|
|
5834
|
+
if (cellResult.success) {
|
|
5835
|
+
cellsExecuted++;
|
|
5836
|
+
wsManager?.send({
|
|
5837
|
+
action: "jupyter_cell_output",
|
|
5838
|
+
requestId,
|
|
5839
|
+
sessionId,
|
|
5840
|
+
jupyterSessionId,
|
|
5841
|
+
cellIndex: codeCellIndex,
|
|
5842
|
+
outputType: "execute_result",
|
|
5843
|
+
content: {
|
|
5844
|
+
text: cellResult.outputs.map((o) => {
|
|
5845
|
+
if (o.text) return Array.isArray(o.text) ? o.text.join("") : o.text;
|
|
5846
|
+
if (o.data && typeof o.data === "object" && "text/plain" in o.data) {
|
|
5847
|
+
const textPlain = o.data["text/plain"];
|
|
5848
|
+
return Array.isArray(textPlain) ? textPlain.join("") : String(textPlain);
|
|
5849
|
+
}
|
|
5850
|
+
return "";
|
|
5851
|
+
}).join(""),
|
|
5852
|
+
data: cellResult.outputs.find((o) => o.data)?.data
|
|
5853
|
+
},
|
|
5854
|
+
executionCount: cellResult.executionCount,
|
|
5855
|
+
isComplete: true
|
|
5856
|
+
});
|
|
5857
|
+
} else {
|
|
5858
|
+
cellsFailed++;
|
|
5859
|
+
wsManager?.send({
|
|
5860
|
+
action: "jupyter_cell_output",
|
|
5861
|
+
requestId,
|
|
5862
|
+
sessionId,
|
|
5863
|
+
jupyterSessionId,
|
|
5864
|
+
cellIndex: codeCellIndex,
|
|
5865
|
+
outputType: "error",
|
|
5866
|
+
content: {
|
|
5867
|
+
ename: cellResult.error?.ename || "ExecutionError",
|
|
5868
|
+
evalue: cellResult.error?.evalue || "Cell execution failed",
|
|
5869
|
+
traceback: cellResult.error?.traceback || []
|
|
5870
|
+
},
|
|
5871
|
+
executionCount: cellResult.executionCount,
|
|
5872
|
+
isComplete: true
|
|
5873
|
+
});
|
|
5874
|
+
logger.warn(`[Keep] Cell ${codeCellIndex + 1} failed: ${cellResult.error?.ename}: ${cellResult.error?.evalue}`);
|
|
5875
|
+
}
|
|
5876
|
+
} catch (cellErr) {
|
|
5877
|
+
cellsFailed++;
|
|
5878
|
+
const errMsg = cellErr instanceof Error ? cellErr.message : String(cellErr);
|
|
5879
|
+
wsManager?.send({
|
|
5880
|
+
action: "jupyter_cell_output",
|
|
5881
|
+
requestId,
|
|
5882
|
+
sessionId,
|
|
5883
|
+
jupyterSessionId,
|
|
5884
|
+
cellIndex: codeCellIndex,
|
|
5885
|
+
outputType: "error",
|
|
5886
|
+
content: {
|
|
5887
|
+
ename: "ExecutionError",
|
|
5888
|
+
evalue: errMsg,
|
|
5889
|
+
traceback: []
|
|
5890
|
+
},
|
|
5891
|
+
executionCount: null,
|
|
5892
|
+
isComplete: true
|
|
5893
|
+
});
|
|
5894
|
+
logger.error(`[Keep] Cell ${codeCellIndex + 1} threw error: ${errMsg}`);
|
|
5895
|
+
}
|
|
5896
|
+
codeCellIndex++;
|
|
5819
5897
|
}
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
5826
|
-
|
|
5827
|
-
|
|
5828
|
-
|
|
5829
|
-
|
|
5830
|
-
|
|
5831
|
-
|
|
5832
|
-
|
|
5833
|
-
|
|
5898
|
+
return {
|
|
5899
|
+
success: cellsFailed === 0,
|
|
5900
|
+
cellsExecuted,
|
|
5901
|
+
cellsFailed,
|
|
5902
|
+
totalCodeCells,
|
|
5903
|
+
executedNotebook: JSON.stringify(notebook)
|
|
5904
|
+
};
|
|
5905
|
+
} finally {
|
|
5906
|
+
if (jupyterSession) try {
|
|
5907
|
+
logger.info(`[Keep] Stopping Jupyter session: ${jupyterSession.id}`);
|
|
5908
|
+
await jupyterClient.stopSession(jupyterSession.id);
|
|
5909
|
+
} catch (stopErr) {
|
|
5910
|
+
logger.warn(`[Keep] Failed to stop session: ${stopErr instanceof Error ? stopErr.message : String(stopErr)}`);
|
|
5911
|
+
}
|
|
5912
|
+
try {
|
|
5913
|
+
await promises.unlink(tempNotebookPath);
|
|
5914
|
+
} catch {}
|
|
5834
5915
|
}
|
|
5835
|
-
|
|
5916
|
+
}
|
|
5917
|
+
//#endregion
|
|
5918
|
+
//#region src/bootstrap/registerKeepHandlers.ts
|
|
5919
|
+
/**
|
|
5920
|
+
* Get a configured JupyterClient or throw an error if not configured.
|
|
5921
|
+
* Centralizes the Jupyter configuration check for all Jupyter Keep commands.
|
|
5922
|
+
*/
|
|
5923
|
+
function getRequiredJupyterClient() {
|
|
5924
|
+
const client = createJupyterClientFromEnv();
|
|
5925
|
+
if (!client) throw new Error("Jupyter not configured. Set JUPYTER_SERVER_URL and optionally JUPYTER_TOKEN environment variables.");
|
|
5926
|
+
return client;
|
|
5927
|
+
}
|
|
5928
|
+
/**
|
|
5929
|
+
* Register the Keep command handler on a connected WebSocket manager — allows
|
|
5930
|
+
* the web HUD to execute commands on this machine via the B4M cloud relay.
|
|
5931
|
+
*
|
|
5932
|
+
* Pure bootstrap seam: no React hooks, no Zustand state. The connected
|
|
5933
|
+
* `wsManager` is passed in by the caller. Invoked from `buildLlmBackend`'s
|
|
5934
|
+
* `onWsConnected` callback at exactly the point the original inline registration
|
|
5935
|
+
* ran, so a registration throw still propagates into the WS try/catch and
|
|
5936
|
+
* triggers the SSE fallback.
|
|
5937
|
+
*/
|
|
5938
|
+
function registerKeepHandlers(wsManager) {
|
|
5939
|
+
wsManager.onAction("keep_command", async (message) => {
|
|
5940
|
+
const { commandType, params, requestId, originConnectionId } = message;
|
|
5941
|
+
let result;
|
|
5942
|
+
let success = true;
|
|
5943
|
+
let error;
|
|
5836
5944
|
try {
|
|
5837
|
-
|
|
5838
|
-
|
|
5945
|
+
switch (commandType) {
|
|
5946
|
+
case "read_file": {
|
|
5947
|
+
const filePath = params.path;
|
|
5948
|
+
if (!filePath) throw new Error("Missing required param: path");
|
|
5949
|
+
logger.info(`[Keep] Reading file: ${filePath}`);
|
|
5950
|
+
result = {
|
|
5951
|
+
content: await promises.readFile(filePath, "utf-8"),
|
|
5952
|
+
path: filePath
|
|
5953
|
+
};
|
|
5954
|
+
break;
|
|
5955
|
+
}
|
|
5956
|
+
case "list_directory": {
|
|
5957
|
+
const dirPath = params.path || ".";
|
|
5958
|
+
logger.info(`[Keep] Listing directory: ${dirPath}`);
|
|
5959
|
+
result = (await promises.readdir(dirPath, { withFileTypes: true })).map((e) => ({
|
|
5960
|
+
name: e.name,
|
|
5961
|
+
isDirectory: e.isDirectory()
|
|
5962
|
+
}));
|
|
5963
|
+
break;
|
|
5964
|
+
}
|
|
5965
|
+
case "jupyter_get_kernelspecs":
|
|
5966
|
+
logger.info("[Keep] Getting Jupyter kernel specs");
|
|
5967
|
+
result = await getRequiredJupyterClient().getKernelSpecs();
|
|
5968
|
+
break;
|
|
5969
|
+
case "jupyter_start_kernel": {
|
|
5970
|
+
const notebookPath = params.notebookPath;
|
|
5971
|
+
const kernelName = params.kernelName;
|
|
5972
|
+
logger.info(`[Keep] Starting Jupyter kernel for: ${notebookPath}`);
|
|
5973
|
+
result = await getRequiredJupyterClient().startSession(notebookPath, kernelName);
|
|
5974
|
+
break;
|
|
5975
|
+
}
|
|
5976
|
+
case "jupyter_stop_kernel": {
|
|
5977
|
+
const sessionId = params.sessionId;
|
|
5978
|
+
if (!sessionId) throw new Error("Missing required param: sessionId");
|
|
5979
|
+
logger.info(`[Keep] Stopping Jupyter session: ${sessionId}`);
|
|
5980
|
+
await getRequiredJupyterClient().stopSession(sessionId);
|
|
5981
|
+
result = {
|
|
5982
|
+
success: true,
|
|
5983
|
+
sessionId
|
|
5984
|
+
};
|
|
5985
|
+
break;
|
|
5986
|
+
}
|
|
5987
|
+
case "jupyter_execute_cell": {
|
|
5988
|
+
const code = params.code;
|
|
5989
|
+
const kernelId = params.kernelId;
|
|
5990
|
+
const timeoutMs = params.timeoutMs || 3e4;
|
|
5991
|
+
if (!code) throw new Error("Missing required param: code");
|
|
5992
|
+
if (!kernelId) throw new Error("Missing required param: kernelId");
|
|
5993
|
+
logger.info(`[Keep] Executing cell (kernel: ${kernelId}, code length: ${code.length})`);
|
|
5994
|
+
const cellResult = await getRequiredJupyterClient().executeCell(kernelId, code, timeoutMs);
|
|
5995
|
+
logger.info(`[Keep] Cell execution ${cellResult.success ? "succeeded" : "failed"} (outputs: ${cellResult.outputs.length}, execution_count: ${cellResult.executionCount})`);
|
|
5996
|
+
result = {
|
|
5997
|
+
success: cellResult.success,
|
|
5998
|
+
outputs: cellResult.outputs,
|
|
5999
|
+
executionCount: cellResult.executionCount,
|
|
6000
|
+
error: cellResult.error
|
|
6001
|
+
};
|
|
6002
|
+
break;
|
|
6003
|
+
}
|
|
6004
|
+
case "jupyter_execute_notebook":
|
|
6005
|
+
result = await executeNotebook(params, {
|
|
6006
|
+
jupyterClient: getRequiredJupyterClient(),
|
|
6007
|
+
wsManager,
|
|
6008
|
+
logger,
|
|
6009
|
+
requestId
|
|
6010
|
+
});
|
|
6011
|
+
break;
|
|
6012
|
+
default: throw new Error(`Unknown command type: ${commandType}`);
|
|
6013
|
+
}
|
|
5839
6014
|
} catch (err) {
|
|
5840
|
-
|
|
5841
|
-
|
|
6015
|
+
success = false;
|
|
6016
|
+
error = err instanceof Error ? err.message : String(err);
|
|
5842
6017
|
}
|
|
5843
|
-
|
|
5844
|
-
|
|
5845
|
-
|
|
5846
|
-
|
|
5847
|
-
|
|
5848
|
-
|
|
5849
|
-
|
|
5850
|
-
|
|
5851
|
-
|
|
6018
|
+
logger.info(`[Keep] Sending response: success=${success}, originConnectionId=${originConnectionId?.slice(0, 12)}..., resultSize=${JSON.stringify(result)?.length ?? 0}`);
|
|
6019
|
+
try {
|
|
6020
|
+
wsManager.send({
|
|
6021
|
+
action: "keep_command_response",
|
|
6022
|
+
requestId,
|
|
6023
|
+
originConnectionId,
|
|
6024
|
+
success,
|
|
6025
|
+
result,
|
|
6026
|
+
error
|
|
6027
|
+
});
|
|
6028
|
+
logger.info("[Keep] Response sent successfully");
|
|
6029
|
+
} catch (sendErr) {
|
|
6030
|
+
logger.info(`[Keep] Failed to send response: ${sendErr instanceof Error ? sendErr.message : String(sendErr)}`);
|
|
6031
|
+
}
|
|
6032
|
+
});
|
|
6033
|
+
}
|
|
6034
|
+
//#endregion
|
|
6035
|
+
//#region src/bootstrap/buildLlmBackend.ts
|
|
6036
|
+
/** Production wiring: real transport classes + the ToolRouter singleton. */
|
|
6037
|
+
const defaultLlmBackendDeps = {
|
|
6038
|
+
connectWebSocket: async (wsUrl, tokenGetter) => {
|
|
6039
|
+
const ws = new WebSocketConnectionManager(wsUrl, tokenGetter);
|
|
6040
|
+
await ws.connect();
|
|
6041
|
+
return ws;
|
|
6042
|
+
},
|
|
6043
|
+
installWebSocketToolExecutor: (ws, tokenGetter) => {
|
|
6044
|
+
setWebSocketToolExecutor(new WebSocketToolExecutor(ws, tokenGetter));
|
|
6045
|
+
},
|
|
6046
|
+
clearWebSocketToolExecutor: () => setWebSocketToolExecutor(null),
|
|
6047
|
+
createWebSocketBackend: (opts) => new WebSocketLlmBackend(opts),
|
|
6048
|
+
createServerBackend: (opts) => new ServerLlmBackend(opts),
|
|
6049
|
+
registerKeepHandlers,
|
|
6050
|
+
createOllamaBackend: (host) => new OllamaBackend(host, {
|
|
6051
|
+
debug: (...args) => logger.debug(args.map(String).join(" ")),
|
|
6052
|
+
info: (...args) => logger.info(args.map(String).join(" ")),
|
|
6053
|
+
warn: (...args) => logger.warn(args.map(String).join(" ")),
|
|
6054
|
+
error: (...args) => logger.error(args.map(String).join(" "))
|
|
6055
|
+
}),
|
|
6056
|
+
createMultiBackend: (server, ollama, serverModels, ollamaModels, defaultModel) => new MultiLlmBackend(server, ollama, serverModels, ollamaModels, defaultModel)
|
|
6057
|
+
};
|
|
6058
|
+
/**
|
|
6059
|
+
* Resolve the model to use from the available list: the requested default if
|
|
6060
|
+
* present, otherwise the first available model. Pure — exported for testing.
|
|
6061
|
+
*/
|
|
6062
|
+
function resolveModelInfo(models, defaultModel) {
|
|
6063
|
+
return models.find((m) => m.id === defaultModel) || models[0];
|
|
6064
|
+
}
|
|
6065
|
+
/**
|
|
6066
|
+
* Build the LLM backend: WebSocket transport first (bypasses CloudFront 20s
|
|
6067
|
+
* timeout), SSE fallback, optional Ollama multiplexing. Resolves the default
|
|
6068
|
+
* model and pins it on the backend.
|
|
6069
|
+
*
|
|
6070
|
+
* Pure bootstrap seam — no React hooks, no Zustand state. The WS path registers
|
|
6071
|
+
* the Keep command handler inline (inside the same try-block) so a registration
|
|
6072
|
+
* throw still triggers the SSE fallback, exactly as before. The tool-executor
|
|
6073
|
+
* install/clear ordering (set on connect, cleared on fallback) is preserved.
|
|
6074
|
+
*/
|
|
6075
|
+
async function buildLlmBackend(input, deps = defaultLlmBackendDeps) {
|
|
6076
|
+
const { config, apiClient, tokenGetter, startupLog } = input;
|
|
6077
|
+
let wsManager = null;
|
|
6078
|
+
let llm;
|
|
6079
|
+
let completionsUrl;
|
|
6080
|
+
try {
|
|
6081
|
+
const serverConfig = await apiClient.get("/api/settings/serverConfig");
|
|
6082
|
+
const wsUrl = serverConfig?.websocketUrl;
|
|
6083
|
+
const wsCompletionUrl = serverConfig?.wsCompletionUrl;
|
|
6084
|
+
completionsUrl = serverConfig?.completionsUrl;
|
|
6085
|
+
if (wsUrl && wsCompletionUrl) {
|
|
6086
|
+
wsManager = await deps.connectWebSocket(wsUrl, tokenGetter);
|
|
6087
|
+
deps.installWebSocketToolExecutor(wsManager, tokenGetter);
|
|
6088
|
+
llm = deps.createWebSocketBackend({
|
|
6089
|
+
wsManager,
|
|
6090
|
+
apiClient,
|
|
6091
|
+
model: config.defaultModel,
|
|
6092
|
+
tokenGetter,
|
|
6093
|
+
wsCompletionUrl
|
|
6094
|
+
});
|
|
6095
|
+
deps.registerKeepHandlers(wsManager);
|
|
6096
|
+
logger.debug("🔌 Using WebSocket transport (bypasses CloudFront timeout)");
|
|
6097
|
+
} else throw new Error("No websocketUrl or wsCompletionUrl in server config");
|
|
6098
|
+
} catch (wsError) {
|
|
6099
|
+
logger.debug("⚠️ WebSocket unavailable, using SSE fallback");
|
|
6100
|
+
logger.debug(`[WebSocket] Fallback reason: ${wsError instanceof Error ? wsError.message : String(wsError)}`);
|
|
6101
|
+
if (wsError instanceof Error && wsError.stack) logger.debug(`[WebSocket] Stack: ${wsError.stack}`);
|
|
6102
|
+
wsManager = null;
|
|
6103
|
+
deps.clearWebSocketToolExecutor();
|
|
6104
|
+
llm = deps.createServerBackend({
|
|
6105
|
+
apiClient,
|
|
6106
|
+
model: config.defaultModel,
|
|
6107
|
+
completionsUrl
|
|
5852
6108
|
});
|
|
5853
|
-
if (!res.ok) throw new Error(`bridge ${path} -> ${res.status}`);
|
|
5854
6109
|
}
|
|
5855
|
-
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
6110
|
+
const ollamaHost = input.ollamaHost ?? process.env.B4M_OLLAMA_HOST;
|
|
6111
|
+
let models;
|
|
6112
|
+
if (ollamaHost) {
|
|
6113
|
+
const ollamaBackend = deps.createOllamaBackend(ollamaHost);
|
|
6114
|
+
const [serverModels, ollamaModels] = await Promise.all([llm.getModelInfo(), ollamaBackend.getModelInfo()]);
|
|
6115
|
+
if (serverModels.length === 0 && ollamaModels.length === 0) throw new Error(`No models available from server or Ollama at ${ollamaHost}.\nPull a model: ollama pull qwen3.5`);
|
|
6116
|
+
if (ollamaModels.length === 0) startupLog.push(`⚠️ No models found in Ollama at ${ollamaHost}. Pull one with: ollama pull qwen3.5`);
|
|
6117
|
+
const serverBackend = llm;
|
|
6118
|
+
llm = deps.createMultiBackend(serverBackend, ollamaBackend, serverModels, ollamaModels, config.defaultModel);
|
|
6119
|
+
models = await llm.getModelInfo();
|
|
6120
|
+
startupLog.push(`🦙 Self-hosted Ollama: ${ollamaModels.length} model(s) added to picker`);
|
|
6121
|
+
} else {
|
|
6122
|
+
models = await llm.getModelInfo();
|
|
6123
|
+
if (models.length === 0) throw new Error("No models available from server.");
|
|
6124
|
+
}
|
|
6125
|
+
logger.debug(`📋 Available models: ${models.map((m) => m.id).join(", ")}`);
|
|
6126
|
+
const modelInfo = resolveModelInfo(models, config.defaultModel);
|
|
6127
|
+
if (modelInfo.id !== config.defaultModel) {
|
|
6128
|
+
logger.warn(`⚠️ Requested model '${config.defaultModel}' not available`);
|
|
6129
|
+
logger.warn(`🤖 Using fallback model: ${modelInfo.id}`);
|
|
6130
|
+
}
|
|
6131
|
+
llm.currentModel = modelInfo.id;
|
|
6132
|
+
return {
|
|
6133
|
+
llm,
|
|
6134
|
+
wsManager,
|
|
6135
|
+
models,
|
|
6136
|
+
modelInfo
|
|
6137
|
+
};
|
|
6138
|
+
}
|
|
6139
|
+
//#endregion
|
|
6140
|
+
//#region src/bootstrap/buildSandbox.ts
|
|
6141
|
+
/**
|
|
6142
|
+
* Initialize the sandbox orchestrator for OS-level filesystem isolation, wire
|
|
6143
|
+
* the network-proxy event handler, attach the violation store, and start the
|
|
6144
|
+
* proxy when enabled.
|
|
6145
|
+
*
|
|
6146
|
+
* Pure bootstrap seam — no React hooks, no Zustand state. Sandbox modules are
|
|
6147
|
+
* imported dynamically (as before) so the cost is only paid when init runs.
|
|
6148
|
+
*/
|
|
6149
|
+
async function buildSandbox(input) {
|
|
6150
|
+
const { config, sessionId, permissionManager, checkpointStore } = input;
|
|
6151
|
+
const [{ createSandboxRuntime }, { SandboxOrchestrator }, { DEFAULT_SANDBOX_CONFIG }, { ProxyManager }, { ViolationLogStore }] = await Promise.all([
|
|
6152
|
+
import("./SandboxRuntimeAdapter-CKelGICD.mjs"),
|
|
6153
|
+
import("./SandboxOrchestrator-BS6gALNq.mjs"),
|
|
6154
|
+
import("./types-CqscS34o.mjs"),
|
|
6155
|
+
import("./ProxyManager-ByuAHFMq.mjs"),
|
|
6156
|
+
import("./ViolationLogStore-B-plqJfn.mjs")
|
|
6157
|
+
]);
|
|
6158
|
+
const sandboxConfig = config.sandbox ?? DEFAULT_SANDBOX_CONFIG;
|
|
6159
|
+
const [sandboxRuntime] = await Promise.all([createSandboxRuntime(), checkpointStore.init(sessionId).catch(() => {})]);
|
|
6160
|
+
const proxyManager = new ProxyManager(sandboxConfig.network);
|
|
6161
|
+
const sandboxOrchestrator = new SandboxOrchestrator(sandboxConfig, sandboxRuntime, proxyManager);
|
|
6162
|
+
proxyManager.onEvent((event) => {
|
|
6163
|
+
if (event.type === "blocked") {
|
|
6164
|
+
console.error(`\n\x1b[41m\x1b[97m BLOCKED \x1b[0m \x1b[31mNetwork proxy denied connection to\x1b[0m \x1b[1m${event.domain}\x1b[0m \x1b[90m(${event.method})\x1b[0m`);
|
|
6165
|
+
console.error(`\x1b[90m Tip: /sandbox:trust-domain ${event.domain}\x1b[0m\n`);
|
|
6166
|
+
sandboxOrchestrator.recordViolation({
|
|
6167
|
+
type: "network",
|
|
6168
|
+
domain: event.domain,
|
|
6169
|
+
command: `[network] ${event.method} ${event.domain}`,
|
|
6170
|
+
blockedBy: "proxy",
|
|
6171
|
+
timestamp: event.timestamp,
|
|
6172
|
+
detail: `Blocked ${event.method} to ${event.domain}`
|
|
6173
|
+
}).catch(() => {});
|
|
6174
|
+
}
|
|
6175
|
+
});
|
|
6176
|
+
const violationStore = new ViolationLogStore();
|
|
6177
|
+
sandboxOrchestrator.setViolationStore(violationStore);
|
|
6178
|
+
permissionManager.setSandboxState(sandboxConfig.mode, sandboxOrchestrator.isActive());
|
|
6179
|
+
if (sandboxConfig.enabled && sandboxConfig.mode !== "disabled") {
|
|
6180
|
+
if (sandboxRuntime) console.log(`🔒 Sandbox: ${sandboxConfig.mode} (${sandboxRuntime.name})`);
|
|
6181
|
+
else console.log("⚠️ Sandbox: enabled but runtime not available on this platform");
|
|
6182
|
+
if (sandboxConfig.network.enabled) {
|
|
6183
|
+
await proxyManager.start();
|
|
6184
|
+
if (proxyManager.isRunning()) console.log(`🌐 Network proxy: filtering on port ${proxyManager.getPort()} (${sandboxConfig.network.allowedDomains.length} domains)`);
|
|
6185
|
+
}
|
|
6186
|
+
}
|
|
6187
|
+
return { sandboxOrchestrator };
|
|
6188
|
+
}
|
|
6189
|
+
//#endregion
|
|
6190
|
+
//#region src/bootstrap/buildSupportingStores.ts
|
|
6191
|
+
/**
|
|
6192
|
+
* Build the supporting stores and orchestration the agent needs: CLI tools
|
|
6193
|
+
* (permission-wrapped + server-routed), MCP manager, agent store, context
|
|
6194
|
+
* files, the deferred-tool registry partition, the subagent orchestrator, and
|
|
6195
|
+
* the background-agent manager.
|
|
6196
|
+
*
|
|
6197
|
+
* Pure bootstrap seam — no React hooks, no Zustand state. React-owned values
|
|
6198
|
+
* (permission/user-question prompt functions, the agent context, and the
|
|
6199
|
+
* background-agent status callbacks) are passed in. Tool *assembly* that weaves
|
|
6200
|
+
* the React workflow-store refs (decision/blocker/review-gate tools) stays in
|
|
6201
|
+
* the shell; this module returns only the agent-construction materials.
|
|
6202
|
+
*/
|
|
6203
|
+
async function buildSupportingStores(input) {
|
|
6204
|
+
const { config, llm, modelId, permissionManager, apiClient, configStore, customCommandStore, checkpointStore, sandboxOrchestrator, additionalDirectories, agentContext, promptFn, userQuestionFn, startupLog, silentLogger, onBackgroundStatusChange, onGroupCompletion } = input;
|
|
6205
|
+
const { tools: b4mTools } = await generateCliTools(config.userId, llm, modelId, permissionManager, promptFn, agentContext, configStore, apiClient, void 0, userQuestionFn, checkpointStore, sandboxOrchestrator, additionalDirectories);
|
|
6206
|
+
const mcpManager = new McpManager(config);
|
|
6207
|
+
const builtinAgentsDir = new URL("../agents/defaults/", import.meta.url).pathname;
|
|
6208
|
+
const agentProjectDir = configStore.getProjectConfigDir();
|
|
6209
|
+
const agentStore = new AgentStore(builtinAgentsDir, agentProjectDir || process.cwd());
|
|
6210
|
+
const [, , contextResult] = await Promise.all([
|
|
6211
|
+
mcpManager.initialize(),
|
|
6212
|
+
agentStore.loadAgents(),
|
|
6213
|
+
loadContextFiles(agentProjectDir)
|
|
6214
|
+
]);
|
|
6215
|
+
const mcpTools = mcpManager.getTools();
|
|
6216
|
+
const deferredB4mToolNames = new Set([
|
|
6217
|
+
"math_evaluate",
|
|
6218
|
+
"dice_roll",
|
|
6219
|
+
"current_datetime",
|
|
6220
|
+
"recent_changes",
|
|
6221
|
+
"prompt_enhancement"
|
|
6222
|
+
]);
|
|
6223
|
+
const deferredB4mTools = b4mTools.filter((t) => deferredB4mToolNames.has(t.toolSchema.name));
|
|
6224
|
+
const loadedB4mTools = b4mTools.filter((t) => !deferredB4mToolNames.has(t.toolSchema.name));
|
|
6225
|
+
deferredToolRegistry.register([...mcpTools, ...deferredB4mTools]);
|
|
6226
|
+
if (mcpTools.length > 0) {
|
|
6227
|
+
const serverSummaries = mcpManager.getToolCount().map((s) => `${s.serverName} (${s.count})`).join(", ");
|
|
6228
|
+
startupLog.push(`🛠️ Loaded ${loadedB4mTools.length} B4M + ${mcpTools.length} MCP tool(s, ${deferredB4mTools.length + mcpTools.length} deferred): ${serverSummaries}`);
|
|
6229
|
+
} else {
|
|
6230
|
+
const suffix = deferredB4mTools.length > 0 ? ` (${deferredB4mTools.length} deferred)` : "";
|
|
6231
|
+
startupLog.push(`🛠️ Loaded ${loadedB4mTools.length} B4M tool(s)${suffix}, no MCP tools`);
|
|
6232
|
+
}
|
|
6233
|
+
const agentSummary = agentStore.getSummary();
|
|
6234
|
+
startupLog.push(`🤖 Loaded ${agentSummary.total} agent(s): ${agentSummary.builtin} built-in, ${agentSummary.global} global, ${agentSummary.project} project`);
|
|
6235
|
+
const orchestrator = new SubagentOrchestrator({
|
|
6236
|
+
userId: config.userId,
|
|
6237
|
+
llm,
|
|
6238
|
+
logger: silentLogger,
|
|
6239
|
+
permissionManager,
|
|
6240
|
+
showPermissionPrompt: promptFn,
|
|
6241
|
+
configStore,
|
|
6242
|
+
apiClient,
|
|
6243
|
+
agentStore,
|
|
6244
|
+
customCommandStore,
|
|
6245
|
+
enableParallelToolExecution: config.preferences.enableParallelToolExecution === true,
|
|
6246
|
+
showUserQuestion: userQuestionFn,
|
|
6247
|
+
checkpointStore
|
|
6248
|
+
});
|
|
6249
|
+
const backgroundManager = new BackgroundAgentManager(orchestrator);
|
|
6250
|
+
backgroundManager.setOnStatusChange(onBackgroundStatusChange);
|
|
6251
|
+
backgroundManager.setOnGroupCompletion(onGroupCompletion);
|
|
6252
|
+
return {
|
|
6253
|
+
mcpManager,
|
|
6254
|
+
agentStore,
|
|
6255
|
+
contextResult,
|
|
6256
|
+
mcpTools,
|
|
6257
|
+
loadedB4mTools,
|
|
6258
|
+
deferredB4mTools,
|
|
6259
|
+
orchestrator,
|
|
6260
|
+
backgroundManager
|
|
6261
|
+
};
|
|
6262
|
+
}
|
|
6263
|
+
//#endregion
|
|
6264
|
+
//#region src/bootstrap/buildAgent.ts
|
|
6265
|
+
/**
|
|
6266
|
+
* Construct the main ReAct agent with the system prompt selected by config
|
|
6267
|
+
* variant, wire the tool_search closure to the agent's live tools array, and
|
|
6268
|
+
* record the agent in the shared observation context.
|
|
6269
|
+
*
|
|
6270
|
+
* Pure bootstrap seam — no React hooks, no Zustand state. The interaction-mode
|
|
6271
|
+
* subscription (`useCliStore.subscribe`) stays in the shell and uses the
|
|
6272
|
+
* returned `buildPromptForMode`. Ordering is load-bearing: agent built →
|
|
6273
|
+
* agentToolsRef wired → agentContext.currentAgent set, all here, before the
|
|
6274
|
+
* shell registers the subscription (which guards on currentAgent === agent).
|
|
6275
|
+
*/
|
|
6276
|
+
function buildAgent(input) {
|
|
6277
|
+
const { config, modelId, notifyingLlm, allTools, agentContext, agentToolsRef, silentLogger, sessionId, initialInteractionMode, contextContent, agentStore, customCommandStore, enableSkillTool, additionalDirectories, featureModulePrompts } = input;
|
|
6278
|
+
const promptVariant = config.preferences.promptVariant ?? "current";
|
|
6279
|
+
const buildPromptForMode = (mode) => buildSystemPrompt(promptVariant, {
|
|
6280
|
+
contextContent,
|
|
6281
|
+
agentStore,
|
|
6282
|
+
customCommands: customCommandStore.getAllCommands(),
|
|
6283
|
+
enableSkillTool,
|
|
6284
|
+
enableDynamicAgentCreation: config.preferences.enableDynamicAgentCreation === true,
|
|
6285
|
+
additionalDirectories,
|
|
6286
|
+
featureModulePrompts: featureModulePrompts || void 0,
|
|
6287
|
+
planModeFilePath: mode === "plan" ? getPlanModeFilePath(sessionId) : void 0,
|
|
6288
|
+
appendSystemPrompt: process.env.B4M_APPEND_SYSTEM_PROMPT,
|
|
6289
|
+
deferredToolNames: deferredToolRegistry.getDirectoryNames()
|
|
6290
|
+
});
|
|
6291
|
+
const cliSystemPrompt = buildPromptForMode(initialInteractionMode);
|
|
6292
|
+
const maxIterations = config.preferences.maxIterations === null ? 999999 : config.preferences.maxIterations;
|
|
6293
|
+
const agent = new ReActAgent({
|
|
6294
|
+
userId: config.userId,
|
|
6295
|
+
logger: silentLogger,
|
|
6296
|
+
llm: notifyingLlm,
|
|
6297
|
+
model: modelId,
|
|
6298
|
+
tools: allTools,
|
|
6299
|
+
maxIterations,
|
|
6300
|
+
maxTokens: config.preferences.maxTokens,
|
|
6301
|
+
temperature: config.preferences.temperature,
|
|
6302
|
+
systemPrompt: cliSystemPrompt,
|
|
6303
|
+
unknownToolResolver: async (toolName) => deferredToolRegistry.get(toolName) ?? null
|
|
6304
|
+
});
|
|
6305
|
+
agentToolsRef.current = agent.getTools();
|
|
6306
|
+
agentContext.currentAgent = agent;
|
|
6307
|
+
return {
|
|
6308
|
+
agent,
|
|
6309
|
+
buildPromptForMode
|
|
6310
|
+
};
|
|
6311
|
+
}
|
|
6312
|
+
//#endregion
|
|
6313
|
+
//#region src/bootstrap/wireAgentEvents.ts
|
|
6314
|
+
/**
|
|
6315
|
+
* Wire the main agent's step events to the UI store and the tavern transcript,
|
|
6316
|
+
* and mirror the same handlers onto delegated subagents via the orchestrator's
|
|
6317
|
+
* before/after-run callbacks.
|
|
6318
|
+
*
|
|
6319
|
+
* Bootstrap seam: no React hooks, no useRef, no subscriptions. It reads/writes
|
|
6320
|
+
* the Zustand store via `getState()` (the same runtime-access pattern the agent
|
|
6321
|
+
* step handlers already used) and emits tavern events through the bridge
|
|
6322
|
+
* singleton — neither owns React-lifecycle state.
|
|
6323
|
+
*/
|
|
6324
|
+
function wireAgentEvents(input) {
|
|
6325
|
+
const { agent, agentContext, orchestrator } = input;
|
|
6326
|
+
agent.observationQueue = agentContext.observationQueue;
|
|
6327
|
+
const stepHandler = (step) => {
|
|
6328
|
+
const { pendingMessages, updatePendingMessage } = useCliStore.getState();
|
|
6329
|
+
const lastIdx = pendingMessages.length - 1;
|
|
6330
|
+
if (lastIdx >= 0 && pendingMessages[lastIdx].role === "assistant") {
|
|
6331
|
+
const existingSteps = pendingMessages[lastIdx].metadata?.steps || [];
|
|
6332
|
+
const formattedStep = formatStep(step);
|
|
6333
|
+
updatePendingMessage(lastIdx, {
|
|
6334
|
+
...pendingMessages[lastIdx],
|
|
6335
|
+
metadata: {
|
|
6336
|
+
...pendingMessages[lastIdx].metadata,
|
|
6337
|
+
steps: [...existingSteps, formattedStep]
|
|
6338
|
+
}
|
|
6339
|
+
});
|
|
6340
|
+
}
|
|
6341
|
+
};
|
|
6342
|
+
agent.on("thought", stepHandler);
|
|
6343
|
+
agent.on("observation", stepHandler);
|
|
6344
|
+
agent.on("action", stepHandler);
|
|
6345
|
+
const pendingToolUseIds = /* @__PURE__ */ new Map();
|
|
6346
|
+
const summarizeToolInput = (input) => {
|
|
6347
|
+
if (input == null) return void 0;
|
|
6348
|
+
if (typeof input === "string") return input.slice(0, 240);
|
|
5859
6349
|
try {
|
|
5860
|
-
|
|
5861
|
-
} catch
|
|
5862
|
-
logger.debug(`[tavern] command WS construct failed: ${err.message}`);
|
|
5863
|
-
this.scheduleReconnect();
|
|
6350
|
+
return JSON.stringify(input).slice(0, 240);
|
|
6351
|
+
} catch {
|
|
5864
6352
|
return;
|
|
5865
6353
|
}
|
|
5866
|
-
|
|
5867
|
-
|
|
5868
|
-
|
|
5869
|
-
|
|
6354
|
+
};
|
|
6355
|
+
const tavernActionHandler = (step) => {
|
|
6356
|
+
const toolName = step.metadata?.toolName ?? "tool";
|
|
6357
|
+
const toolUseId = `${toolName}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
6358
|
+
const queue = pendingToolUseIds.get(toolName) ?? [];
|
|
6359
|
+
queue.push(toolUseId);
|
|
6360
|
+
pendingToolUseIds.set(toolName, queue);
|
|
6361
|
+
bridgePresence.emitEvent({
|
|
6362
|
+
type: "tool_use",
|
|
6363
|
+
tool: toolName,
|
|
6364
|
+
toolUseId,
|
|
6365
|
+
text: summarizeToolInput(step.metadata?.toolInput)
|
|
5870
6366
|
});
|
|
5871
|
-
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
|
|
6367
|
+
};
|
|
6368
|
+
const tavernObservationHandler = (step) => {
|
|
6369
|
+
const toolName = step.metadata?.toolName;
|
|
6370
|
+
let toolUseId;
|
|
6371
|
+
if (toolName) {
|
|
6372
|
+
const queue = pendingToolUseIds.get(toolName);
|
|
6373
|
+
if (queue && queue.length > 0) {
|
|
6374
|
+
toolUseId = queue.shift();
|
|
6375
|
+
if (queue.length === 0) pendingToolUseIds.delete(toolName);
|
|
6376
|
+
}
|
|
6377
|
+
}
|
|
6378
|
+
if (!toolUseId) {
|
|
6379
|
+
for (const [name, queue] of pendingToolUseIds.entries()) if (queue.length > 0) {
|
|
6380
|
+
toolUseId = queue.shift();
|
|
6381
|
+
if (queue.length === 0) pendingToolUseIds.delete(name);
|
|
6382
|
+
break;
|
|
5878
6383
|
}
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
5886
|
-
this.scheduleReconnect();
|
|
6384
|
+
}
|
|
6385
|
+
if (!toolUseId) toolUseId = `orphan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
6386
|
+
bridgePresence.emitEvent({
|
|
6387
|
+
type: "tool_result",
|
|
6388
|
+
tool: toolName,
|
|
6389
|
+
toolUseId,
|
|
6390
|
+
text: typeof step.content === "string" ? step.content.slice(0, 4e3) : void 0
|
|
5887
6391
|
});
|
|
5888
|
-
|
|
5889
|
-
|
|
6392
|
+
};
|
|
6393
|
+
const tavernFinalAnswerHandler = (step) => {
|
|
6394
|
+
const text = typeof step.content === "string" ? step.content : "";
|
|
6395
|
+
if (!text) return;
|
|
6396
|
+
bridgePresence.emitEvent({
|
|
6397
|
+
type: "message",
|
|
6398
|
+
role: "assistant",
|
|
6399
|
+
text: text.slice(0, 4e3)
|
|
5890
6400
|
});
|
|
5891
|
-
}
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
|
|
5909
|
-
|
|
5910
|
-
|
|
5911
|
-
|
|
5912
|
-
}
|
|
5913
|
-
}
|
|
5914
|
-
};
|
|
5915
|
-
/** Process-wide singleton — the CLI only ever has one tavern presence per run. */
|
|
5916
|
-
const bridgePresence = new BridgePresence();
|
|
6401
|
+
};
|
|
6402
|
+
agent.on("action", tavernActionHandler);
|
|
6403
|
+
agent.on("observation", tavernObservationHandler);
|
|
6404
|
+
agent.on("final_answer", tavernFinalAnswerHandler);
|
|
6405
|
+
orchestrator.setBeforeRunCallback((subagent, _subagentType) => {
|
|
6406
|
+
subagent.on("thought", stepHandler);
|
|
6407
|
+
subagent.on("observation", stepHandler);
|
|
6408
|
+
subagent.on("action", stepHandler);
|
|
6409
|
+
subagent.on("action", tavernActionHandler);
|
|
6410
|
+
subagent.on("observation", tavernObservationHandler);
|
|
6411
|
+
subagent.on("final_answer", tavernFinalAnswerHandler);
|
|
6412
|
+
});
|
|
6413
|
+
orchestrator.setAfterRunCallback((subagent, _subagentType) => {
|
|
6414
|
+
subagent.off("thought", stepHandler);
|
|
6415
|
+
subagent.off("observation", stepHandler);
|
|
6416
|
+
subagent.off("action", stepHandler);
|
|
6417
|
+
subagent.off("action", tavernActionHandler);
|
|
6418
|
+
subagent.off("observation", tavernObservationHandler);
|
|
6419
|
+
subagent.off("final_answer", tavernFinalAnswerHandler);
|
|
6420
|
+
});
|
|
6421
|
+
}
|
|
5917
6422
|
//#endregion
|
|
5918
6423
|
//#region src/index.tsx
|
|
5919
6424
|
process.removeAllListeners("warning");
|
|
@@ -5922,15 +6427,6 @@ process.on("warning", (warning) => {
|
|
|
5922
6427
|
console.warn(warning);
|
|
5923
6428
|
});
|
|
5924
6429
|
/**
|
|
5925
|
-
* Get a configured JupyterClient or throw an error if not configured.
|
|
5926
|
-
* Centralizes the Jupyter configuration check for all Jupyter Keep commands.
|
|
5927
|
-
*/
|
|
5928
|
-
function getRequiredJupyterClient() {
|
|
5929
|
-
const client = createJupyterClientFromEnv();
|
|
5930
|
-
if (!client) throw new Error("Jupyter not configured. Set JUPYTER_SERVER_URL and optionally JUPYTER_TOKEN environment variables.");
|
|
5931
|
-
return client;
|
|
5932
|
-
}
|
|
5933
|
-
/**
|
|
5934
6430
|
* Render the first question from a UserQuestion payload as a 240-char-capped
|
|
5935
6431
|
* summary for tavern status events. Returns undefined if the payload has no
|
|
5936
6432
|
* questions (defensive: payload.questions[0]?.question is typed string but
|
|
@@ -6171,162 +6667,23 @@ function CliApp() {
|
|
|
6171
6667
|
const envName = getEnvironmentName(config.apiConfig);
|
|
6172
6668
|
startupLog.unshift(`🌍 API Environment: ${envName} (${apiBaseURL})`);
|
|
6173
6669
|
const apiClient = new ApiClient(apiBaseURL, state.configStore);
|
|
6670
|
+
if (process.env.B4M_NO_REMOTE_SKILLS !== "1" && config.preferences.enableRemoteSkills !== false) try {
|
|
6671
|
+
state.customCommandStore.setRemoteSource(new RemoteSkillSource(apiClient));
|
|
6672
|
+
await state.customCommandStore.mergeRemoteCommands();
|
|
6673
|
+
const remoteCount = state.customCommandStore.getCommandsBySource("remote").length;
|
|
6674
|
+
if (remoteCount > 0) startupLog.push(`☁️ Synced ${remoteCount} skill${remoteCount === 1 ? "" : "s"} from B4M web`);
|
|
6675
|
+
} catch (error) {
|
|
6676
|
+
console.warn("Failed to sync remote skills:", error instanceof Error ? error.message : String(error));
|
|
6677
|
+
}
|
|
6174
6678
|
const tokenGetter = async () => {
|
|
6175
6679
|
return (await state.configStore.getAuthTokens())?.accessToken ?? null;
|
|
6176
6680
|
};
|
|
6177
|
-
|
|
6178
|
-
|
|
6179
|
-
|
|
6180
|
-
|
|
6181
|
-
|
|
6182
|
-
|
|
6183
|
-
const wsCompletionUrl = serverConfig?.wsCompletionUrl;
|
|
6184
|
-
completionsUrl = serverConfig?.completionsUrl;
|
|
6185
|
-
if (wsUrl && wsCompletionUrl) {
|
|
6186
|
-
wsManager = new WebSocketConnectionManager(wsUrl, tokenGetter);
|
|
6187
|
-
await wsManager.connect();
|
|
6188
|
-
setWebSocketToolExecutor(new WebSocketToolExecutor(wsManager, tokenGetter));
|
|
6189
|
-
llm = new WebSocketLlmBackend({
|
|
6190
|
-
wsManager,
|
|
6191
|
-
apiClient,
|
|
6192
|
-
model: config.defaultModel,
|
|
6193
|
-
tokenGetter,
|
|
6194
|
-
wsCompletionUrl
|
|
6195
|
-
});
|
|
6196
|
-
wsManager.onAction("keep_command", async (message) => {
|
|
6197
|
-
const { commandType, params, requestId, originConnectionId } = message;
|
|
6198
|
-
let result;
|
|
6199
|
-
let success = true;
|
|
6200
|
-
let error;
|
|
6201
|
-
try {
|
|
6202
|
-
switch (commandType) {
|
|
6203
|
-
case "read_file": {
|
|
6204
|
-
const filePath = params.path;
|
|
6205
|
-
if (!filePath) throw new Error("Missing required param: path");
|
|
6206
|
-
logger.info(`[Keep] Reading file: ${filePath}`);
|
|
6207
|
-
result = {
|
|
6208
|
-
content: await promises.readFile(filePath, "utf-8"),
|
|
6209
|
-
path: filePath
|
|
6210
|
-
};
|
|
6211
|
-
break;
|
|
6212
|
-
}
|
|
6213
|
-
case "list_directory": {
|
|
6214
|
-
const dirPath = params.path || ".";
|
|
6215
|
-
logger.info(`[Keep] Listing directory: ${dirPath}`);
|
|
6216
|
-
result = (await promises.readdir(dirPath, { withFileTypes: true })).map((e) => ({
|
|
6217
|
-
name: e.name,
|
|
6218
|
-
isDirectory: e.isDirectory()
|
|
6219
|
-
}));
|
|
6220
|
-
break;
|
|
6221
|
-
}
|
|
6222
|
-
case "jupyter_get_kernelspecs":
|
|
6223
|
-
logger.info("[Keep] Getting Jupyter kernel specs");
|
|
6224
|
-
result = await getRequiredJupyterClient().getKernelSpecs();
|
|
6225
|
-
break;
|
|
6226
|
-
case "jupyter_start_kernel": {
|
|
6227
|
-
const notebookPath = params.notebookPath;
|
|
6228
|
-
const kernelName = params.kernelName;
|
|
6229
|
-
logger.info(`[Keep] Starting Jupyter kernel for: ${notebookPath}`);
|
|
6230
|
-
result = await getRequiredJupyterClient().startSession(notebookPath, kernelName);
|
|
6231
|
-
break;
|
|
6232
|
-
}
|
|
6233
|
-
case "jupyter_stop_kernel": {
|
|
6234
|
-
const sessionId = params.sessionId;
|
|
6235
|
-
if (!sessionId) throw new Error("Missing required param: sessionId");
|
|
6236
|
-
logger.info(`[Keep] Stopping Jupyter session: ${sessionId}`);
|
|
6237
|
-
await getRequiredJupyterClient().stopSession(sessionId);
|
|
6238
|
-
result = {
|
|
6239
|
-
success: true,
|
|
6240
|
-
sessionId
|
|
6241
|
-
};
|
|
6242
|
-
break;
|
|
6243
|
-
}
|
|
6244
|
-
case "jupyter_execute_cell": {
|
|
6245
|
-
const code = params.code;
|
|
6246
|
-
const kernelId = params.kernelId;
|
|
6247
|
-
const timeoutMs = params.timeoutMs || 3e4;
|
|
6248
|
-
if (!code) throw new Error("Missing required param: code");
|
|
6249
|
-
if (!kernelId) throw new Error("Missing required param: kernelId");
|
|
6250
|
-
logger.info(`[Keep] Executing cell (kernel: ${kernelId}, code length: ${code.length})`);
|
|
6251
|
-
const cellResult = await getRequiredJupyterClient().executeCell(kernelId, code, timeoutMs);
|
|
6252
|
-
logger.info(`[Keep] Cell execution ${cellResult.success ? "succeeded" : "failed"} (outputs: ${cellResult.outputs.length}, execution_count: ${cellResult.executionCount})`);
|
|
6253
|
-
result = {
|
|
6254
|
-
success: cellResult.success,
|
|
6255
|
-
outputs: cellResult.outputs,
|
|
6256
|
-
executionCount: cellResult.executionCount,
|
|
6257
|
-
error: cellResult.error
|
|
6258
|
-
};
|
|
6259
|
-
break;
|
|
6260
|
-
}
|
|
6261
|
-
case "jupyter_execute_notebook":
|
|
6262
|
-
result = await executeNotebook(params, {
|
|
6263
|
-
jupyterClient: getRequiredJupyterClient(),
|
|
6264
|
-
wsManager,
|
|
6265
|
-
logger,
|
|
6266
|
-
requestId
|
|
6267
|
-
});
|
|
6268
|
-
break;
|
|
6269
|
-
default: throw new Error(`Unknown command type: ${commandType}`);
|
|
6270
|
-
}
|
|
6271
|
-
} catch (err) {
|
|
6272
|
-
success = false;
|
|
6273
|
-
error = err instanceof Error ? err.message : String(err);
|
|
6274
|
-
}
|
|
6275
|
-
logger.info(`[Keep] Sending response: success=${success}, originConnectionId=${originConnectionId?.slice(0, 12)}..., resultSize=${JSON.stringify(result)?.length ?? 0}`);
|
|
6276
|
-
try {
|
|
6277
|
-
wsManager.send({
|
|
6278
|
-
action: "keep_command_response",
|
|
6279
|
-
requestId,
|
|
6280
|
-
originConnectionId,
|
|
6281
|
-
success,
|
|
6282
|
-
result,
|
|
6283
|
-
error
|
|
6284
|
-
});
|
|
6285
|
-
logger.info("[Keep] Response sent successfully");
|
|
6286
|
-
} catch (sendErr) {
|
|
6287
|
-
logger.info(`[Keep] Failed to send response: ${sendErr instanceof Error ? sendErr.message : String(sendErr)}`);
|
|
6288
|
-
}
|
|
6289
|
-
});
|
|
6290
|
-
logger.debug("🔌 Using WebSocket transport (bypasses CloudFront timeout)");
|
|
6291
|
-
} else throw new Error("No websocketUrl or wsCompletionUrl in server config");
|
|
6292
|
-
} catch (wsError) {
|
|
6293
|
-
logger.debug("⚠️ WebSocket unavailable, using SSE fallback");
|
|
6294
|
-
logger.debug(`[WebSocket] Fallback reason: ${wsError instanceof Error ? wsError.message : String(wsError)}`);
|
|
6295
|
-
if (wsError instanceof Error && wsError.stack) logger.debug(`[WebSocket] Stack: ${wsError.stack}`);
|
|
6296
|
-
wsManager = null;
|
|
6297
|
-
setWebSocketToolExecutor(null);
|
|
6298
|
-
llm = new ServerLlmBackend({
|
|
6299
|
-
apiClient,
|
|
6300
|
-
model: config.defaultModel,
|
|
6301
|
-
completionsUrl
|
|
6302
|
-
});
|
|
6303
|
-
}
|
|
6304
|
-
const ollamaHost = process.env.B4M_OLLAMA_HOST;
|
|
6305
|
-
let models;
|
|
6306
|
-
if (ollamaHost) {
|
|
6307
|
-
const ollamaBackend = new OllamaBackend(ollamaHost, {
|
|
6308
|
-
debug: (...args) => logger.debug(args.map(String).join(" ")),
|
|
6309
|
-
info: (...args) => logger.info(args.map(String).join(" ")),
|
|
6310
|
-
warn: (...args) => logger.warn(args.map(String).join(" ")),
|
|
6311
|
-
error: (...args) => logger.error(args.map(String).join(" "))
|
|
6312
|
-
});
|
|
6313
|
-
const [serverModels, ollamaModels] = await Promise.all([llm.getModelInfo(), ollamaBackend.getModelInfo()]);
|
|
6314
|
-
if (serverModels.length === 0 && ollamaModels.length === 0) throw new Error(`No models available from server or Ollama at ${ollamaHost}.\nPull a model: ollama pull qwen3.5`);
|
|
6315
|
-
if (ollamaModels.length === 0) startupLog.push(`⚠️ No models found in Ollama at ${ollamaHost}. Pull one with: ollama pull qwen3.5`);
|
|
6316
|
-
llm = new MultiLlmBackend(llm, ollamaBackend, serverModels, ollamaModels, config.defaultModel);
|
|
6317
|
-
models = await llm.getModelInfo();
|
|
6318
|
-
startupLog.push(`🦙 Self-hosted Ollama: ${ollamaModels.length} model(s) added to picker`);
|
|
6319
|
-
} else {
|
|
6320
|
-
models = await llm.getModelInfo();
|
|
6321
|
-
if (models.length === 0) throw new Error("No models available from server.");
|
|
6322
|
-
}
|
|
6323
|
-
logger.debug(`📋 Available models: ${models.map((m) => m.id).join(", ")}`);
|
|
6324
|
-
const modelInfo = models.find((m) => m.id === config.defaultModel) || models[0];
|
|
6325
|
-
if (modelInfo.id !== config.defaultModel) {
|
|
6326
|
-
logger.warn(`⚠️ Requested model '${config.defaultModel}' not available`);
|
|
6327
|
-
logger.warn(`🤖 Using fallback model: ${modelInfo.id}`);
|
|
6328
|
-
}
|
|
6329
|
-
llm.currentModel = modelInfo.id;
|
|
6681
|
+
const { llm, wsManager, models, modelInfo } = await buildLlmBackend({
|
|
6682
|
+
config,
|
|
6683
|
+
apiClient,
|
|
6684
|
+
tokenGetter,
|
|
6685
|
+
startupLog
|
|
6686
|
+
});
|
|
6330
6687
|
const pinnedSessionId = process.env.B4M_SESSION_ID;
|
|
6331
6688
|
const resumeSessionId = process.env.B4M_RESUME_ID;
|
|
6332
6689
|
let newSession;
|
|
@@ -6362,43 +6719,13 @@ function CliApp() {
|
|
|
6362
6719
|
debug: () => {}
|
|
6363
6720
|
};
|
|
6364
6721
|
const permissionManager = new PermissionManager(config.trustedTools || [], void 0, config.tools.disabled || []);
|
|
6365
|
-
const [{ createSandboxRuntime }, { SandboxOrchestrator }, { DEFAULT_SANDBOX_CONFIG }, { ProxyManager }, { ViolationLogStore }] = await Promise.all([
|
|
6366
|
-
import("./SandboxRuntimeAdapter-CKelGICD.mjs"),
|
|
6367
|
-
import("./SandboxOrchestrator-BS6gALNq.mjs"),
|
|
6368
|
-
import("./types-CqscS34o.mjs"),
|
|
6369
|
-
import("./ProxyManager-ByuAHFMq.mjs"),
|
|
6370
|
-
import("./ViolationLogStore-B-plqJfn.mjs")
|
|
6371
|
-
]);
|
|
6372
|
-
const sandboxConfig = config.sandbox ?? DEFAULT_SANDBOX_CONFIG;
|
|
6373
6722
|
const checkpointStore = new CheckpointStore(state.configStore.getProjectConfigDir() || process.cwd());
|
|
6374
|
-
const
|
|
6375
|
-
|
|
6376
|
-
|
|
6377
|
-
|
|
6378
|
-
|
|
6379
|
-
console.error(`\n\x1b[41m\x1b[97m BLOCKED \x1b[0m \x1b[31mNetwork proxy denied connection to\x1b[0m \x1b[1m${event.domain}\x1b[0m \x1b[90m(${event.method})\x1b[0m`);
|
|
6380
|
-
console.error(`\x1b[90m Tip: /sandbox:trust-domain ${event.domain}\x1b[0m\n`);
|
|
6381
|
-
sandboxOrchestrator.recordViolation({
|
|
6382
|
-
type: "network",
|
|
6383
|
-
domain: event.domain,
|
|
6384
|
-
command: `[network] ${event.method} ${event.domain}`,
|
|
6385
|
-
blockedBy: "proxy",
|
|
6386
|
-
timestamp: event.timestamp,
|
|
6387
|
-
detail: `Blocked ${event.method} to ${event.domain}`
|
|
6388
|
-
}).catch(() => {});
|
|
6389
|
-
}
|
|
6723
|
+
const { sandboxOrchestrator } = await buildSandbox({
|
|
6724
|
+
config,
|
|
6725
|
+
sessionId: newSession.id,
|
|
6726
|
+
permissionManager,
|
|
6727
|
+
checkpointStore
|
|
6390
6728
|
});
|
|
6391
|
-
const violationStore = new ViolationLogStore();
|
|
6392
|
-
sandboxOrchestrator.setViolationStore(violationStore);
|
|
6393
|
-
permissionManager.setSandboxState(sandboxConfig.mode, sandboxOrchestrator.isActive());
|
|
6394
|
-
if (sandboxConfig.enabled && sandboxConfig.mode !== "disabled") {
|
|
6395
|
-
if (sandboxRuntime) console.log(`🔒 Sandbox: ${sandboxConfig.mode} (${sandboxRuntime.name})`);
|
|
6396
|
-
else console.log("⚠️ Sandbox: enabled but runtime not available on this platform");
|
|
6397
|
-
if (sandboxConfig.network.enabled) {
|
|
6398
|
-
await proxyManager.start();
|
|
6399
|
-
if (proxyManager.isRunning()) console.log(`🌐 Network proxy: filtering on port ${proxyManager.getPort()} (${sandboxConfig.network.allowedDomains.length} domains)`);
|
|
6400
|
-
}
|
|
6401
|
-
}
|
|
6402
6729
|
let permissionPromptCounter = 0;
|
|
6403
6730
|
const promptFn = (toolName, args, preview) => {
|
|
6404
6731
|
return new Promise((resolve) => {
|
|
@@ -6473,57 +6800,29 @@ function CliApp() {
|
|
|
6473
6800
|
currentAgent: null,
|
|
6474
6801
|
observationQueue: []
|
|
6475
6802
|
};
|
|
6476
|
-
const {
|
|
6477
|
-
|
|
6478
|
-
const builtinAgentsDir = new URL("./agents/defaults/", import.meta.url).pathname;
|
|
6479
|
-
const agentProjectDir = state.configStore.getProjectConfigDir();
|
|
6480
|
-
const agentStore = new AgentStore(builtinAgentsDir, agentProjectDir || process.cwd());
|
|
6481
|
-
const [, , contextResult] = await Promise.all([
|
|
6482
|
-
mcpManager.initialize(),
|
|
6483
|
-
agentStore.loadAgents(),
|
|
6484
|
-
loadContextFiles(agentProjectDir)
|
|
6485
|
-
]);
|
|
6486
|
-
const mcpTools = mcpManager.getTools();
|
|
6487
|
-
const deferredB4mToolNames = new Set([
|
|
6488
|
-
"math_evaluate",
|
|
6489
|
-
"dice_roll",
|
|
6490
|
-
"current_datetime",
|
|
6491
|
-
"recent_changes",
|
|
6492
|
-
"prompt_enhancement"
|
|
6493
|
-
]);
|
|
6494
|
-
const deferredB4mTools = b4mTools.filter((t) => deferredB4mToolNames.has(t.toolSchema.name));
|
|
6495
|
-
const loadedB4mTools = b4mTools.filter((t) => !deferredB4mToolNames.has(t.toolSchema.name));
|
|
6496
|
-
deferredToolRegistry.register([...mcpTools, ...deferredB4mTools]);
|
|
6497
|
-
if (mcpTools.length > 0) {
|
|
6498
|
-
const serverSummaries = mcpManager.getToolCount().map((s) => `${s.serverName} (${s.count})`).join(", ");
|
|
6499
|
-
startupLog.push(`🛠️ Loaded ${loadedB4mTools.length} B4M + ${mcpTools.length} MCP tool(s, ${deferredB4mTools.length + mcpTools.length} deferred): ${serverSummaries}`);
|
|
6500
|
-
} else {
|
|
6501
|
-
const suffix = deferredB4mTools.length > 0 ? ` (${deferredB4mTools.length} deferred)` : "";
|
|
6502
|
-
startupLog.push(`🛠️ Loaded ${loadedB4mTools.length} B4M tool(s)${suffix}, no MCP tools`);
|
|
6503
|
-
}
|
|
6504
|
-
const agentSummary = agentStore.getSummary();
|
|
6505
|
-
startupLog.push(`🤖 Loaded ${agentSummary.total} agent(s): ${agentSummary.builtin} built-in, ${agentSummary.global} global, ${agentSummary.project} project`);
|
|
6506
|
-
const orchestrator = new SubagentOrchestrator({
|
|
6507
|
-
userId: config.userId,
|
|
6803
|
+
const { mcpManager, agentStore, contextResult, mcpTools, loadedB4mTools, deferredB4mTools, orchestrator, backgroundManager } = await buildSupportingStores({
|
|
6804
|
+
config,
|
|
6508
6805
|
llm,
|
|
6509
|
-
|
|
6806
|
+
modelId: modelInfo.id,
|
|
6510
6807
|
permissionManager,
|
|
6511
|
-
showPermissionPrompt: promptFn,
|
|
6512
|
-
configStore: state.configStore,
|
|
6513
6808
|
apiClient,
|
|
6514
|
-
|
|
6809
|
+
configStore: state.configStore,
|
|
6515
6810
|
customCommandStore: state.customCommandStore,
|
|
6516
|
-
|
|
6517
|
-
|
|
6518
|
-
|
|
6519
|
-
|
|
6520
|
-
|
|
6521
|
-
|
|
6522
|
-
|
|
6523
|
-
|
|
6524
|
-
|
|
6525
|
-
|
|
6526
|
-
|
|
6811
|
+
checkpointStore,
|
|
6812
|
+
sandboxOrchestrator,
|
|
6813
|
+
additionalDirectories,
|
|
6814
|
+
agentContext,
|
|
6815
|
+
promptFn,
|
|
6816
|
+
userQuestionFn,
|
|
6817
|
+
startupLog,
|
|
6818
|
+
silentLogger,
|
|
6819
|
+
onBackgroundStatusChange: (job) => {
|
|
6820
|
+
useCliStore.getState().upsertBackgroundAgent(job);
|
|
6821
|
+
},
|
|
6822
|
+
onGroupCompletion: (notification, groupDescription) => {
|
|
6823
|
+
useCliStore.getState().addCompletedGroupNotification(notification, groupDescription);
|
|
6824
|
+
useCliStore.getState().setPendingBackgroundTrigger(true);
|
|
6825
|
+
}
|
|
6527
6826
|
});
|
|
6528
6827
|
const agentDelegateTool = createAgentDelegateTool(orchestrator, agentStore, newSession.id, backgroundManager);
|
|
6529
6828
|
const dynamicAgentTool = config.preferences.enableDynamicAgentCreation === true ? createDynamicAgentTool(orchestrator, newSession.id, backgroundManager) : null;
|
|
@@ -6600,36 +6899,23 @@ function CliApp() {
|
|
|
6600
6899
|
if (contextResult.globalContext) startupLog.push(`📄 Global context: ${contextResult.globalContext.filename}`);
|
|
6601
6900
|
if (contextResult.projectContext) startupLog.push(`📄 Project context: ${contextResult.projectContext.filename}`);
|
|
6602
6901
|
for (const error of contextResult.errors) startupLog.push(`⚠️ Context file error: ${error}`);
|
|
6603
|
-
const
|
|
6604
|
-
|
|
6605
|
-
|
|
6902
|
+
const { agent, buildPromptForMode } = buildAgent({
|
|
6903
|
+
config,
|
|
6904
|
+
modelId: modelInfo.id,
|
|
6905
|
+
notifyingLlm,
|
|
6906
|
+
allTools,
|
|
6907
|
+
agentContext,
|
|
6908
|
+
agentToolsRef,
|
|
6909
|
+
silentLogger,
|
|
6910
|
+
sessionId: newSession.id,
|
|
6911
|
+
initialInteractionMode: useCliStore.getState().interactionMode,
|
|
6606
6912
|
contextContent: contextResult.mergedContent,
|
|
6607
6913
|
agentStore,
|
|
6608
|
-
|
|
6914
|
+
customCommandStore: state.customCommandStore,
|
|
6609
6915
|
enableSkillTool,
|
|
6610
|
-
enableDynamicAgentCreation: config.preferences.enableDynamicAgentCreation === true,
|
|
6611
6916
|
additionalDirectories,
|
|
6612
|
-
featureModulePrompts:
|
|
6613
|
-
planModeFilePath: mode === "plan" ? getPlanModeFilePath(newSession.id) : void 0,
|
|
6614
|
-
appendSystemPrompt: process.env.B4M_APPEND_SYSTEM_PROMPT,
|
|
6615
|
-
deferredToolNames: deferredToolRegistry.getDirectoryNames()
|
|
6616
|
-
});
|
|
6617
|
-
const cliSystemPrompt = buildPromptForMode(useCliStore.getState().interactionMode);
|
|
6618
|
-
const maxIterations = config.preferences.maxIterations === null ? 999999 : config.preferences.maxIterations;
|
|
6619
|
-
const agent = new ReActAgent({
|
|
6620
|
-
userId: config.userId,
|
|
6621
|
-
logger: silentLogger,
|
|
6622
|
-
llm: notifyingLlm,
|
|
6623
|
-
model: modelInfo.id,
|
|
6624
|
-
tools: allTools,
|
|
6625
|
-
maxIterations,
|
|
6626
|
-
maxTokens: config.preferences.maxTokens,
|
|
6627
|
-
temperature: config.preferences.temperature,
|
|
6628
|
-
systemPrompt: cliSystemPrompt,
|
|
6629
|
-
unknownToolResolver: async (toolName) => deferredToolRegistry.get(toolName) ?? null
|
|
6917
|
+
featureModulePrompts: featureRegistry.getSystemPromptSections()
|
|
6630
6918
|
});
|
|
6631
|
-
agentToolsRef.current = agent.getTools();
|
|
6632
|
-
agentContext.currentAgent = agent;
|
|
6633
6919
|
let lastInteractionMode = useCliStore.getState().interactionMode;
|
|
6634
6920
|
useCliStore.subscribe((s) => {
|
|
6635
6921
|
if (s.interactionMode === lastInteractionMode) return;
|
|
@@ -6637,110 +6923,10 @@ function CliApp() {
|
|
|
6637
6923
|
if (agentContext.currentAgent !== agent) return;
|
|
6638
6924
|
agent.setSystemPrompt(buildPromptForMode(s.interactionMode));
|
|
6639
6925
|
});
|
|
6640
|
-
|
|
6641
|
-
|
|
6642
|
-
|
|
6643
|
-
|
|
6644
|
-
if (lastIdx >= 0 && pendingMessages[lastIdx].role === "assistant") {
|
|
6645
|
-
const existingSteps = pendingMessages[lastIdx].metadata?.steps || [];
|
|
6646
|
-
const formattedStep = formatStep(step);
|
|
6647
|
-
updatePendingMessage(lastIdx, {
|
|
6648
|
-
...pendingMessages[lastIdx],
|
|
6649
|
-
metadata: {
|
|
6650
|
-
...pendingMessages[lastIdx].metadata,
|
|
6651
|
-
steps: [...existingSteps, formattedStep]
|
|
6652
|
-
}
|
|
6653
|
-
});
|
|
6654
|
-
}
|
|
6655
|
-
};
|
|
6656
|
-
agent.on("thought", stepHandler);
|
|
6657
|
-
agent.on("observation", stepHandler);
|
|
6658
|
-
agent.on("action", stepHandler);
|
|
6659
|
-
orchestrator.setBeforeRunCallback((subagent, _subagentType) => {
|
|
6660
|
-
subagent.on("thought", stepHandler);
|
|
6661
|
-
subagent.on("observation", stepHandler);
|
|
6662
|
-
subagent.on("action", stepHandler);
|
|
6663
|
-
});
|
|
6664
|
-
orchestrator.setAfterRunCallback((subagent, _subagentType) => {
|
|
6665
|
-
subagent.off("thought", stepHandler);
|
|
6666
|
-
subagent.off("observation", stepHandler);
|
|
6667
|
-
subagent.off("action", stepHandler);
|
|
6668
|
-
});
|
|
6669
|
-
const pendingToolUseIds = /* @__PURE__ */ new Map();
|
|
6670
|
-
const summarizeToolInput = (input) => {
|
|
6671
|
-
if (input == null) return void 0;
|
|
6672
|
-
if (typeof input === "string") return input.slice(0, 240);
|
|
6673
|
-
try {
|
|
6674
|
-
return JSON.stringify(input).slice(0, 240);
|
|
6675
|
-
} catch {
|
|
6676
|
-
return;
|
|
6677
|
-
}
|
|
6678
|
-
};
|
|
6679
|
-
const tavernActionHandler = (step) => {
|
|
6680
|
-
const toolName = step.metadata?.toolName ?? "tool";
|
|
6681
|
-
const toolUseId = `${toolName}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
6682
|
-
const queue = pendingToolUseIds.get(toolName) ?? [];
|
|
6683
|
-
queue.push(toolUseId);
|
|
6684
|
-
pendingToolUseIds.set(toolName, queue);
|
|
6685
|
-
bridgePresence.emitEvent({
|
|
6686
|
-
type: "tool_use",
|
|
6687
|
-
tool: toolName,
|
|
6688
|
-
toolUseId,
|
|
6689
|
-
text: summarizeToolInput(step.metadata?.toolInput)
|
|
6690
|
-
});
|
|
6691
|
-
};
|
|
6692
|
-
const tavernObservationHandler = (step) => {
|
|
6693
|
-
const toolName = step.metadata?.toolName;
|
|
6694
|
-
let toolUseId;
|
|
6695
|
-
if (toolName) {
|
|
6696
|
-
const queue = pendingToolUseIds.get(toolName);
|
|
6697
|
-
if (queue && queue.length > 0) {
|
|
6698
|
-
toolUseId = queue.shift();
|
|
6699
|
-
if (queue.length === 0) pendingToolUseIds.delete(toolName);
|
|
6700
|
-
}
|
|
6701
|
-
}
|
|
6702
|
-
if (!toolUseId) {
|
|
6703
|
-
for (const [name, queue] of pendingToolUseIds.entries()) if (queue.length > 0) {
|
|
6704
|
-
toolUseId = queue.shift();
|
|
6705
|
-
if (queue.length === 0) pendingToolUseIds.delete(name);
|
|
6706
|
-
break;
|
|
6707
|
-
}
|
|
6708
|
-
}
|
|
6709
|
-
if (!toolUseId) toolUseId = `orphan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
6710
|
-
bridgePresence.emitEvent({
|
|
6711
|
-
type: "tool_result",
|
|
6712
|
-
tool: toolName,
|
|
6713
|
-
toolUseId,
|
|
6714
|
-
text: typeof step.content === "string" ? step.content.slice(0, 4e3) : void 0
|
|
6715
|
-
});
|
|
6716
|
-
};
|
|
6717
|
-
const tavernFinalAnswerHandler = (step) => {
|
|
6718
|
-
const text = typeof step.content === "string" ? step.content : "";
|
|
6719
|
-
if (!text) return;
|
|
6720
|
-
bridgePresence.emitEvent({
|
|
6721
|
-
type: "message",
|
|
6722
|
-
role: "assistant",
|
|
6723
|
-
text: text.slice(0, 4e3)
|
|
6724
|
-
});
|
|
6725
|
-
};
|
|
6726
|
-
agent.on("action", tavernActionHandler);
|
|
6727
|
-
agent.on("observation", tavernObservationHandler);
|
|
6728
|
-
agent.on("final_answer", tavernFinalAnswerHandler);
|
|
6729
|
-
orchestrator.setBeforeRunCallback((subagent, _subagentType) => {
|
|
6730
|
-
subagent.on("thought", stepHandler);
|
|
6731
|
-
subagent.on("observation", stepHandler);
|
|
6732
|
-
subagent.on("action", stepHandler);
|
|
6733
|
-
subagent.on("action", tavernActionHandler);
|
|
6734
|
-
subagent.on("observation", tavernObservationHandler);
|
|
6735
|
-
subagent.on("final_answer", tavernFinalAnswerHandler);
|
|
6736
|
-
});
|
|
6737
|
-
orchestrator.setAfterRunCallback((subagent, _subagentType) => {
|
|
6738
|
-
subagent.off("thought", stepHandler);
|
|
6739
|
-
subagent.off("observation", stepHandler);
|
|
6740
|
-
subagent.off("action", stepHandler);
|
|
6741
|
-
subagent.off("action", tavernActionHandler);
|
|
6742
|
-
subagent.off("observation", tavernObservationHandler);
|
|
6743
|
-
subagent.off("final_answer", tavernFinalAnswerHandler);
|
|
6926
|
+
wireAgentEvents({
|
|
6927
|
+
agent,
|
|
6928
|
+
agentContext,
|
|
6929
|
+
orchestrator
|
|
6744
6930
|
});
|
|
6745
6931
|
setState((prev) => ({
|
|
6746
6932
|
...prev,
|