feat(web,server,core): attach files to a message from the composer (#121)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,10 @@ export type { GoalRunOptions, SessionConfig, SessionRunOptions } from "./session
|
||||
// (stripConversationMarkers) is exported from the markers module via the omnimessage barrel.
|
||||
export { sanitizeTitle } from "./internal/session-title.js";
|
||||
export type { SessionTitleResult } from "./internal/session-title.js";
|
||||
// Session assembly likewise stays internal; only the attachment-line placement rule is
|
||||
// re-exported, because the server appends `[attached file: …]` lines for the composer's
|
||||
// uploads and both producers must place them identically (see the markers module).
|
||||
export { appendAttachmentLines } from "./internal/session-support.js";
|
||||
export { Agent, createAgent } from "./agent.js";
|
||||
export type { CreateAgentOptions, CreateSessionOptions, ResumeSessionOptions } from "./agent.js";
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { formatLocalDate } from "./dates.js";
|
||||
import { sessionShell } from "../environment/tools/command/shell.js";
|
||||
import type { SessionEnvironmentValues } from "../state/agent-state.js";
|
||||
import { workspacesDir } from "../state/index.js";
|
||||
import { userText } from "../omnimessage/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. */
|
||||
@@ -135,7 +135,7 @@ export async function imagesToScratchpadPaths(
|
||||
if (!isImage(msg)) continue;
|
||||
const url = (msg.payload as { image_url?: string }).image_url ?? "";
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
lines.push(`[attached image: ${url}]`);
|
||||
lines.push(attachedImageLine(url));
|
||||
continue;
|
||||
}
|
||||
const match = /^data:([^;,]+);base64,(.+)$/s.exec(url);
|
||||
@@ -158,18 +158,44 @@ export async function imagesToScratchpadPaths(
|
||||
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
||||
}
|
||||
}
|
||||
lines.push(`[attached image: ${file}]`);
|
||||
lines.push(attachedImageLine(file));
|
||||
}
|
||||
|
||||
// Concatenation: the path lines are appended after the last user text message; if the input is images only, add a plain path-only text message.
|
||||
const rest = input.filter((m) => !isImage(m));
|
||||
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 = rest.findLastIndex((m) => {
|
||||
const p = m.payload as { type?: string; role?: string };
|
||||
return p.type === "text" && p.role === "user";
|
||||
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 [...rest, userText(suffix)];
|
||||
return rest.map((m, i) => {
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* `[attached image: …]` / `[attached file: …]` — the one-line markers that hand a file on
|
||||
* disk to the model.
|
||||
*
|
||||
* Unlike this module's other markers these are not `[tag]…[/tag]` blocks and they are
|
||||
* **appended after** the user's own text rather than prefixed to it. That placement is
|
||||
* load-bearing: the origin blocks and `[use_skills]` are all parsed at index 0 of the
|
||||
* message, so a second leading block would break that chain — an attachment is a trailing
|
||||
* footnote to the message, not a new frame around it.
|
||||
*
|
||||
* Two producers write them, for the same reason (the bytes cannot travel in the message
|
||||
* itself, so the message carries their path instead):
|
||||
* - `[attached image: <path|URL>]` — core, when the session model has no image input: the
|
||||
* input images are written to the session scratchpad and the model reads them by path via
|
||||
* describe_image (see internal/session-support.ts);
|
||||
* - `[attached file: <path>]` — the server, for the composer's file attachments: the upload
|
||||
* lands in the session scratchpad and the model opens it with its ordinary file tools
|
||||
* (see services/task-attachments.ts).
|
||||
*
|
||||
* One consumer reads them: the Web App's message renderer, which pulls the lines back out of
|
||||
* the body text and shows an image / an attachment banner instead (see lib/attachments.ts).
|
||||
* Producer and parser share the spellings below so the two can never drift apart.
|
||||
*/
|
||||
|
||||
/** Line prefix of an image attachment; exported so a parser can cheaply pre-test a whole body text. */
|
||||
export const ATTACHED_IMAGE_PREFIX = "[attached image: ";
|
||||
/** Line prefix of a file attachment (same purpose as ATTACHED_IMAGE_PREFIX). */
|
||||
export const ATTACHED_FILE_PREFIX = "[attached file: ";
|
||||
|
||||
/** The line naming one input image by absolute path (image-less model) or by http(s) URL (referenced as-is). */
|
||||
export function attachedImageLine(target: string): string {
|
||||
return `${ATTACHED_IMAGE_PREFIX}${target}]`;
|
||||
}
|
||||
|
||||
/** The line naming one uploaded file by absolute path. */
|
||||
export function attachedFileLine(filePath: string): string {
|
||||
return `${ATTACHED_FILE_PREFIX}${filePath}]`;
|
||||
}
|
||||
|
||||
const ATTACHED_IMAGE_RE = /^\[attached image: (.+)\]$/;
|
||||
const ATTACHED_FILE_RE = /^\[attached file: (.+)\]$/;
|
||||
|
||||
/** Inverse of `attachedImageLine`: the target of a whole line that is one image attachment, else null. */
|
||||
export function matchAttachedImageLine(line: string): string | null {
|
||||
return ATTACHED_IMAGE_RE.exec(line)?.[1] ?? null;
|
||||
}
|
||||
|
||||
/** Inverse of `attachedFileLine`: the path of a whole line that is one file attachment, else null. */
|
||||
export function matchAttachedFileLine(line: string): string | null {
|
||||
return ATTACHED_FILE_RE.exec(line)?.[1] ?? null;
|
||||
}
|
||||
@@ -15,6 +15,11 @@
|
||||
* - **goal** (`goal-block.ts`): `[goal]`, the goal-mode round protocol block prefixed to
|
||||
* each round's input by the Session's goal loop (line-anchored close — see the module).
|
||||
*
|
||||
* `attachment-lines.ts` is the one exception to the block form: `[attached image: …]` /
|
||||
* `[attached file: …]` are single lines **appended after** a user message, naming a file the
|
||||
* model must open by path. They live here for the same reason the blocks do — core, the
|
||||
* server and the Web renderer all have to spell them identically.
|
||||
*
|
||||
* `block.ts` owns the spelling itself — the canonical square form for producers and the
|
||||
* dual-form (square + legacy angle) matching every parser applies, because markers persist in
|
||||
* Traces and in each agent's stored compaction prompt. `tags.ts` owns the tag list.
|
||||
@@ -24,6 +29,7 @@
|
||||
*/
|
||||
export * from "./block.js";
|
||||
export * from "./tags.js";
|
||||
export * from "./attachment-lines.js";
|
||||
export * from "./engine-blocks.js";
|
||||
export * from "./origin-blocks.js";
|
||||
export * from "./steering.js";
|
||||
|
||||
@@ -281,3 +281,25 @@ export function parseModelSwitchMessage(text: string): ModelSwitchOrigin | null
|
||||
}
|
||||
return origin.sessionId ? origin : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared predicate over the whole-message origin blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* True when `text` is **entirely** one origin block whose parser demands a whole-message match:
|
||||
* `[handoff_from]` and `[model_switch_from]`, both of which compare the match length against the
|
||||
* trimmed message. Anything appended after such a block — not just prefixed to it — makes it
|
||||
* unparseable, and the raw marker then renders verbatim in a user bubble.
|
||||
*
|
||||
* Exported for the producers that append to an existing message (see core's
|
||||
* `appendAttachmentLines`): a message of this shape is a machine-written frame, not user text,
|
||||
* and must be left alone. Deliberately implemented by running the parsers rather than
|
||||
* re-testing their patterns, so the predicate cannot drift from what they accept.
|
||||
*
|
||||
* `[use_skills]` and `[scheduled_task]` are **not** included: they are prefix blocks followed by
|
||||
* the message's own body and are parsed at index 0 only, so appending after that body is safe.
|
||||
*/
|
||||
export function isWholeOriginBlock(text: string): boolean {
|
||||
return parseHandoffMessage(text) !== null || parseModelSwitchMessage(text) !== null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user