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
16 changes: 10 additions & 6 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,21 +235,23 @@ POST /api/v1/conversations

Creates a new conversation, optionally with initial messages.

Message writes are limited to **100 messages per request**, **64 KiB (65,536 UTF-8 bytes) per content string**, and **1 MiB (1,048,576 UTF-8 bytes) of combined content**. These limits also apply to appending messages, trace ingestion (`observations`), and the MCP `store_conversation` tool. REST rejects violations before writing with `400 BAD_REQUEST`; MCP returns an `isError` tool result containing `INVALID_PARAMS`. Empty initial message arrays remain valid; append and trace ingestion require at least one item.

**Request body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `external_id` | string | No | Caller-provided identifier. Must be unique within the project. |
| `title` | string | No | Conversation title. |
| `metadata` | object | No | Arbitrary key-value pairs. |
| `messages` | array | No | Initial messages to attach. |
| `messages` | array | No | Initial messages to attach (maximum 100). Combined message content must not exceed 1 MiB (1,048,576 UTF-8 bytes). |

Each message object:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `role` | string | Yes | One of: `system`, `user`, `assistant`, `tool`. |
| `content` | string | Yes | Message content (min 1 character). |
| `content` | string | Yes | Non-empty message content, at most 64 KiB (65,536 UTF-8 bytes). |
| `metadata` | object | No | Arbitrary key-value pairs. |
| `token_count` | integer | No | Non-negative integer. Defaults to 0. |

Expand Down Expand Up @@ -469,7 +471,7 @@ Add one or more messages to an existing conversation. Automatically updates the

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `messages` | array | Yes | At least 1 message. Same schema as [Create Conversation](#create-conversation) messages. |
| `messages` | array | Yes | 1–100 messages; each content string at most 64 KiB and combined content at most 1 MiB (UTF-8 bytes). Same schema as [Create Conversation](#create-conversation) messages. |

**Response:** `201 Created`

Expand Down Expand Up @@ -988,21 +990,23 @@ POST /v1/conversations

Creates a new conversation, optionally with initial messages.

Message writes are limited to **100 messages per request**, **64 KiB (65,536 UTF-8 bytes) per content string**, and **1 MiB (1,048,576 UTF-8 bytes) of combined content**. These limits also apply to appending messages, trace ingestion (`observations`), and the MCP `store_conversation` tool. REST rejects violations before writing with `400 BAD_REQUEST`; MCP returns an `isError` tool result containing `INVALID_PARAMS`. Empty initial message arrays remain valid; append and trace ingestion require at least one item.

**Request body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `external_id` | string | No | Caller-provided identifier. Must be unique within the project. |
| `title` | string | No | Conversation title. |
| `metadata` | object | No | Arbitrary key-value pairs. |
| `messages` | array | No | Initial messages to attach. |
| `messages` | array | No | Initial messages to attach (maximum 100). Combined message content must not exceed 1 MiB (1,048,576 UTF-8 bytes). |

Each message object:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `role` | string | Yes | One of: `system`, `user`, `assistant`, `tool`. |
| `content` | string | Yes | Message content (min 1 character). |
| `content` | string | Yes | Non-empty message content, at most 64 KiB (65,536 UTF-8 bytes). |
| `metadata` | object | No | Arbitrary key-value pairs. |
| `token_count` | integer | No | Non-negative integer. Defaults to 0. |

Expand Down Expand Up @@ -1365,7 +1369,7 @@ Add one or more messages to an existing conversation. Automatically updates the

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `messages` | array | Yes | At least 1 message. Same schema as [Create Conversation](#create-conversation) messages. |
| `messages` | array | Yes | 1–100 messages; each content string at most 64 KiB and combined content at most 1 MiB (UTF-8 bytes). Same schema as [Create Conversation](#create-conversation) messages. |

**Response:** `201 Created`

Expand Down
45 changes: 40 additions & 5 deletions packages/api/src/lib/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,24 @@ export const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
// Message schemas
// ---------------------------------------------------------------------------

export const MAX_MESSAGES_PER_REQUEST = 100;
export const MAX_MESSAGE_CONTENT_BYTES = 64 * 1024;
export const MAX_MESSAGES_CONTENT_BYTES = 1024 * 1024;

const utf8Encoder = new TextEncoder();

export const MessageContentSchema = z
.string()
.min(1)
.max(MAX_MESSAGE_CONTENT_BYTES)
.refine((content) => utf8Encoder.encode(content).byteLength <= MAX_MESSAGE_CONTENT_BYTES, {
message: `Message content must not exceed ${MAX_MESSAGE_CONTENT_BYTES} UTF-8 bytes`,
})
.describe("Non-empty content, at most 65536 UTF-8 bytes (64 KiB)");

export const MessageInputSchema = z.object({
role: z.enum(MESSAGE_ROLES),
content: z.string().min(1),
content: MessageContentSchema,
metadata: z.record(z.string(), z.unknown()).optional(),
token_count: z.number().int().nonnegative().optional(),
model: z.string().max(100).optional(),
Expand All @@ -50,6 +65,26 @@ export const MessageInputSchema = z.object({
});
export type MessageInput = z.infer<typeof MessageInputSchema>;

export function boundedMessagesSchema<T extends z.ZodType<{ content: string }>>(itemSchema: T) {
return z
.array(itemSchema)
.max(MAX_MESSAGES_PER_REQUEST)
.refine(
(items) => {
let bytes = 0;
for (const item of items) {
bytes += utf8Encoder.encode(item.content).byteLength;
if (bytes > MAX_MESSAGES_CONTENT_BYTES) return false;
}
return true;
},
{ message: `Combined message content must not exceed ${MAX_MESSAGES_CONTENT_BYTES} UTF-8 bytes` },
)
.describe("At most 100 messages; combined content at most 1048576 UTF-8 bytes (1 MiB)");
}

export const MessagesInputSchema = boundedMessagesSchema(MessageInputSchema);

// ---------------------------------------------------------------------------
// Conversation schemas
// ---------------------------------------------------------------------------
Expand All @@ -58,7 +93,7 @@ export const CreateConversationSchema = z.object({
external_id: z.string().optional(),
title: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
messages: z.array(MessageInputSchema).optional(),
messages: MessagesInputSchema.optional(),
});
export type CreateConversationInput = z.infer<typeof CreateConversationSchema>;

Expand All @@ -69,7 +104,7 @@ export const UpdateConversationSchema = z.object({
export type UpdateConversationInput = z.infer<typeof UpdateConversationSchema>;

export const AppendMessagesSchema = z.object({
messages: z.array(MessageInputSchema).min(1),
messages: MessagesInputSchema.min(1),
});
export type AppendMessagesInput = z.infer<typeof AppendMessagesSchema>;

Expand Down Expand Up @@ -277,7 +312,7 @@ export type CreateClaimInput = z.infer<typeof CreateClaimSchema>;

const ObservationInputSchema = z.object({
role: z.enum(MESSAGE_ROLES).optional().default("assistant"),
content: z.string().min(1),
content: MessageContentSchema,
parent_message_id: z.string().optional(),
observation_type: z.enum(OBSERVATION_TYPES),
metadata: z.record(z.string(), z.unknown()).optional(),
Expand All @@ -298,6 +333,6 @@ export const IngestTraceSchema = z.object({
title: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
observations: z.array(ObservationInputSchema).min(1).max(100),
observations: boundedMessagesSchema(ObservationInputSchema).min(1),
});
export type IngestTraceInput = z.infer<typeof IngestTraceSchema>;
10 changes: 7 additions & 3 deletions packages/api/src/routes/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { z } from "zod";
import { conversations as conversationsTable, messages as messagesTable } from "../../db/schema";
import { GrantableScopeSchema, scopesSatisfyAll } from "../../lib/scopes";
import { deserializeConversationFull, deserializeMessage } from "../../lib/serialization";
import { CapabilityScopeSchema } from "../../lib/validation";
import {
boundedMessagesSchema,
CapabilityScopeSchema,
MessageContentSchema,
} from "../../lib/validation";
import * as capabilityTokensService from "../../services/capability-tokens";
import * as claimsService from "../../services/claims";
import * as keysService from "../../services/keys";
Expand Down Expand Up @@ -46,7 +50,7 @@ export class ToolError extends Error {

const messageSchema = z.object({
role: z.enum(["user", "assistant", "system", "tool"]),
content: z.string(),
content: MessageContentSchema,
metadata: z.record(z.string(), z.unknown()).optional(),
token_count: z.number().int().optional(),
});
Expand Down Expand Up @@ -82,7 +86,7 @@ const storeConversationSchema = z.object({
external_id: z.string().optional().describe("Optional external identifier for deduplication"),
title: z.string().optional().describe("Human-readable title for the conversation"),
metadata: z.record(z.string(), z.unknown()).optional().describe("Arbitrary JSON metadata"),
messages: z.array(messageSchema).optional().describe("Initial messages to include"),
messages: boundedMessagesSchema(messageSchema).optional(),
});

const recallConversationSchema = z.object({
Expand Down
3 changes: 2 additions & 1 deletion packages/api/src/services/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { serializeMetadata } from "../lib/serialization";
import { TagSchema } from "../lib/validation";
import { sendWebhookWithRetry } from "../lib/webhook";
import * as webhooksService from "../services/webhooks";
import { insertMessageRows } from "./messages";

// ---------------------------------------------------------------------------
// Field Selection Types and Utilities
Expand Down Expand Up @@ -238,7 +239,7 @@ export async function createConversation(
createdAt: now,
}));

await db.insert(messages).values(rows);
await insertMessageRows(db, rows);
messageRows = rows as (typeof messages.$inferSelect)[];
}

Expand Down
3 changes: 2 additions & 1 deletion packages/api/src/services/mcp-conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { invalidateAnalyticsCache } from "../lib/analytics-cache";
import { CACHE_COUNT_TTL_S } from "../lib/config";
import { generateId } from "../lib/id";
import { serializeMetadata } from "../lib/serialization";
import { insertMessageRows } from "./messages";

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -206,7 +207,7 @@ export async function createConversation(
createdAt: now,
}));

await db.insert(messages).values(rows);
await insertMessageRows(db, rows);
}

invalidateAnalyticsCache(cache, executionCtx, projectId);
Expand Down
19 changes: 18 additions & 1 deletion packages/api/src/services/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@ import { generateId } from "../lib/id";
import { serializeMetadata } from "../lib/serialization";
import type { MessageInput } from "../lib/validation";

/** D1 permits 100 bound parameters per statement; a message has 17 columns. */
export const MESSAGE_INSERT_CHUNK_SIZE = 5;

export async function insertMessageRows(
db: DrizzleD1Database,
rows: (typeof messages.$inferInsert)[],
): Promise<void> {
if (rows.length === 0) return;
const first = db.insert(messages).values(rows.slice(0, MESSAGE_INSERT_CHUNK_SIZE));
const remaining = [];
for (let index = MESSAGE_INSERT_CHUNK_SIZE; index < rows.length; index += MESSAGE_INSERT_CHUNK_SIZE) {
remaining.push(db.insert(messages).values(rows.slice(index, index + MESSAGE_INSERT_CHUNK_SIZE)));
}
// D1 batch is transactional, so a failed chunk cannot leave a partial append.
await db.batch([first, ...remaining]);
}

/**
* Append messages to a conversation.
*
Expand Down Expand Up @@ -44,7 +61,7 @@ export async function appendMessages(
createdAt: now,
}));

await db.insert(messages).values(messageRows);
await insertMessageRows(db, messageRows);

const addedTokens = inputMessages.reduce((sum, m) => sum + (m.token_count ?? 0), 0);
const addedCost = inputMessages.reduce((sum, m) => sum + (m.cost_microdollars ?? 0), 0);
Expand Down
3 changes: 2 additions & 1 deletion packages/api/src/services/traces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "../lib/serialization";
import { buildObservationTree } from "../lib/trace-tree";
import type { IngestTraceInput } from "../lib/validation";
import { insertMessageRows } from "./messages";

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -104,7 +105,7 @@ export async function ingestTrace(
}
}

await db.insert(messages).values(messageRows);
await insertMessageRows(db, messageRows);

const conversation = {
id: conversationId,
Expand Down
Loading