diff --git a/backend/cli/src/agent/prompt/research.txt b/backend/cli/src/agent/prompt/research.txt index 7b74076c..5dd7771d 100644 --- a/backend/cli/src/agent/prompt/research.txt +++ b/backend/cli/src/agent/prompt/research.txt @@ -3,20 +3,23 @@ You are the primary Research agent. Use this adaptive loop: -understand -> plan only if useful -> inspect or search -> analyze -> save useful output -> -review only if useful +understand -> plan if useful -> inspect/search -> analyze -> save useful output -> review if useful ## Calibrate the work -- A direct question should receive a direct answer. Do not create a plan, literature review, - reasoning file, methodology file, or child task unless the work actually needs one. +- A direct question should receive a direct answer. Create no plan, review, methodology, or child task + unless the work needs one. - For an analysis, inspect inputs before choosing a method. State the success criterion when the request leaves it implicit and the choice affects the result. -- Search literature when current or source-backed claims matter. One focused search is - usually better than a search swarm. Preserve stable source identifiers and distinguish - source claims from your inference. +- Search when current or source-backed claims matter. Prefer one focused search, preserve + stable source IDs, and distinguish source claims from inference. - If the user asks for a report, figure, dataset, notebook, or other output, create and verify that deliverable rather than returning only a narrative. +- For multi-kernel analysis, preserve every track, correct failures, and finish with a reusable + report plus registered tables/figures/datasets. Save finished files with `artifact.save_file` + as versioned project artifacts, each with a descriptive non-empty summary. +- For tabular/quantitative analysis, create at least two useful figures when supported. Show + each final figure inline from its kernel cell and save it; an unseen file is not a result. ## Specialists and delegation @@ -24,17 +27,26 @@ review only if useful - Biology, ML, and Physics are explicit specialists. Use one only for a distinct domain concern that can be bounded and merged cleanly. - Default to no child agents; never create several literature children for one search. -- At most two independent children may run concurrently. Continue with useful primary work - while they run and do not wait for optional stragglers. +- At most two independent children run concurrently. Keep working; do not wait for stragglers. ## Compute and tools - Use a persistent kernel for iterative Python or R analysis whose state matters. +- When the user requests multiple kernels, use distinct managed `kernel` names and issue the + independent notebook or R-kernel calls together so they run concurrently. Never substitute + shell processes and describe them as kernels. +- Use exactly the requested kernel count. Prepare shared inputs once in one of those named + kernels, save the handoff dataset, then fan the independent tracks out across that kernel and + the remaining names; do not create an extra setup kernel. +- Named kernels are temporary. After outputs and artifacts are verified, call each kernel tool + with `action: "stop"` before answering; leave no completed worker idle. - Use shell for builds, tests, file operations, and non-interactive scripts. - Keep modest calculations, parsing, plotting, and statistics local. - Use remote compute only when the workload or user requires it. Never dispatch paid work without an approval request that names the action, provider, resources, duration, and estimated price. +- Remote work finishes only after delivery is inspected and the provider resource is closed, + or a truthful cleanup warning is surfaced. - Prefer one concern per kernel cell and named output files when replay matters. - Do not promise checkpointing or recovery unless the selected runtime implements it. diff --git a/backend/cli/src/science/command/registry.ts b/backend/cli/src/science/command/registry.ts new file mode 100644 index 00000000..907f96f2 --- /dev/null +++ b/backend/cli/src/science/command/registry.ts @@ -0,0 +1,75 @@ +import type { ChildProcess } from "node:child_process" +import z from "zod" + +export const CommandStatus = z.object({ + id: z.string(), + projectID: z.string(), + sessionID: z.string(), + messageID: z.string(), + callID: z.string().optional(), + description: z.string(), + command: z.string(), + state: z.literal("running"), + process_id: z.number().int(), + started_at: z.number().int(), + resources: z + .object({ + cpu_percent: z.number(), + memory_bytes: z.number().int(), + }) + .partial() + .optional(), +}) +export type CommandStatus = z.infer + +type Entry = CommandStatus & { + process: ChildProcess + stop: () => Promise +} + +const entries = new Map() + +export namespace CommandRuntime { + export function start( + input: Omit, + process: ChildProcess, + stop: () => Promise, + ) { + if (!process.pid) throw new Error("Shell command started without a process id") + const value: Entry = { + ...input, + id: `command-${crypto.randomUUID()}`, + state: "running", + process_id: process.pid, + started_at: Date.now(), + process, + stop, + } + entries.set(value.id, value) + return value + } + + export function finish(id: string) { + entries.delete(id) + } + + export function list(projectID: string, sessionID?: string): CommandStatus[] { + return [...entries.values()] + .filter((value) => value.projectID === projectID && (!sessionID || value.sessionID === sessionID)) + .map(({ process: _process, stop: _stop, ...value }) => value) + .toSorted((a, b) => b.started_at - a.started_at) + } + + export function owned(id: string, projectID: string, sessionID: string) { + const value = entries.get(id) + if (!value || value.projectID !== projectID || value.sessionID !== sessionID) return + return value + } + + export async function stop(id: string, projectID: string, sessionID: string) { + const value = owned(id, projectID, sessionID) + if (!value) return false + await value.stop() + return true + } +} diff --git a/backend/cli/src/server/routes/notebook.ts b/backend/cli/src/server/routes/notebook.ts index 05733303..71dde5d8 100644 --- a/backend/cli/src/server/routes/notebook.ts +++ b/backend/cli/src/server/routes/notebook.ts @@ -13,6 +13,7 @@ import { Identifier } from "../../id/id" import { Session } from "../../session" import { lazy } from "../../util/lazy" import { Storage } from "../../storage/storage" +import { CommandRuntime, CommandStatus } from "../../science/command/registry" const Language = z.enum(["python", "r"]) const Key = z.object({ @@ -26,21 +27,15 @@ const List = z.object({ const Owner = z.object({ sessionID: Identifier.schema("session"), }) -const Create = Owner.extend({ - name: z - .string() - .trim() - .min(1) - .max(120) - .refine((value) => value !== "agent", "The default agent kernel name is reserved."), - language: Language, -}) const ControlStatus = KernelStatus.extend({ state_preserved: z.boolean().optional(), }) const KernelParam = z.object({ kernelID: z.string().regex(/^kernel-[a-z0-9]+$/), }) +const CommandParam = z.object({ + commandID: z.string().regex(/^command-[a-f0-9-]+$/), +}) const Execute = Key.extend({ code: z.string().max(2_000_000), timeout: z.number().int().min(5_000).max(600_000).optional(), @@ -107,9 +102,9 @@ export const NotebookRoutes = lazy(() => .get( "/compute", describeRoute({ - summary: "Report host compute capacity", + summary: "Report live local compute capacity", operationId: "notebook.compute", - responses: { 200: { description: "Machine capacity and the share kernels hold" } }, + responses: { 200: { description: "Machine capacity and the share live kernels and commands hold" } }, }), async (c) => { // Both samplers measure across the window since THIS caller's previous @@ -123,42 +118,113 @@ export const NotebookRoutes = lazy(() => const caller = c.req.query("client")?.slice(0, 128) || "anonymous" const host = await KernelHost.snapshot(caller) const live = KernelRuntime.list().filter((kernel) => kernel.active) - const samples = await KernelMetrics.sampleAll( - `compute:${caller}`, - live.flatMap((kernel) => (kernel.process_id === null ? [] : [kernel.process_id])), - ) + const commands = CommandRuntime.list(Instance.project.id) + const samples = await KernelMetrics.sampleAll(`compute:${caller}`, [ + ...live.flatMap((kernel) => (kernel.process_id === null ? [] : [kernel.process_id])), + ...commands.map((command) => command.process_id), + ]) const usage = [...samples.values()] + const kernelUsage = live.flatMap((kernel) => { + const value = kernel.process_id === null ? undefined : samples.get(kernel.process_id) + return value ? [value] : [] + }) const cpu = usage.filter((sample) => sample.cpu_percent !== undefined) const memory = usage.filter((sample) => sample.memory_bytes !== undefined) + const kernelCpu = kernelUsage.filter((sample) => sample.cpu_percent !== undefined) + const kernelMemory = kernelUsage.filter((sample) => sample.memory_bytes !== undefined) return c.json({ memory: { total: host.memory.total, available: host.memory.available, - // No live kernels at all is a real measurement: they hold exactly - // zero bytes. Kernels that exist but went unsampled this poll stay - // omitted — that figure is genuinely unknown, not zero. + ...(live.length === 0 && commands.length === 0 + ? { compute: 0 } + : memory.length + ? { compute: memory.reduce((sum, item) => sum + (item.memory_bytes ?? 0), 0) } + : {}), ...(live.length === 0 ? { kernels: 0 } - : memory.length - ? { kernels: memory.reduce((sum, item) => sum + (item.memory_bytes ?? 0), 0) } + : kernelMemory.length + ? { kernels: kernelMemory.reduce((sum, item) => sum + (item.memory_bytes ?? 0), 0) } : {}), }, cpu: { cores: host.cpu.cores, ...(host.cpu.busy === undefined ? {} : { busy: host.cpu.busy }), + ...(live.length === 0 && commands.length === 0 + ? { compute: 0 } + : cpu.length + ? { compute: cpu.reduce((sum, item) => sum + (item.cpu_percent ?? 0), 0) / 100 } + : {}), ...(live.length === 0 ? { kernels: 0 } - : cpu.length - ? { kernels: cpu.reduce((sum, item) => sum + (item.cpu_percent ?? 0), 0) / 100 } + : kernelCpu.length + ? { kernels: kernelCpu.reduce((sum, item) => sum + (item.cpu_percent ?? 0), 0) / 100 } : {}), }, kernels: { live: live.length, running: live.filter((kernel) => kernel.state === "running").length, }, + commands: { + live: commands.length, + running: commands.length, + }, }) }, ) + .get( + "/commands", + describeRoute({ + summary: "List live project shell commands", + operationId: "notebook.commands", + responses: { + 200: { + description: "Live shell commands and process resource usage", + content: { "application/json": { schema: resolver(z.object({ commands: CommandStatus.array() })) } }, + }, + }, + }), + validator("query", List), + async (c) => { + const query = c.req.valid("query") + if (query.sessionID) { + const denied = await owner(c, query.sessionID) + if (denied) return denied + } + const commands = CommandRuntime.list(Instance.project.id, query.sessionID) + const caller = c.req.query("client")?.slice(0, 128) || "anonymous" + const samples = await KernelMetrics.sampleAll( + `commands:${caller}`, + commands.map((command) => command.process_id), + ) + return c.json({ + commands: commands.map((command) => { + const resources = samples.get(command.process_id) + return resources && Object.keys(resources).length ? { ...command, resources } : command + }), + }) + }, + ) + .post( + "/commands/:commandID/stop", + describeRoute({ + summary: "Stop a live shell command", + operationId: "notebook.command.stop", + responses: { 200: { description: "Command stopped" }, 404: { description: "Command not found" } }, + }), + validator("param", CommandParam), + validator("json", Owner), + async (c) => { + const body = c.req.valid("json") + const denied = await owner(c, body.sessionID) + if (denied) return denied + const stopped = await CommandRuntime.stop(c.req.valid("param").commandID, Instance.project.id, body.sessionID) + if (!stopped) { + return c.json({ error: "command_not_found", message: "The command is no longer running." }, 404) + } + return c.json({ stopped: true }) + }, + ) .get( "/kernels", describeRoute({ @@ -209,34 +275,6 @@ export const NotebookRoutes = lazy(() => return c.json({ kernels }) }, ) - .post( - "/kernels", - describeRoute({ - summary: "Create a named session kernel record", - operationId: "notebook.kernel.create", - responses: { - 200: { - description: "Lazy named kernel record", - content: { "application/json": { schema: resolver(KernelStatus) } }, - }, - }, - }), - validator("json", Create), - async (c) => { - const body = c.req.valid("json") - const denied = await owner(c, body.sessionID) - if (denied) return denied - await KernelRuntime.restoreSession(Instance.project.id, body.sessionID) - return c.json( - await KernelRuntime.create({ - projectID: Instance.project.id, - sessionID: body.sessionID, - name: body.name, - language: body.language, - }), - ) - }, - ) .post( "/kernels/:kernelID/restart", describeRoute({ diff --git a/backend/cli/src/session/trace.ts b/backend/cli/src/session/trace.ts index ed0d2b9e..2147972b 100644 --- a/backend/cli/src/session/trace.ts +++ b/backend/cli/src/session/trace.ts @@ -419,6 +419,7 @@ export namespace SessionTrace { toolID: part.id, messageID: part.messageID, language: part.tool === "notebook" ? ("python" as const) : ("r" as const), + kernel: string(part.state.input.kernel) ?? "agent", status: part.state.status, ...times(part, now), executionCount: number(meta.executionCount), diff --git a/backend/cli/src/tool/artifact.ts b/backend/cli/src/tool/artifact.ts index 736307c2..b26d9940 100644 --- a/backend/cli/src/tool/artifact.ts +++ b/backend/cli/src/tool/artifact.ts @@ -1,5 +1,9 @@ import z from "zod" +import path from "node:path" import { Tool } from "./tool" +import { ArtifactStore } from "@/artifact/store" +import { File } from "@/file" +import { ArtifactFile } from "@/file/artifacts" import { Instance } from "@/project/instance" import { Provenance } from "@/science/provenance/store" import type { Node, Run } from "@/science/provenance/store" @@ -17,6 +21,7 @@ export const ArtifactTool = Tool.define("artifact", { "Store and retrieve large data artifacts by reference.", "Use this to keep large outputs (DataFrames, analysis results, raw data) out of context.", "Actions:", + " - save_file: Promote a finished workspace file into the durable, immutable, versioned artifact store", " - register: Store content on disk, returns a reference ID + summary", " - update: Replace the current content while retaining an immutable version", " - resolve: Retrieve full content by artifact ID", @@ -25,7 +30,10 @@ export const ArtifactTool = Tool.define("artifact", { " - read_version: Retrieve one immutable version by version ID", ].join(" "), parameters: z.object({ - action: z.enum(["register", "update", "resolve", "list", "list_versions", "read_version"]).describe("The action"), + action: z + .enum(["save_file", "register", "update", "resolve", "list", "list_versions", "read_version"]) + .describe("The action"), + path: z.string().trim().min(1).max(10_000).optional().describe("For save_file: workspace file path"), type: z.string().optional().describe('For register/update: artifact type (e.g. "dataframe", "analysis")'), content: z.string().optional().describe("For register/update: the large content to store"), summary: z.string().optional().describe("For register/update: brief summary for context window"), @@ -72,6 +80,65 @@ export const ArtifactTool = Tool.define("artifact", { ...(typeof run === "string" ? { runID: run } : ctx.callID ? { runID: ctx.callID } : {}), ...(params.provenance_id ? { provenanceID: params.provenance_id } : {}), } + if (params.action === "save_file") { + if (!params.path) return result("Error", "save_file requires `path`") + const file = await File.raw(params.path, { sessionID: ctx.sessionID }) + const name = path.basename(params.path) + const classified = ArtifactFile.classify(name) + const title = params.summary?.trim() || name + const preview = await (async () => { + if (file.type.startsWith("image/") && file.size <= 1_500_000) { + const bytes = Buffer.from(await file.arrayBuffer()).toString("base64") + return { kind: "image" as const, data: `data:${file.type};base64,${bytes}` } + } + const text = + file.type.startsWith("text/") || + ["application/json", "application/xml", "application/yaml", "application/x-yaml"].includes(file.type) + if (text && file.size <= 250_000) { + return { kind: "text" as const, data: (await file.text()).slice(0, 12_000) } + } + })() + const saved = await ArtifactStore.save({ + projectID: Instance.project.id, + sessionID: ctx.sessionID, + sourcePath: params.path, + filename: name, + kind: classified?.kind ?? "file", + content: file, + title, + mimeType: file.type, + messageID: ctx.messageID, + captureQuality: "declared", + }) + return result( + `Saved artifact: ${saved.title}`, + [ + "Workspace file saved as a durable, immutable artifact version.", + ` ID: ${saved.id}`, + ` Version: ${saved.current.version}`, + ` Kind: ${saved.kind}`, + ` Path: ${saved.current.sourcePath}`, + ` Size: ${saved.current.size} bytes`, + ` SHA-256: ${saved.current.sha256}`, + "", + "The artifact is available project-wide in Files and can be opened, reviewed, renamed, versioned, or downloaded.", + ].join("\n"), + { + savedArtifact: { + id: saved.id, + versionID: saved.currentVersionID, + version: saved.current.version, + title: saved.title, + kind: saved.kind, + path: saved.current.sourcePath, + mimeType: saved.current.mimeType, + size: saved.current.size, + sha256: saved.current.sha256, + ...(preview ? { preview } : {}), + }, + }, + ) + } if (params.action === "register") { if (!params.type || !params.content) { return result("Error", "register requires `type` and `content` parameters") diff --git a/backend/cli/src/tool/bash.ts b/backend/cli/src/tool/bash.ts index 3b5b0cc2..b91dd33f 100644 --- a/backend/cli/src/tool/bash.ts +++ b/backend/cli/src/tool/bash.ts @@ -22,6 +22,7 @@ import { Filesystem } from "@/util/filesystem" import { Provenance } from "@/science/provenance/store" import { ProvenanceEnvelope } from "@/science/provenance/envelope" import { ExecutionAuthority } from "@/project/execution" +import { CommandRuntime } from "@/science/command/registry" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENSCIENCE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 0 @@ -305,6 +306,24 @@ export const BashTool = Tool.define("bash", async () => { detached: process.platform !== "win32", }) + let exited = false + let aborted = false + const kill = () => Shell.killTree(proc, { exited: () => exited, detached: process.platform !== "win32" }) + const command = CommandRuntime.start( + { + projectID: Instance.project.id, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + ...(ctx.callID ? { callID: ctx.callID } : {}), + description: params.description, + command: params.command, + }, + proc, + async () => { + aborted = true + await kill() + }, + ) let output = "" // Initialize metadata with empty output @@ -345,10 +364,6 @@ export const BashTool = Tool.define("bash", async () => { proc.stderr?.on("data", capture("stderr")) let timedOut = false - let aborted = false - let exited = false - - const kill = () => Shell.killTree(proc, { exited: () => exited, detached: process.platform !== "win32" }) if (ctx.abort.aborted) { aborted = true @@ -378,12 +393,14 @@ export const BashTool = Tool.define("bash", async () => { proc.once("exit", () => { exited = true + CommandRuntime.finish(command.id) cleanup() resolve() }) proc.once("error", (error) => { exited = true + CommandRuntime.finish(command.id) cleanup() reject(error) }) diff --git a/backend/cli/src/tool/notebook.ts b/backend/cli/src/tool/notebook.ts index e450c599..f4b12f68 100644 --- a/backend/cli/src/tool/notebook.ts +++ b/backend/cli/src/tool/notebook.ts @@ -576,16 +576,58 @@ function clip(s: string, max = 30_000): string { export const NotebookTool = Tool.define("notebook", { description: [ - "Execute Python code in a persistent kernel. Variables, imports, and state persist across calls.", + "Execute Python code in a persistent, managed kernel. Variables, imports, and state persist across calls that use the same kernel name.", + "For multiple independent analyses, issue multiple notebook calls in the same response with distinct `kernel` names. Those kernels execute concurrently and appear separately in Compute.", + "Never use shell subprocesses to imitate multiple kernels; use this tool's `kernel` parameter instead.", + "After a named analysis is fully saved and verified, call this tool with `action: stop` and the same kernel name so completed workers do not idle.", "Use instead of `bash python` for analysis — no need to re-import or re-load data between cells.", "numpy (np), pandas (pd), scipy, and matplotlib (plt) are pre-imported. Expression results auto-display like Jupyter.", "matplotlib figures are captured as inline PNG images. Not gated to any agent.", ].join("\n"), - parameters: z.object({ - code: z.string().describe("Python code to execute in the persistent kernel"), - timeout: z.number().default(120_000).describe("Execution timeout in ms (default: 120s, max: 600s)"), - }), + parameters: z + .object({ + action: z.enum(["execute", "stop"]).optional().describe("Execute a cell (default) or stop this named kernel"), + code: z.string().optional().describe("Python code to execute; required when action is execute"), + kernel: z + .string() + .trim() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/) + .optional() + .describe("Stable name for an isolated managed kernel. Use a distinct name for each parallel analysis."), + timeout: z.number().default(120_000).describe("Execution timeout in ms (default: 120s, max: 600s)"), + }) + .superRefine((params, issue) => { + if (params.action !== "stop" && !params.code) { + issue.addIssue({ code: "custom", path: ["code"], message: "code is required when action is execute" }) + } + }), async execute(params, ctx) { + const name = params.kernel ?? "agent" + const identity = { + projectID: Instance.project.id, + sessionID: ctx.sessionID, + name, + language: "python" as const, + } + if (params.action === "stop") { + ctx.metadata({ title: `Stopped Python · ${name}`, metadata: { kernel: name, language: "python", stopped: true } }) + await KernelRuntime.release(identity) + return { + title: `Stopped Python · ${name}`, + output: `Managed kernel ${name} stopped. Its in-memory state was cleared.`, + metadata: { + kernel: name, + language: "python", + stopped: true, + ok: true, + output: `Managed kernel ${name} stopped.`, + }, + } + } + ctx.metadata({ title: `Python · ${name}`, metadata: { kernel: name, language: "python" } }) + // Executes arbitrary code — same permission gate as bash. await ctx.ask({ permission: "bash", @@ -594,16 +636,11 @@ export const NotebookTool = Tool.define("notebook", { metadata: {}, }) - const result = await KernelRuntime.execute( - { - projectID: Instance.project.id, - sessionID: ctx.sessionID, - name: "agent", - language: "python", - }, - params.code, - { timeout: params.timeout, signal: ctx.abort, origin: { messageID: ctx.messageID, callID: ctx.callID } }, - ) + const result = await KernelRuntime.execute(identity, params.code!, { + timeout: params.timeout, + signal: ctx.abort, + origin: { messageID: ctx.messageID, callID: ctx.callID }, + }) const images = result.outputs.filter((o) => o.type === "display" && o.data?.["image/png"]) const dataUrls = images.map((o) => `data:image/png;base64,${o.data!["image/png"]}`) @@ -623,15 +660,19 @@ export const NotebookTool = Tool.define("notebook", { const output = clip(parts.join("\n")) ctx.metadata({ - metadata: { output, ok: result.ok, provenanceID: result.provenanceID }, + title: `Python · ${name}`, + metadata: { output, ok: result.ok, provenanceID: result.provenanceID, kernel: name, language: "python" }, }) return { - title: result.ok ? "Python cell" : "Python cell (error)", + title: result.ok ? `Python · ${name}` : `Python · ${name} (error)`, output, metadata: { + stopped: false, ok: result.ok, output, + kernel: name, + language: "python", provenanceID: result.provenanceID, executionCount: result.executionCount, hasImages: images.length, diff --git a/backend/cli/src/tool/rkernel.ts b/backend/cli/src/tool/rkernel.ts index d88d7b67..24210a8e 100644 --- a/backend/cli/src/tool/rkernel.ts +++ b/backend/cli/src/tool/rkernel.ts @@ -517,16 +517,59 @@ function clip(s: string, max = 30_000): string { export const RKernelTool = Tool.define("rkernel", { description: [ - "Execute R code in a persistent kernel. Objects, attached packages, and state persist across calls.", + "Execute R code in a persistent, managed kernel. Objects, attached packages, and state persist across calls that use the same kernel name.", + "For multiple independent analyses, issue multiple kernel calls in the same response with distinct `kernel` names. Those kernels execute concurrently and appear separately in Compute.", + "Never use shell subprocesses to imitate multiple kernels; use this tool's `kernel` parameter instead.", + "After a named analysis is fully saved and verified, call this tool with `action: stop` and the same kernel name so completed workers do not idle.", "Use instead of `bash Rscript` for analysis — no need to re-source data or reload packages between cells.", "Print output is captured; base-graphics and ggplot2 plots are captured as inline PNG images where the platform supports it.", "Requires Rscript on PATH; if R is not installed the tool reports a clear install hint.", ].join("\n"), - parameters: z.object({ - code: z.string().describe("R code to execute in the persistent kernel"), - timeout: z.number().default(120_000).describe("Execution timeout in ms (default: 120s, max: 600s)"), - }), + parameters: z + .object({ + action: z.enum(["execute", "stop"]).optional().describe("Execute a cell (default) or stop this named kernel"), + code: z.string().optional().describe("R code to execute; required when action is execute"), + kernel: z + .string() + .trim() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/) + .optional() + .describe("Stable name for an isolated managed kernel. Use a distinct name for each parallel analysis."), + timeout: z.number().default(120_000).describe("Execution timeout in ms (default: 120s, max: 600s)"), + }) + .superRefine((params, issue) => { + if (params.action !== "stop" && !params.code) { + issue.addIssue({ code: "custom", path: ["code"], message: "code is required when action is execute" }) + } + }), async execute(params, ctx) { + const name = params.kernel ?? "agent" + const identity = { + projectID: Instance.project.id, + sessionID: ctx.sessionID, + name, + language: "r" as const, + } + if (params.action === "stop") { + ctx.metadata({ title: `Stopped R · ${name}`, metadata: { kernel: name, language: "r", stopped: true } }) + await KernelRuntime.release(identity) + return { + title: `Stopped R · ${name}`, + output: `Managed kernel ${name} stopped. Its in-memory state was cleared.`, + metadata: { + kernel: name, + language: "r", + stopped: true, + ok: true, + available: true, + output: `Managed kernel ${name} stopped.`, + }, + } + } + ctx.metadata({ title: `R · ${name}`, metadata: { kernel: name, language: "r" } }) + // Executes arbitrary code — same permission gate as bash. await ctx.ask({ permission: "bash", @@ -541,19 +584,18 @@ export const RKernelTool = Tool.define("rkernel", { const msg = "Rscript not found. Install R from https://www.r-project.org (or `brew install r`) so `Rscript` is on PATH." ctx.metadata({ metadata: { output: msg, ok: false } }) - return { title: "R kernel unavailable", output: msg, metadata: { ok: false, available: false, output: msg } } + return { + title: "R kernel unavailable", + output: msg, + metadata: { kernel: name, language: "r", stopped: false, ok: false, available: false, output: msg }, + } } - const result = await KernelRuntime.execute( - { - projectID: Instance.project.id, - sessionID: ctx.sessionID, - name: "agent", - language: "r", - }, - params.code, - { timeout: params.timeout, signal: ctx.abort, origin: { messageID: ctx.messageID, callID: ctx.callID } }, - ) + const result = await KernelRuntime.execute(identity, params.code!, { + timeout: params.timeout, + signal: ctx.abort, + origin: { messageID: ctx.messageID, callID: ctx.callID }, + }) const images = result.outputs.filter((o) => o.type === "display" && o.data?.["image/png"]) const dataUrls = images.map((o) => `data:image/png;base64,${o.data!["image/png"]}`) @@ -565,15 +607,21 @@ export const RKernelTool = Tool.define("rkernel", { if (!parts.length) parts.push("(no output)") const output = clip(parts.join("\n")) - ctx.metadata({ metadata: { output, ok: result.ok, provenanceID: result.provenanceID } }) + ctx.metadata({ + title: `R · ${name}`, + metadata: { output, ok: result.ok, provenanceID: result.provenanceID, kernel: name, language: "r" }, + }) return { - title: result.ok ? "R cell" : "R cell (error)", + title: result.ok ? `R · ${name}` : `R · ${name} (error)`, output, metadata: { + stopped: false, ok: result.ok, available: true, output, + kernel: name, + language: "r", provenanceID: result.provenanceID, hasImages: images.length, ...(images.length ? { artifact: { kind: "image", data: { images: dataUrls } } } : {}), diff --git a/backend/cli/test/server/notebook.test.ts b/backend/cli/test/server/notebook.test.ts index 710154ee..7e268348 100644 --- a/backend/cli/test/server/notebook.test.ts +++ b/backend/cli/test/server/notebook.test.ts @@ -53,7 +53,7 @@ describe("/notebook routes", () => { paths[path]?.post?.requestBody?.content?.["application/json"]?.schema?.required ?? [] expect(paths["/notebook/kernels"]?.get).toBeDefined() - expect(paths["/notebook/kernels"]?.post).toBeDefined() + expect(paths["/notebook/kernels"]?.post).toBeUndefined() expect(paths["/notebook/kernels/{kernelID}/restart"]?.post).toBeDefined() expect(paths["/notebook/kernels/{kernelID}/stop"]?.post).toBeDefined() expect(paths["/notebook/kernels/{kernelID}/interrupt"]?.post).toBeDefined() @@ -65,7 +65,6 @@ describe("/notebook routes", () => { expect(paths["/notebook/stop"]?.post).toBeDefined() expect(paths["/notebook/interrupt"]?.post).toBeDefined() expect(required("/notebook/execute")).toContain("sessionID") - expect(required("/notebook/kernels")).toEqual(expect.arrayContaining(["sessionID", "name", "language"])) expect(required("/notebook/restart")).toContain("sessionID") expect(required("/notebook/stop")).toContain("sessionID") expect(required("/notebook/interrupt")).toContain("sessionID") @@ -96,63 +95,6 @@ describe("/notebook routes", () => { }) }) - test("creates durable named Python and R kernel records without starting a process", async () => { - await using tmp = await tmpdir({ git: true }) - const result = await Instance.provide({ - directory: tmp.path, - fn: async () => { - const app = NotebookRoutes() - const session = await Session.create({}) - const create = (name: string, language: "python" | "r") => - app.request("/kernels", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sessionID: session.id, name, language }), - }) - const python = await create("analysis", "python") - const duplicate = await create("analysis", "python") - const r = await create("statistics", "r") - const pythonBody = (await python.json()) as { id: string } - const duplicateBody = (await duplicate.json()) as { id: string } - - expect(python.status).toBe(200) - expect(pythonBody).toMatchObject({ - active: false, - state: "lazy", - name: "analysis", - language: "python", - target: { kind: "local" }, - process_id: null, - }) - expect(duplicateBody.id).toBe(pythonBody.id) - expect(await r.json()).toMatchObject({ - active: false, - state: "lazy", - name: "statistics", - language: "r", - target: { kind: "local" }, - }) - await Instance.dispose() - return session.id - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const inventory = (await ( - await NotebookRoutes().request(`/kernels?sessionID=${encodeURIComponent(result)}`) - ).json()) as { kernels: Array<{ name: string; state: string }> } - expect(inventory.kernels).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: "analysis", state: "lazy" }), - expect.objectContaining({ name: "statistics", state: "lazy" }), - ]), - ) - }, - }) - }) - test("executes cells in a persistent session-owned Python kernel", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ diff --git a/backend/cli/test/tool/artifact-save-file.test.ts b/backend/cli/test/tool/artifact-save-file.test.ts new file mode 100644 index 00000000..c66eb593 --- /dev/null +++ b/backend/cli/test/tool/artifact-save-file.test.ts @@ -0,0 +1,80 @@ +import { expect, test } from "bun:test" +import path from "node:path" +import { ArtifactStore } from "../../src/artifact/store" +import { Instance } from "../../src/project/instance" +import { ArtifactTool } from "../../src/tool/artifact" +import { executionSession, tmpdir } from "../fixture/fixture" + +const context = (sessionID: string) => ({ + sessionID, + messageID: "msg_artifact_save_file", + callID: "call_artifact_save_file", + agent: "research", + abort: new AbortController().signal, + messages: [], + metadata() {}, + async ask() {}, +}) + +test("artifact save_file promotes a workspace result into immutable versions", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await executionSession() + const tool = await ArtifactTool.init() + const target = path.join(tmp.path, "results", "titanic-report.md") + await Bun.write(target, "# Titanic analysis\n\nFirst verified result.\n") + + const first = await tool.execute( + { action: "save_file", path: "results/titanic-report.md", summary: "Titanic analysis report" }, + context(session.id), + ) + await Bun.write(target, "# Titanic analysis\n\nImproved verified result.\n") + const second = await tool.execute( + { action: "save_file", path: "results/titanic-report.md", summary: "Titanic analysis report" }, + context(session.id), + ) + const firstSaved = first.metadata.savedArtifact as { id: string } + + expect(first.title).toBe("Saved artifact: Titanic analysis report") + expect(first.metadata.savedArtifact).toMatchObject({ + version: 1, + title: "Titanic analysis report", + kind: "report", + path: "results/titanic-report.md", + mimeType: "text/markdown", + preview: { kind: "text", data: "# Titanic analysis\n\nFirst verified result.\n" }, + sha256: expect.stringMatching(/^[a-f0-9]{64}$/), + }) + expect(second.metadata.savedArtifact).toMatchObject({ + id: firstSaved.id, + version: 2, + }) + expect(await ArtifactStore.list(Instance.project.id)).toHaveLength(1) + expect(await ArtifactStore.get(Instance.project.id, firstSaved.id)).toMatchObject({ versionCount: 2 }) + }, + }) +}) + +test("artifact save_file never persists a blank display title", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await executionSession() + const tool = await ArtifactTool.init() + await Bun.write(path.join(tmp.path, "result.csv"), "metric,value\naccuracy,0.91\n") + + const saved = await tool.execute({ action: "save_file", path: "result.csv", summary: " " }, context(session.id)) + + expect(saved.title).toBe("Saved artifact: result.csv") + expect(saved.metadata.savedArtifact).toMatchObject({ + title: "result.csv", + kind: "dataset", + mimeType: "text/csv", + preview: { kind: "text", data: "metric,value\naccuracy,0.91\n" }, + }) + }, + }) +}) diff --git a/backend/cli/test/tool/command-runtime.test.ts b/backend/cli/test/tool/command-runtime.test.ts new file mode 100644 index 00000000..d04764c0 --- /dev/null +++ b/backend/cli/test/tool/command-runtime.test.ts @@ -0,0 +1,52 @@ +import { expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { CommandRuntime } from "../../src/science/command/registry" +import { BashTool } from "../../src/tool/bash" +import { executionSession, tmpdir } from "../fixture/fixture" + +const context = (sessionID: string) => ({ + sessionID, + messageID: "msg_live_command", + callID: "call_live_command", + agent: "research", + abort: new AbortController().signal, + messages: [], + metadata() {}, + async ask() {}, +}) + +test("bash registers only its live process in the project compute ledger", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await executionSession() + const tool = await BashTool.init() + const running = tool.execute( + { command: "sleep 10", description: "Waiting for analysis input" }, + context(session.id), + ) + const find = async (attempt = 0): Promise[number]> => { + const command = CommandRuntime.list(Instance.project.id, session.id)[0] + if (command) return command + if (attempt >= 100) throw new Error("Live command did not enter the compute ledger") + await Bun.sleep(10) + return find(attempt + 1) + } + const command = await find() + + expect(command).toMatchObject({ + sessionID: session.id, + messageID: "msg_live_command", + callID: "call_live_command", + description: "Waiting for analysis input", + command: "sleep 10", + state: "running", + process_id: expect.any(Number), + }) + expect(await CommandRuntime.stop(command.id, Instance.project.id, session.id)).toBe(true) + expect((await running).output).toContain("User aborted the command") + expect(CommandRuntime.list(Instance.project.id, session.id)).toEqual([]) + }, + }) +}, 30_000) diff --git a/backend/cli/test/tool/named-kernels.test.ts b/backend/cli/test/tool/named-kernels.test.ts new file mode 100644 index 00000000..ce356048 --- /dev/null +++ b/backend/cli/test/tool/named-kernels.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { KernelRuntime, type KernelIdentity } from "../../src/science/kernel/registry" +import { NotebookTool } from "../../src/tool/notebook" +import { RKernelTool } from "../../src/tool/rkernel" +import { executionSession, tmpdir } from "../fixture/fixture" + +const context = (sessionID: string, callID: string) => ({ + sessionID, + messageID: "message_named_kernels", + callID, + agent: "research", + abort: new AbortController().signal, + messages: [], + metadata() {}, + async ask() {}, +}) + +test("kernel tools advertise and validate isolated managed names", async () => { + const python = await NotebookTool.init() + const r = await RKernelTool.init() + + expect(python.description).toContain("distinct `kernel` names") + expect(python.description).toContain("Never use shell subprocesses") + expect(python.description).toContain("`action: stop`") + expect(r.description).toContain("distinct `kernel` names") + expect(python.parameters.parse({ code: "1 + 1", kernel: "descriptive-eda" }).kernel).toBe("descriptive-eda") + expect(r.parameters.parse({ code: "1 + 1", kernel: "stratified_rates" }).kernel).toBe("stratified_rates") + expect(() => python.parameters.parse({ code: "1 + 1", kernel: "invalid name" })).toThrow() +}) + +test("four named notebook calls own four live managed kernels", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await executionSession() + const tool = await NotebookTool.init() + const names = ["descriptive-eda", "survival-rates", "inference", "model-benchmark"] + const identities: KernelIdentity[] = names.map((name) => ({ + projectID: Instance.project.id, + sessionID: session.id, + name, + language: "python", + })) + + try { + const results = await Promise.all( + names.map((name, index) => + tool.execute( + { + action: "execute", + code: `import time\ntime.sleep(0.15)\nprint(${JSON.stringify(name)})`, + kernel: name, + timeout: 30_000, + }, + context(session.id, `call_named_kernel_${index}`), + ), + ), + ) + + expect(results.map((result) => result.output.trim())).toEqual(names) + expect( + KernelRuntime.list(session.id) + .filter((kernel) => kernel.active) + .map((kernel) => kernel.name) + .sort(), + ).toEqual(names.toSorted()) + const stopped = await Promise.all( + names.map((name, index) => + tool.execute({ action: "stop", kernel: name, timeout: 30_000 }, context(session.id, `call_stop_${index}`)), + ), + ) + expect(stopped.every((result) => result.metadata.stopped === true)).toBe(true) + expect(KernelRuntime.list(session.id).some((kernel) => kernel.active)).toBe(false) + } finally { + await Promise.all(identities.map((identity) => KernelRuntime.release(identity))) + } + }, + }) +}, 60_000) diff --git a/docs/notes/claude-science-ui-behavior-audit.md b/docs/notes/claude-science-ui-behavior-audit.md new file mode 100644 index 00000000..89c44113 --- /dev/null +++ b/docs/notes/claude-science-ui-behavior-audit.md @@ -0,0 +1,120 @@ +# Claude Science UI behavior audit + +Observed on 2026-08-08 against these local reference surfaces: + +- Project shell: `http://localhost:8765/projects/proj_41efbe3a56fc` +- Completed four-kernel reference: `http://localhost:8765/projects/proj_41efbe3a56fc/frames/4654d750-f605-4d90-a749-0d6a9ecf2615` +- Fresh-session reference: `http://localhost:8765/projects/proj_68013d68e83c` +- Comparison prompt: `start up multiple analysis jobs for the titanic dataset across 4 kernels` + +This is a behavior index, not a request to reproduce Claude branding. It records the interaction contracts that make the science workflow legible and maps them to OpenScience. + +## 1. Project shell and navigation + +| Surface | Claude Science behavior | OpenScience contract | +| ------------------ | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| Project identity | Back control and project name anchor the shell. | Keep project identity at the top of the sessions rail. | +| Primary navigation | New, Search, Customize, Files, and Compute use one compact type scale and icon rhythm. | Use the same typography as the sessions rail for every primary item, including Customize and settings content. | +| Session list | Sessions are grouped by time, have a readable activity state, and expose row actions without taking over the row. | Preserve session titles and activity dots; keep utility controls visually secondary. | +| Open work | Multiple sessions remain open as tabs. | Session tabs may change while the right inspector remains project-scoped and mounted. | +| Density | Dividers, labels, and counters are quiet; content carries the emphasis. | Avoid oversized settings type, heavy borders, or card-on-card decoration. | + +## 2. Right-side workspace + +| Surface | Claude Science behavior | OpenScience contract | +| -------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| Independence | The right strip survives session changes and can contain Files, Compute, and opened artifacts. | Inspector state is project-scoped, not owned by the selected session. | +| Tabs | Files, Compute, and opened files coexist as closable tabs. | Files and Compute remain side by side with chat and keep their own open-tab state. | +| Split/merge | The strip can merge back to one tab row or remain split. | Narrow layouts must collapse gracefully without changing the underlying project state. | +| Direct opening | Artifact cards have an explicit open-in-split action. | Every promoted artifact has `Open beside chat`; no Browse button is required. | + +## 3. Conversation activity ledger + +| Surface | Claude Science behavior | OpenScience contract | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| Progress grouping | Steps are grouped into summaries such as “Ran 3 commands” or “Saved artifacts.” | Routine reasoning may remain in Show steps, but scientific code, outputs, figures, artifacts, and remote results are promoted outside it. | +| Step labels | Each operation has a task label and a compact result summary such as lines of output, figure count, or artifact count. | Tool headers state language, kernel, state, and useful output identity. | +| Live state | Background cells visibly move through queued/running/finished/failed states. | Named kernels, commands, and remote jobs poll into Compute while the turn is running. | +| Failures | Failures stay visible and are followed by a short diagnosis and retry. | Preserve failed code/output in chat; retries appear as later cards rather than replacing history. | +| Narrative checkpoints | The agent explains handoffs: kernels ready, a plot needs correction, outputs are being saved. | Final answers summarize the completed tracks, key metrics, artifact location, and cleanup state. | + +## 4. Code and output cards + +| Surface | Claude Science behavior | OpenScience contract | +| -------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| Default presentation | A step opens into a language/environment header, source, and separate output control. | Notebook and R cards auto-open so code is never hidden behind Show steps. | +| Source height | Long source remains contained inside the operation instead of dominating the transcript. | Show exactly five code lines in a vertically and horizontally scrollable source window; retain the complete source in that window. | +| Output | Text output is visually separated from source. | Text output is open by default and independently scrollable. | +| Figures | Figures appear immediately after the cell that produced them. | Inline notebook images stay visible even when text output is collapsed. | +| Identity | The environment name is always visible. | Show the stable named kernel (`env titanic-quality`, etc.) in each card. | +| Completion | Stopped workers get a compact lifecycle receipt. | Render `Kernel stopped` cards and keep the source/results above them. | + +## 5. Compute + +| Surface | Claude Science behavior | OpenScience contract | +| --------------- | ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| Host strip | Memory, CPU, live-kernel count, and running count are always visible. | Host totals combine local kernels, shell commands, and remote jobs. Unknown metrics render as unavailable, never fabricated zeroes. | +| Project ledger | Work is grouped by owning session with a current-session marker. | Compute aggregates every session in the project and does not reset when the selected session changes. | +| Kernel row | Language, state, age/cell count, activity label, RSS, CPU, and stop action form one dense row. | Live rows show named kernel, state/recovery text, uptime, RSS, cores, and Stop. | +| Job row | Long-running/background work stays visible independently of chat scroll. | Shell commands and Modal/GPU jobs are first-class rows with command/target, resources, duration, status, output, artifacts, cleanup, and cancel. | +| Completed work | Claude commonly leaves idle kernels visible. | OpenScience intentionally stops finished kernels, then keeps up to five recent local completion rows so the trail remains without wasting compute. | +| Manual creation | Claude exposes environment setup as part of agent work, not a user kernel launcher in the completed session. | Do not expose manual kernel creation. Kernels start only when an agent executes work. | +| Cleanup | Claude exposes stop/kill per kernel but may leave kernels idle. | The research agent must stop every named kernel after outputs and artifacts are verified. Remote cleanup warnings remain visible. | + +## 6. Files and artifacts + +| Surface | Claude Science behavior | OpenScience contract | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Automatic collection | Generated files appear without a Browse step. | `save_file` promotes files into the project artifact store automatically. | +| Grouping | Artifacts are grouped by session and show a count and relative time. | Files is project-wide, groups by session, and marks artifacts created by the active run. | +| Grid/list | Images get thumbnails; CSV cards show dimensions/schema; reports show rendered content; grid and list layouts are available. | Figures preview inline, text/Markdown renders in chat, and existing Files previews retain grid/list support. | +| Actions | Open in split, download, and more-actions controls are adjacent to each artifact. | Chat provides `Open beside chat`; Files owns project-level artifact actions. | +| Naming | Artifacts use meaningful names and the final answer links the consolidated report. | Blank summaries fall back to filename; the agent is prompted to provide descriptive non-empty titles. | +| Versioning | Saved outputs are durable products of the session. | Artifact cards show kind, version, size, and checksum; overwrites create durable versions. | + +## 7. Scientific result quality + +The reference run does more than execute code: + +1. Finds or fetches a reusable dataset and saves it as an artifact. +2. Splits one request into distinct analytical remits. +3. Starts multiple real environments concurrently. +4. Creates publication-style figures, visually inspects them, and corrects layout or data bugs. +5. Retries infrastructure/library failures with a safer implementation. +6. Saves figures, tables, and a consolidated Markdown report. +7. Ends with numerical findings, methods, caveats, and cross-track interpretation. +8. Runs a reviewer that can point to an unsupported visual claim without discarding the otherwise valid result. + +OpenScience's research prompt now requires at least two decision-useful figures for tabular analysis when supported, inline display, output validation, descriptive artifact summaries, a consolidated result, and worker cleanup. The exact comparison run produced four figures, three promoted tables, one report, a visible sklearn failure and scipy retry, and four stop receipts. + +## 8. Responsive behavior + +- The inspector is a container, not a fixed desktop canvas. +- At medium widths, session headers wrap before data rows become illegible. +- At narrow widths, the primary identity occupies the first row; metrics and controls wrap beneath it. +- Code and logs scroll inside their cards instead of widening the conversation. +- Artifact previews use the available width and preserve aspect ratio. +- Controls remain reachable; labels may compress, but state and stop/cancel actions do not disappear. + +## 9. Intentional OpenScience differences + +- Finished kernels are stopped automatically rather than left idle. Recent completion rows preserve visibility without retaining memory. +- Manual kernel creation is removed. The execution ledger describes real work; it is not a launcher. +- Compute also includes shell subprocesses and Modal/GPU jobs, which the reference surface did not expose in this exact local run. +- Project-wide Files and Compute remain stable while sessions switch, matching the requested cross-session workspace model. + +## 10. Acceptance checklist + +- [x] Exact prompt starts exactly four named managed kernels. +- [x] Four kernel rows are visible in Compute during execution. +- [x] Python source and output remain visible in chat while working and after completion. +- [x] Source is auto-open but capped to a five-line scroll window. +- [x] Figures display inline beside their producing cells. +- [x] Saved report, tables, and figures auto-appear in Files. +- [x] Artifact titles are meaningful and previews open beside chat. +- [x] Failed analysis is visible and can be retried without losing history. +- [x] Every named kernel stops after result verification. +- [x] Recent completed local work remains visible in Compute. +- [x] Modal/GPU jobs have live and recent-result rows with resources, logs, artifacts, cancel, and cleanup state. +- [x] The right workspace remains project-scoped across session changes. +- [x] Compute and artifact cards adapt at narrow container widths. diff --git a/frontend/ui/src/components/message-part-artifact.test.ts b/frontend/ui/src/components/message-part-artifact.test.ts new file mode 100644 index 00000000..f006cc19 --- /dev/null +++ b/frontend/ui/src/components/message-part-artifact.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" + +const source = () => readFileSync(fileURLToPath(new URL("./message-part.tsx", import.meta.url)), "utf8") + +test("saved workspace artifacts render previewable, openable results", () => { + const part = source() + + expect(part).toContain('name: "artifact"') + expect(part).toContain('data-component="saved-artifact-tool"') + expect(part).toContain('title: saved() ? "Saved artifact"') + expect(part).toContain("sha256 {artifact().sha256.slice(0, 12)}") + expect(part).toContain('data-slot="saved-artifact-preview"') + expect(part).toContain('data-slot="saved-artifact-preview-text"') + expect(part).toContain("data.openFile?.(artifact().path)") + expect(part).toContain("Open beside chat") + expect(part).toContain("Show save receipt") +}) + +test("Modal and compute job results use a dedicated visible renderer", () => { + const part = source() + + expect(part).toContain('name: "modal"') + expect(part).toContain('name: "compute_job"') + expect(part).toContain('title: props.tool === "modal" ? "Modal compute" : "Remote compute result"') +}) diff --git a/frontend/ui/src/components/message-part-notebook.test.ts b/frontend/ui/src/components/message-part-notebook.test.ts new file mode 100644 index 00000000..e0849ae5 --- /dev/null +++ b/frontend/ui/src/components/message-part-notebook.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" + +const source = () => readFileSync(fileURLToPath(new URL("./message-part.tsx", import.meta.url)), "utf8") +const styles = () => readFileSync(fileURLToPath(new URL("./message-part.css", import.meta.url)), "utf8") + +test("notebook tools open source, text output, and figures by default", () => { + const part = source() + + expect(part).toContain('name: "notebook"') + expect(part).toContain('name: "rkernel"') + expect(part).toContain('data-slot="kernel-tool-source"') + expect(part).toContain("{code()}") + expect(part).toContain('typeof props.input.kernel === "string"') + expect(part).toContain("env {kernel()}") + expect(part).toContain("Show output") + expect(part).toContain('data-slot="kernel-tool-output" open') + expect(part).toContain('data-slot="kernel-tool-images"') + expect(part).toContain('props.input.action === "stop"') + expect(part).toContain('trigger={{ title: "Kernel stopped"') + expect(part).toContain('title: props.status === "completed" ? "Computed" : "Computing"') + expect(styles()).toContain("max-height: calc(5 * 1.55em + 20px)") + expect(styles()).toContain("overflow: auto") +}) diff --git a/frontend/ui/src/components/message-part.css b/frontend/ui/src/components/message-part.css index b03dd912..6bf20445 100644 --- a/frontend/ui/src/components/message-part.css +++ b/frontend/ui/src/components/message-part.css @@ -590,6 +590,184 @@ } } +[data-component="kernel-tool"] { + border-top: 1px solid var(--border-weak-base); + background: var(--background-base); + + & > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 7px 12px; + border-bottom: 1px solid var(--border-weak-base); + color: var(--text-weak); + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + + strong { + color: var(--text-base); + font-weight: var(--font-weight-medium); + } + + span { + font-size: var(--font-size-xs); + } + } + + [data-slot="kernel-tool-source"] { + max-height: calc(5 * 1.55em + 20px); + margin: 0; + padding: 10px 12px; + overflow: auto; + color: var(--text-base); + background: var(--background-base); + font-family: var(--font-family-mono); + font-size: var(--font-size-small); + line-height: 1.55; + tab-size: 2; + white-space: pre; + + code { + font: inherit; + } + } + + [data-slot="kernel-tool-output"] { + border-top: 1px solid var(--border-weak-base); + + summary { + padding: 7px 12px; + color: var(--text-weak); + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + cursor: pointer; + user-select: none; + } + + & > pre { + max-height: 260px; + margin: 0; + padding: 8px 12px 10px; + overflow: auto; + color: var(--text-base); + font-family: var(--font-family-mono); + font-size: var(--font-size-small); + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + } + + [data-slot="kernel-tool-images"] { + display: grid; + gap: 8px; + padding: 10px 12px 12px; + border-top: 1px solid var(--border-weak-base); + + img { + display: block; + width: 100%; + height: auto; + border: 1px solid var(--border-weak-base); + border-radius: 6px; + } + } +} + +[data-component="saved-artifact-tool"] { + display: grid; + gap: 8px; + padding: 10px 12px; +} + +[data-component="saved-artifact-tool"] > header, +[data-component="saved-artifact-tool"] > footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +[data-component="saved-artifact-tool"] > header strong { + min-width: 0; + overflow: hidden; + color: var(--text-strong); + font-size: 13px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-component="saved-artifact-tool"] > header span, +[data-component="saved-artifact-tool"] > footer { + color: var(--text-weak); + font-size: 11px; +} + +[data-component="saved-artifact-tool"] > code { + overflow: hidden; + color: var(--text-base); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-slot="saved-artifact-preview"] { + display: block; + width: 100%; + max-height: 420px; + object-fit: contain; + border: 1px solid var(--border-weak-base); + border-radius: 6px; + background: var(--background-base); +} + +[data-slot="saved-artifact-preview-text"] { + max-height: 320px; + overflow: auto; + padding: 10px 12px; + border: 1px solid var(--border-weak-base); + border-radius: 6px; + background: var(--background-base); +} + +[data-component="saved-artifact-tool"] > footer { + justify-content: flex-start; + flex-wrap: wrap; +} + +[data-component="saved-artifact-tool"] > footer button { + margin-right: auto; + border: 0; + padding: 0; + background: transparent; + color: var(--text-interactive-base, var(--text-base)); + font: inherit; + cursor: pointer; +} + +[data-component="saved-artifact-tool"] details { + border-top: 1px solid var(--border-weak-base); + padding-top: 8px; +} + +[data-component="saved-artifact-tool"] summary { + color: var(--text-weak); + cursor: pointer; + font-size: 12px; +} + +[data-component="saved-artifact-tool"] details pre { + margin-top: 8px; + max-height: 220px; + overflow: auto; + color: var(--text-base); + font-family: var(--font-family-mono); + font-size: 11px; + white-space: pre-wrap; +} + @property --border-angle { syntax: ""; initial-value: 0deg; diff --git a/frontend/ui/src/components/message-part.tsx b/frontend/ui/src/components/message-part.tsx index c34c8e53..1abecd2d 100644 --- a/frontend/ui/src/components/message-part.tsx +++ b/frontend/ui/src/components/message-part.tsx @@ -53,7 +53,7 @@ import { createAutoScroll } from "../hooks" import { createResizeObserver } from "@solid-primitives/resize-observer" import { NotebookView, type NotebookCellProps } from "./notebook-cell" import { skillName, stripRedactedReasoning } from "./tool-display" -import { ToolRegistry } from "./tool-registry" +import { ToolRegistry, type ToolProps } from "./tool-registry" export { ARTIFACT_TOOL, ToolRegistry, type ToolComponent, type ToolProps } from "./tool-registry" @@ -804,6 +804,213 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { ) } +function KernelTool(props: ToolProps & { language: "python" | "r"; label: "Python" | "R" }) { + const code = () => (typeof props.input.code === "string" ? props.input.code : "") + const kernel = () => (typeof props.input.kernel === "string" ? props.input.kernel : props.language) + const preview = () => code().trim().split("\n").find(Boolean)?.slice(0, 120) + const images = () => { + const artifact = props.metadata.artifact + if (!artifact || typeof artifact !== "object") return [] + const data = "data" in artifact && artifact.data && typeof artifact.data === "object" ? artifact.data : undefined + const value = data && "images" in data ? data.images : undefined + if (!Array.isArray(value)) return [] + return value.filter((item): item is string => typeof item === "string" && item.startsWith("data:image/")) + } + + if (props.input.action === "stop") { + return ( + + ) + } + + return ( + +
+
+ {props.label} + env {kernel()} +
+
+          {code()}
+        
+ +
+ Show output +
{stripAnsi(props.output ?? "")}
+
+
+ 0}> +
+ + {(image) => {`${props.label}} + +
+
+
+
+ ) +} + +ToolRegistry.register({ + name: "notebook", + render: (props) => , +}) + +ToolRegistry.register({ + name: "rkernel", + render: (props) => , +}) + +function SavedArtifactTool(props: ToolProps) { + const data = useData() + const saved = () => { + const value = props.metadata.savedArtifact + if (!value || typeof value !== "object") return + if ( + typeof value.title !== "string" || + typeof value.kind !== "string" || + typeof value.path !== "string" || + typeof value.id !== "string" || + typeof value.versionID !== "string" || + typeof value.version !== "number" || + typeof value.size !== "number" || + typeof value.sha256 !== "string" + ) + return + return value as { + title: string + kind: string + path: string + id: string + versionID: string + mimeType?: string + version: number + size: number + sha256: string + preview?: { kind: "image" | "text"; data: string } + } + } + + return ( + + +
+
{stripAnsi(props.output ?? "")}
+
+
+ } + > + {(artifact) => ( +
+
+ {artifact().title} + + {artifact().kind} · v{artifact().version} + +
+ {artifact().path} + + {(image) => ( + {artifact().title} + )} + + + {(preview) => ( +
+ +
+ )} +
+
+ + {artifact().size.toLocaleString()} bytes + sha256 {artifact().sha256.slice(0, 12)} +
+ +
+ Show save receipt +
{stripAnsi(props.output ?? "")}
+
+
+
+ )} + +
+ ) +} + +ToolRegistry.register({ + name: "artifact", + render: (props) => , +}) + +function RemoteComputeTool(props: ToolProps) { + const job = () => { + const value = props.metadata.job + return value && typeof value === "object" ? (value as Record) : undefined + } + const gpu = () => { + const value = job()?.modal + if (value && typeof value === "object" && "gpu" in value && typeof value.gpu === "string") return value.gpu + return typeof props.input.gpu === "string" ? props.input.gpu : undefined + } + const status = () => (typeof job()?.status === "string" ? job()!.status : props.status) + return ( + + + {(command) => ( +
+
{command()}
+
+ )} +
+ +
+
{stripAnsi(props.output ?? "")}
+
+
+
+ ) +} + +ToolRegistry.register({ name: "modal", render: (props) => }) +ToolRegistry.register({ name: "compute_job", render: (props) => }) + ToolRegistry.register({ name: "read", render(props) { diff --git a/frontend/ui/src/components/session-turn-science-results.test.ts b/frontend/ui/src/components/session-turn-science-results.test.ts new file mode 100644 index 00000000..a7aaa63b --- /dev/null +++ b/frontend/ui/src/components/session-turn-science-results.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" + +const source = readFileSync(fileURLToPath(new URL("./session-turn.tsx", import.meta.url)), "utf8") + +test("scientific code, results, artifacts, and remote jobs stay outside collapsed steps", () => { + expect(source).toContain('new Set(["notebook", "rkernel", "artifact", "modal", "compute_job"])') + expect(source).toContain('aria-label="Analysis code and results"') + expect(source).toContain("hidePromotedTools") + expect(source).toContain(".filter(isPromotedTool)") + expect(source).toContain("parts.filter((part) => !isPromotedTool(part))") +}) diff --git a/frontend/ui/src/components/session-turn.css b/frontend/ui/src/components/session-turn.css index e85c6163..b9b9168f 100644 --- a/frontend/ui/src/components/session-turn.css +++ b/frontend/ui/src/components/session-turn.css @@ -598,6 +598,14 @@ gap: 12px; } + [data-slot="session-turn-promoted-results"] { + width: 100%; + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; + } + [data-slot="session-turn-artifact-save"] { width: 100%; min-width: 0; diff --git a/frontend/ui/src/components/session-turn.tsx b/frontend/ui/src/components/session-turn.tsx index b6fec0aa..8a6f1346 100644 --- a/frontend/ui/src/components/session-turn.tsx +++ b/frontend/ui/src/components/session-turn.tsx @@ -96,11 +96,18 @@ function isAttachment(part: PartType | undefined) { ) } +const promotedTools = new Set(["notebook", "rkernel", "artifact", "modal", "compute_job"]) + +function isPromotedTool(part: PartType | undefined): part is ToolPart { + return part?.type === "tool" && promotedTools.has(part.tool) +} + function AssistantMessageItem(props: { message: AssistantMessage responsePartId: string | undefined hideResponsePart: boolean hideReasoning: boolean + hidePromotedTools?: boolean }) { const data = useData() const emptyParts: PartType[] = [] @@ -121,6 +128,10 @@ function AssistantMessageItem(props: { parts = parts.filter((part) => part?.type !== "reasoning") } + if (props.hidePromotedTools) { + parts = parts.filter((part) => !isPromotedTool(part)) + } + if (!props.hideResponsePart) return parts const responsePartId = props.responsePartId @@ -271,6 +282,13 @@ export function SessionTurn( return false }) + const promoted = createMemo(() => + assistantMessages().flatMap((message) => { + const parts = (data.store.part[message.id] ?? emptyParts).filter(isPromotedTool) + return parts.length ? [{ message, parts }] : [] + }), + ) + const permissions = createMemo(() => data.store.permission?.[props.sessionID] ?? emptyPermissions) const nextPermission = createMemo(() => permissions()[0]) const questions = createMemo(() => data.store.question?.[props.sessionID] ?? emptyQuestions) @@ -675,6 +693,7 @@ export function SessionTurn( responsePartId={responsePartId()} hideResponsePart={hideResponsePart()} hideReasoning={false} + hidePromotedTools /> )} @@ -685,6 +704,13 @@ export function SessionTurn( + 0}> +
+ + {(entry) => } + +
+
0}>
{({ part, message }) => } diff --git a/frontend/workspace/src/atlas/CommandCard.test.tsx b/frontend/workspace/src/atlas/CommandCard.test.tsx new file mode 100644 index 00000000..9aa21ab4 --- /dev/null +++ b/frontend/workspace/src/atlas/CommandCard.test.tsx @@ -0,0 +1,61 @@ +import { afterAll, afterEach, expect, test } from "bun:test" +import { fileURLToPath } from "node:url" +import type { JSX } from "solid-js" +import { createServer } from "vite" +import solid from "vite-plugin-solid" + +const server = await createServer({ + root: fileURLToPath(new URL("../..", import.meta.url)), + mode: "production", + logLevel: "silent", + plugins: [solid({ ssr: false, dev: false })], + server: { middlewareMode: true }, + appType: "custom", + resolve: { conditions: ["browser", "production"], dedupe: ["solid-js", "solid-js/web"] }, + ssr: { noExternal: true, resolve: { conditions: ["browser", "production"] } }, +}) +const [subject, web] = await Promise.all([ + server.ssrLoadModule("/src/atlas/CommandCard.tsx") as Promise, + server.ssrLoadModule("solid-js/web") as Promise, +]) +const cleanups: Array<() => void> = [] + +afterAll(() => server.close()) +afterEach(() => { + cleanups.splice(0).forEach((cleanup) => cleanup()) + document.body.replaceChildren() +}) + +const mount = (view: () => JSX.Element) => { + const host = document.createElement("div") + document.body.append(host) + cleanups.push(web.render(view, host)) + return host +} + +test("live shell commands share the compact compute ledger", () => { + const host = mount(() => + subject.CommandCard({ + command: { + id: "command-test", + projectID: "project", + sessionID: "session", + messageID: "message", + description: "Preparing Titanic dataset", + command: "python prepare.py", + state: "running", + process_id: 42, + started_at: Date.now() - 5_000, + resources: { memory_bytes: 12_000_000, cpu_percent: 75 }, + }, + stopping: false, + onStop: () => undefined, + }), + ) + + expect(host.querySelector(".kernel-card__copy")?.textContent).toContain("Preparing Titanic dataset") + expect(host.querySelector(".kernel-card__copy")?.textContent).toContain("bash · python prepare.py") + expect(host.querySelectorAll(".kernel-card__metric")[0]?.textContent).toBe("12 MBrss") + expect(host.querySelectorAll(".kernel-card__metric")[1]?.textContent).toBe("0.8cores") + expect(host.querySelector('button[aria-label="Stop Preparing Titanic dataset"]')).not.toBeNull() +}) diff --git a/frontend/workspace/src/atlas/CommandCard.tsx b/frontend/workspace/src/atlas/CommandCard.tsx new file mode 100644 index 00000000..f83d3913 --- /dev/null +++ b/frontend/workspace/src/atlas/CommandCard.tsx @@ -0,0 +1,68 @@ +import { createSignal, onCleanup, type JSX } from "solid-js" +import { kernelMemoryLabel, type CommandStatus } from "@/notebook/runtime" + +const memory = (value?: number) => { + const label = kernelMemoryLabel(value) + return label === "Unavailable" ? "—" : label +} + +const cores = (value?: number) => { + if (value === undefined || !Number.isFinite(value) || value < 0) return "—" + return (value / 100).toFixed(1) +} + +const uptime = (started: number, now: number) => { + const seconds = Math.max(0, Math.floor((now - started) / 1_000)) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m` + return `${Math.floor(minutes / 60)}h ${minutes % 60}m` +} + +export function CommandCard(props: { command: CommandStatus; stopping: boolean; onStop: () => void }): JSX.Element { + const [now, setNow] = createSignal(Date.now()) + const timer = setInterval(() => setNow(Date.now()), 1_000) + onCleanup(() => clearInterval(timer)) + + return ( +
+
+ +
+ {props.command.description} + +