Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions apps/ui/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,9 @@ DEVBOX_API_BASE_URL=
TEMPLATE_PROVIDER_URL=

DEVBOX_JWT_SIGNING_KEY=
DEVBOX_RUNTIME_IMAGE=ghcr.io/labring-actions/devbox-base-images/sandbox-v1:2026-6-stable-zh-cn-amd64
# Required for managed Skills: immutable sandbox/v1 image built with the offline Skill bundle.
DEVBOX_RUNTIME_IMAGE=
DEVBOX_JWT_TTL_SECONDS=14400
# Skill repository source installed into Chat Agent and AI deployment Devboxes.
DEPLOY_SKILL_SOURCE=
# Public Streamable HTTP endpoint reachable from deployment Devboxes.
DEPLOY_AGENT_MCP_URL=https://brain.example.com/api/deploy-agent/mcp/v1
# Optional comma-separated browser Origin allowlist. Codex MCP clients do not
Expand Down
124 changes: 113 additions & 11 deletions apps/ui/src/features/chat/devbox/chat-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ mock.module("./lifecycle-registration", () => ({
recordChatDevboxActivity: () => Promise.resolve(),
}));

const INSTALL_MARKER_RE = /sealos-skills-install\.marker/;

const {
bootstrapChatDevboxIfNeeded,
getChatDevboxSkillsSnapshot,
Expand All @@ -30,6 +28,14 @@ test("background Skill warmup is shared and publishes metadata after discovery",
const originalToken = process.env.DEVBOX_TOKEN;
let execCalls = 0;
let installCommand = "";
let emptyDiscovery = false;
let preparationFails = false;
let prepareCalls = 0;
let creationTimestamp: string | null = "2026-09-07T00:00:00Z";
let discoveryGate: Promise<void> | undefined;
let onDiscovery: (() => void) | undefined;
const upstreamIds: string[] = [];
const originalImage = process.env.DEVBOX_RUNTIME_IMAGE;

process.env.DEVBOX_API_BASE_URL = "https://devbox.test";
process.env.DEVBOX_TOKEN = "test-token";
Expand All @@ -39,6 +45,7 @@ test("background Skill warmup is shared and publishes metadata after discovery",
) => {
const url = new URL(String(input));
if (url.searchParams.has("upstreamID")) {
upstreamIds.push(url.searchParams.get("upstreamID") ?? "");
return Response.json({
data: { items: [{ name: "existing-runtime" }] },
});
Expand All @@ -48,7 +55,7 @@ test("background Skill warmup is shared and publishes metadata after discovery",
}
if (url.pathname.endsWith("/existing-runtime")) {
return Response.json({
data: { state: { phase: "Running" } },
data: { creationTimestamp, state: { phase: "Running" } },
});
}
if (url.pathname.endsWith("/exec")) {
Expand All @@ -57,18 +64,29 @@ test("background Skill warmup is shared and publishes metadata after discovery",
command?: string[];
};
const command = body.command?.at(-1) ?? "";
if (command.includes("skills@1.5.20 add")) {
if (command.includes("sealai-prepare-skills")) {
prepareCalls += 1;
installCommand = command;
if (preparationFails) {
return Response.json({
data: { exitCode: 1, stderr: "private raw error", stdout: "" },
});
}
}
if (command.includes("find ")) {
return Response.json({
onDiscovery?.();
const response = Response.json({
data: {
exitCode: 0,
stderr: "",
stdout:
"/home/devbox/project/.agents/skills/sealos-deploy/SKILL.md\n",
stdout: emptyDiscovery
? ""
: "/home/devbox/project/.agents/skills/sealos-deploy/SKILL.md\n",
},
});
return discoveryGate == null
? response
: discoveryGate.then(() => response);
}
if (command.includes("cat --")) {
return Response.json({
Expand Down Expand Up @@ -102,12 +120,96 @@ test("background Skill warmup is shared and publishes metadata after discovery",
["sealos-deploy"]
);
assert.ok(execCalls >= 3);
assert.match(installCommand, INSTALL_MARKER_RE);
assert.ok(
installCommand.indexOf('cat -- "$install_marker"') <
installCommand.indexOf("install_output")
assert.ok(installCommand.includes("/usr/local/bin/sealai-prepare-skills"));
assert.ok(!installCommand.includes("npx"));
assert.ok(installCommand.includes(" --init-workspace"));
const warmedExecCalls = execCalls;
await warmChatDevboxSkills(options);
assert.equal(execCalls, warmedExecCalls);
const copiedSnapshot = await warmChatDevboxSkills(options);
assert.ok(copiedSnapshot[0]);
copiedSnapshot[0].name = "modified-by-caller";
assert.equal(
(await warmChatDevboxSkills(options))[0]?.name,
"sealos-deploy"
);
const failureOptions = { ...options, namespace: "ns-warmup-failure" };
emptyDiscovery = true;
await assert.rejects(warmChatDevboxSkills(failureOptions));
emptyDiscovery = false;
creationTimestamp = "2026-09-07T01:00:00Z";
preparationFails = true;
await assert.rejects(
warmChatDevboxSkills(failureOptions),
(error: unknown) =>
error instanceof Error && !error.message.includes("private raw error")
);
preparationFails = false;
assert.equal(
(await warmChatDevboxSkills(failureOptions))[0]?.name,
"sealos-deploy"
);
const previousIdentity = upstreamIds.at(-1);
process.env.DEVBOX_RUNTIME_IMAGE =
"example.test/sandbox@sha256:new-test-image";
assert.deepEqual(getChatDevboxSkillsSnapshot(options), []);
await warmChatDevboxSkills(options);
assert.notEqual(upstreamIds.at(-1), previousIdentity);

const { buildChatToolset } = await import("../runtime/tools");
const toolsetOptions = {
chatId: "skills-first-turn",
kubeconfig: options.kubeconfig,
kubernetesNamespace: "ns-toolset-first-turn",
workspaceActor: "test-actor",
workspaceUserUid: "test-user",
};
const gate = Promise.withResolvers<void>();
const started = Promise.withResolvers<void>();
discoveryGate = gate.promise;
onDiscovery = started.resolve;
let toolsetReady = false;
const toolset = buildChatToolset(toolsetOptions).then((result) => {
toolsetReady = true;
return result;
});
await started.promise;
assert.equal(toolsetReady, false);
gate.resolve();
const ready = await toolset;
assert.ok(ready.systemPrompt.includes("sealos-deploy"));
assert.ok(ready.tools.loadSkill);
const firstTurnExecCalls = execCalls;
const secondTurn = await buildChatToolset(toolsetOptions);
assert.equal(execCalls, firstTurnExecCalls);
const prepared = prepareCalls;
const loadSkill = secondTurn.tools.loadSkill?.execute;
assert.ok(loadSkill);
await loadSkill(
{ name: "sealos-deploy", intention: "Deploy an app" },
{ toolCallId: "load-skill", messages: [], context: undefined }
);
assert.equal(prepareCalls, prepared);
creationTimestamp = "2026-09-07T02:00:00Z";
await bootstrapChatDevboxIfNeeded({
kubeconfig: options.kubeconfig,
namespace: toolsetOptions.kubernetesNamespace,
});
assert.equal(prepareCalls, prepared + 1);
creationTimestamp = null;
for (let attempt = 0; attempt < 2; attempt += 1) {
await bootstrapChatDevboxIfNeeded({
kubeconfig: options.kubeconfig,
namespace: toolsetOptions.kubernetesNamespace,
});
}
assert.equal(prepareCalls, prepared + 3);
} finally {
if (originalImage === undefined) {
delete process.env.DEVBOX_RUNTIME_IMAGE;
} else {
process.env.DEVBOX_RUNTIME_IMAGE = originalImage;
}
globalThis.fetch = originalFetch;
if (originalBaseUrl === undefined) {
delete process.env.DEVBOX_API_BASE_URL;
Expand Down
72 changes: 49 additions & 23 deletions apps/ui/src/features/chat/devbox/chat-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import {
discoverChatDevboxSkills,
} from "@/features/chat/tool/devbox-skills";
import {
assertBundledSkillsConfiguration,
buildSealosSkillsInstallCommand,
getSealosSkillsSourceFromEnv,
SEALOS_SKILLS_CLI_VERSION,
SEALOS_SKILLS_RUNTIME_CONTRACT,
} from "@/features/sealos-skills/install";
import {
createDevbox,
Expand All @@ -35,9 +35,10 @@ const DEVBOX_COMMAND_TIMEOUT_SECONDS = 60;
const DEVBOX_WRITE_TIMEOUT_SECONDS = 60;
const DEVBOX_READ_TIMEOUT_SECONDS = 60;
const DEVBOX_WARMUP_TIMEOUT_SECONDS = 30;
const DEVBOX_SKILL_INSTALL_TIMEOUT_SECONDS = 180;
const DEVBOX_SKILL_INSTALL_TIMEOUT_SECONDS = 30;
const chatDevboxSkillSnapshots = new Map<string, ChatSkillMeta[]>();
const chatDevboxSkillWarmups = new Map<string, Promise<ChatSkillMeta[]>>();
const chatDevboxPreparedSkills = new Map<string, string>();

export interface ChatDevboxRuntimeOptions {
kubeconfig: string;
Expand Down Expand Up @@ -70,7 +71,12 @@ export function getChatDevboxSkillsSnapshot(
export function warmChatDevboxSkills(
options: ChatDevboxRuntimeOptions
): Promise<ChatSkillMeta[]> {
assertBundledSkillsConfiguration(process.env);
const cacheKey = chatDevboxSkillCacheKey(options);
const snapshot = chatDevboxSkillSnapshots.get(cacheKey);
if (snapshot != null) {
return Promise.resolve(snapshot.map((skill) => ({ ...skill })));
}
const existing = chatDevboxSkillWarmups.get(cacheKey);
if (existing != null) {
return existing;
Expand All @@ -79,6 +85,9 @@ export function warmChatDevboxSkills(
const sandbox = createChatDevboxSandbox(options);
const warmup = discoverChatDevboxSkills(sandbox)
.then((skills) => {
if (skills.length === 0) {
throw new Error("Chat runtime has no usable Skills.");
}
chatDevboxSkillSnapshots.set(cacheKey, skills);
return skills.map((skill) => ({ ...skill }));
})
Expand Down Expand Up @@ -118,7 +127,9 @@ export function hashKubeconfigForDevbox(kubeconfig: string): string {

function hashRuntimeIdentity(kubeconfig: string, namespace: string): string {
return createHash("sha256")
.update(`${namespace}|${hashKubeconfigForDevbox(kubeconfig)}`)
.update(
`${namespace}|${hashKubeconfigForDevbox(kubeconfig)}|${getDevboxDefaultImage() ?? "provider-default"}|${SEALOS_SKILLS_RUNTIME_CONTRACT}`
)
.digest("hex")
.slice(0, 32);
}
Expand All @@ -128,8 +139,7 @@ function chatDevboxSkillCacheKey(options: ChatDevboxRuntimeOptions): string {
normalizeKubeconfig(options.kubeconfig),
options.namespace
);
const skillSource = getSealosSkillsSourceFromEnv(process.env);
return `${runtimeHash}|${skillSource}|${SEALOS_SKILLS_CLI_VERSION}`;
return runtimeHash;
}

function runtimeName(runtimeHash: string): string {
Expand Down Expand Up @@ -300,32 +310,38 @@ async function assertDevboxKubectlReady(
}

async function installChatSkills(
authNamespace: string,
options: ChatDevboxRuntimeOptions,
name: string,
creationTimestamp: string | null | undefined,
signal?: AbortSignal
): Promise<void> {
const cacheKey = chatDevboxSkillCacheKey(options);
// Names may be reused after deletion; only cache a known runtime generation.
const generation = creationTimestamp ? `${name}|${creationTimestamp}` : null;
if (
generation != null &&
chatDevboxPreparedSkills.get(cacheKey) === generation
) {
return;
}
chatDevboxPreparedSkills.delete(cacheKey);
chatDevboxSkillSnapshots.delete(cacheKey);
const result = await runDevboxCommand(
authNamespace,
options.namespace,
name,
buildSealosSkillsInstallCommand({
skipIfInstallMarkerMatches: true,
skillSource: getSealosSkillsSourceFromEnv(process.env),
timeoutSeconds: DEVBOX_SKILL_INSTALL_TIMEOUT_SECONDS,
}),
buildSealosSkillsInstallCommand({ initializeWorkspace: true }),
DEVBOX_SKILL_INSTALL_TIMEOUT_SECONDS,
signal
);

if (result.exitCode !== 0) {
throw new Error(
[
"Chat Devbox Sealos skills installation failed.",
result.stderr.trim() || result.stdout.trim(),
]
.filter(Boolean)
.join(" ")
"Chat runtime Skill preparation failed. Check the runtime image bundle."
);
}
if (generation != null) {
chatDevboxPreparedSkills.set(cacheKey, generation);
}
}

async function ensureChatDevbox(
Expand All @@ -334,6 +350,7 @@ async function ensureChatDevbox(
): Promise<{ name: string; skippedExisting: boolean }> {
signal?.throwIfAborted();
const kubeconfig = normalizeKubeconfig(options.kubeconfig);
assertBundledSkillsConfiguration(process.env);
const authNamespace = options.namespace;
const runtimeHash = hashRuntimeIdentity(kubeconfig, options.namespace);
const name = runtimeName(runtimeHash);
Expand All @@ -353,15 +370,24 @@ async function ensureChatDevbox(
const existing = (await listDevboxes(authNamespace, upstreamID, signal)).data
.items[0];
if (existing != null) {
await ensureRunningDevbox(authNamespace, existing.name, signal);
const info = await ensureRunningDevbox(
authNamespace,
existing.name,
signal
);
await refreshLease(authNamespace, existing.name, pauseAt, signal);
await assertDevboxKubectlReady(
authNamespace,
existing.name,
options.namespace,
signal
);
await installChatSkills(authNamespace, existing.name, signal);
await installChatSkills(
options,
existing.name,
info.creationTimestamp,
signal
);
return { name: existing.name, skippedExisting: true };
}

Expand All @@ -388,14 +414,14 @@ async function ensureChatDevbox(
signal
);

await waitForRunningDevbox(authNamespace, name, signal);
const info = await waitForRunningDevbox(authNamespace, name, signal);
await assertDevboxKubectlReady(
authNamespace,
name,
options.namespace,
signal
);
await installChatSkills(authNamespace, name, signal);
await installChatSkills(options, name, info.creationTimestamp, signal);
return { name, skippedExisting: false };
}

Expand Down
8 changes: 4 additions & 4 deletions apps/ui/src/features/chat/runtime/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
executeEmitGenUISpec,
genUISpecInputSchema,
} from "@/features/chat/agui/gen-ui-tool";
import { getChatDevboxSkillsSnapshot } from "@/features/chat/devbox/chat-runtime";
import { warmChatDevboxSkills } from "@/features/chat/devbox/chat-runtime";
import type { AssistantContextPayload } from "@/features/chat/persistence/types";
import { createChatBashTool } from "@/features/chat/tool/chat-bash-tool";
import { createSearchDeployCatalogTool } from "@/features/chat/tool/chat-deploy-catalog-tool";
Expand Down Expand Up @@ -54,8 +54,8 @@ export interface ChatToolset {
* Assemble the per-request tool registry + system prompt.
*
* - Skill index drives both the `loadSkill` tool and the discovery prompt addendum.
* - The shared Chat Devbox remains lazy; Skill metadata comes from the
* background warmup cache and never blocks the chat stream preflight.
* - Await shared Devbox preparation/discovery before the model starts;
* page-load background warmup is only a latency optimization.
*/
export async function buildChatToolset({
billingActor,
Expand All @@ -79,7 +79,7 @@ export async function buildChatToolset({
kubeconfig,
namespace: kubernetesNamespace,
});
const skillIndex = getChatDevboxSkillsSnapshot({
const skillIndex = await warmChatDevboxSkills({
kubeconfig,
namespace: kubernetesNamespace,
});
Expand Down
Loading
Loading