e8499befd4
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
216 lines
9.5 KiB
TypeScript
216 lines
9.5 KiB
TypeScript
/**
|
|
* 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 `<agent>/workspaces/<workspace_id>`, 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<string> {
|
|
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<string, string> = {
|
|
"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<OmniMessage[]> {
|
|
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;
|
|
});
|
|
}
|