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
30 changes: 21 additions & 9 deletions backend/cli/src/agent/prompt/research.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,50 @@ 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

- You own the task from request to result.
- 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.

Expand Down
75 changes: 75 additions & 0 deletions backend/cli/src/science/command/registry.ts
Original file line number Diff line number Diff line change
@@ -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<typeof CommandStatus>

type Entry = CommandStatus & {
process: ChildProcess
stop: () => Promise<void>
}

const entries = new Map<string, Entry>()

export namespace CommandRuntime {
export function start(
input: Omit<CommandStatus, "id" | "state" | "process_id" | "started_at" | "resources">,
process: ChildProcess,
stop: () => Promise<void>,
) {
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
}
}
138 changes: 88 additions & 50 deletions backend/cli/src/server/routes/notebook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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(),
Expand Down Expand Up @@ -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
Expand All @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
1 change: 1 addition & 0 deletions backend/cli/src/session/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading