/** * Session creation helpers (used by `agent.createSession` for assembly, not exported * via the barrel): Session id generation, runtime environment fields, and temp * Workspace creation. */ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { randomBytes, randomUUID } from "node:crypto"; import { formatLocalDate } from "./dates.js"; import { sessionShell } from "../environment/tools/command/shell.js"; import type { SessionEnvironmentValues } from "../state/agent-state.js"; import { modelVisiblePath } from "./model-visible-path.js"; import { workspacesDir } from "../state/index.js"; import { attachedImageLine, isWholeOriginBlock, userText } from "../omnimessage/index.js"; import type { OmniMessage } from "../omnimessage/index.js"; /** Session runtime environment fields: the placeholder substitution values for `assembleSystemPrompt`; producer and consumer share the same type. */ export type SessionEnvironment = SessionEnvironmentValues; /** Generate a Session id of the form `session-YYYY-MM-DD-HH-mm-ss-<8-hex>` (local timezone, zero-padded: 4-digit year, 2 digits for the rest; hex from randomUUID). */ export function formatSessionId(date: Date = new Date()): string { const pad = (n: number) => n.toString().padStart(2, "0"); const ts = `${formatLocalDate(date)}` + `-${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`; const hex = randomUUID().replace(/-/g, "").slice(0, 8); return `session-${ts}-${hex}`; } /** * Generate this Session's runtime environment fields (injected via specific * placeholders in the system prompt). * This is system-generated runtime context, not sourced from Agent State / Workspace files. */ export function sessionEnvironment( workspaceDir: string, sessionId: string, ids: { agentId: string; projectDir: string; provider: string; modelId: string }, date = new Date(), ): SessionEnvironment { return { sessionId, // Model-visible spelling (forward slashes on Windows): the model composes tool arguments // and shell commands from these two lines, so they must be safe in both contexts. cwd: modelVisiblePath(workspaceDir), agentId: ids.agentId, projectDir: modelVisiblePath(ids.projectDir), provider: ids.provider, modelId: ids.modelId, platform: process.platform, osVersion: getOsVersion(), // The shell exec_command actually runs (bash on POSIX; resolved on Windows): the model // must know whether to write bash or PowerShell syntax. shell: sessionShell().name, date: formatLocalDate(date), }; } function getOsVersion(): string { // os.* is a stable built-in API that normally doesn't throw; but this function only // builds a single line of environment info for the system prompt, so it's not worth // letting an exception take down createSession — fall back to "unknown" instead. try { if (process.platform === "win32") { return `${os.version()} ${os.release()}`; } return `${os.type()} ${os.release()}`; } catch { return "unknown"; } } /** The 8-hex space is 2^32, so the odds of consecutive collisions are negligible; the cap only guards against an infinite loop caused by an abnormal filesystem. */ const MAX_TMP_ID_ATTEMPTS = 16; /** * Create a temporary Workspace under `/workspaces/`, where the * directory name is the workspace_id, shaped like `tmp-<8hex>`; if it collides with * an existing directory, regenerate the id. No symlinks are created inside the Workspace: * the model composes absolute paths (to Agent State, scratchpad, etc.) directly from the * Environment placeholders (App Data Dir / Agent ID) in the system prompt. */ export async function createTempWorkspace( root: string, projectId: string, agentId: string, ): Promise { const base = workspacesDir(root, projectId, agentId); await fs.mkdir(base, { recursive: true }); // The final directory must use a non-recursive mkdir: recursive mkdir succeeds // silently when the directory already exists, which would put a new Session into // an existing temporary workspace; EEXIST means an id collision, so retry with a new id. for (let attempt = 0; attempt < MAX_TMP_ID_ATTEMPTS; attempt++) { const dir = path.join(base, `tmp-${randomUUID().slice(0, 8)}`); try { await fs.mkdir(dir); return dir; } catch (err) { if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; } } throw new Error( `failed to allocate a unique temp workspace id under ${base} after ${MAX_TMP_ID_ATTEMPTS} attempts`, ); } /** * Stands in for a single image that could not be turned into a path line — a data URL that * doesn't parse. Shown to the model and the user instead of silently dropping the attachment. * A scratchpad that can't be written to is a different matter: the fold throws and the run * ends, rather than carrying on without what the sender attached. */ const IMAGE_DROPPED_NOTE = "[an attached image could not be saved and was dropped]"; /** Maps a data URL's mime type to a file extension on disk; unknown mimes use bin (the image-reading tool sniffs the magic bytes and doesn't rely on the extension). */ const MIME_TO_EXT: Record = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", }; /** An input image message — what both conversions below pull out of the input. */ const isImage = (m: OmniMessage): boolean => (m.payload as { type?: string }).type === "image_url"; /** * Input conversion for when the session model doesn't support images: image messages * in the `run` input are written to disk as files (base64 data URLs are saved to the * session scratchpad; http(s) URLs are referenced * as-is), and the path/URL is appended to the user text (an `[attached image: …]` * line); the image message itself is removed from the input — the model views it by * path via describe_image (read on its behalf by a vision model), and images never * enter that session's history directly. * Returns the input unchanged when there are no images; an image that can't be * parsed is replaced with an explanatory line rather than silently dropped. */ export async function imagesToScratchpadPaths( input: OmniMessage[], dir: string, ): Promise { if (!input.some(isImage)) return input; const lines: string[] = []; for (const msg of input) { if (!isImage(msg)) continue; const url = (msg.payload as { image_url?: string }).image_url ?? ""; if (/^https?:\/\//i.test(url)) { lines.push(attachedImageLine(url)); continue; } const match = /^data:([^;,]+);base64,(.+)$/s.exec(url); if (!match) { lines.push(IMAGE_DROPPED_NOTE); continue; } await fs.mkdir(dir, { recursive: true }); const ext = MIME_TO_EXT[match[1]!.toLowerCase()] ?? "bin"; // Filename = upload-<8 random hex chars> (same convention as project-<8hex>; the // prefix distinguishes model-generated temp files). // "wx" flag does exclusive creation to avoid name collisions: on the rare chance of a collision, retry with a new random value. let file: string; for (;;) { file = path.join(dir, `upload-${randomBytes(4).toString("hex")}.${ext}`); try { await fs.writeFile(file, Buffer.from(match[2]!, "base64"), { flag: "wx" }); break; } catch (err) { if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; } } lines.push(attachedImageLine(modelVisiblePath(file))); } return appendAttachmentLines( input.filter((m) => !isImage(m)), lines, ); } /** * Concatenation rule shared by every attachment-line producer (see the markers module's * attachment-lines.ts): the lines are appended as one block after the **last user text * message**; if the input carries no such message (attachments only), they become a plain * line-only text message of their own. * * "User text" here excludes a message that is entirely a whole-message origin block — * `[handoff_from]` / `[model_switch_from]` (isWholeOriginBlock). Those parsers only recognize * the block when it IS the whole message, so appending to one would turn a one-line banner * back into a raw marker in a user bubble. The Web composer reaches exactly that shape when a * message carries attachments, no text, and a staged handoff: its only text message is the * origin block. Such input therefore falls through to the line-only message, leaving the block * intact. Prefix blocks (`[use_skills]`, `[scheduled_task]`) are parsed at index 0 and keep * their own body, so they still take the lines as usual. * * Exported from the package barrel because the server writes `[attached file: …]` lines for * the composer's uploads and must place them exactly the same way — one rule, so a message * carrying both kinds of attachment still reads as a single trailing block. * Returns `input` unchanged when there are no lines to append. */ export function appendAttachmentLines(input: OmniMessage[], lines: string[]): OmniMessage[] { if (lines.length === 0) return input; const suffix = lines.join("\n"); const lastTextIdx = input.findLastIndex((m) => { const p = m.payload as { type?: string; role?: string; text?: string }; return p.type === "text" && p.role === "user" && !isWholeOriginBlock(p.text ?? ""); }); if (lastTextIdx === -1) return [...input, userText(suffix)]; return input.map((m, i) => { if (i !== lastTextIdx) return m; const p = m.payload as { type: string; role: string; text: string }; return { ...m, payload: { ...p, text: `${p.text}\n\n${suffix}` } } as OmniMessage; }); }