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
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-14T17:33:22Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 3b6eafcbe8faee26
raw_hash: 67f0e882288fa8ce
vouched: 2026-10-16T22:27:53Z
body_hash: 3d7816658a739ed7
raw_hash: 85f3d4da91b05dae
vouched: 2026-10-16T23:12:54Z
lines: 114-792
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-15T00:30:42Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 3b6eafcbe8faee26
raw_hash: 67f0e882288fa8ce
vouched: 2026-10-16T22:27:53Z
body_hash: 3d7816658a739ed7
raw_hash: 85f3d4da91b05dae
vouched: 2026-10-16T23:12:54Z
lines: 114-792
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-15T02:34:37Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 3b6eafcbe8faee26
raw_hash: 67f0e882288fa8ce
vouched: 2026-10-16T22:27:53Z
body_hash: 3d7816658a739ed7
raw_hash: 85f3d4da91b05dae
vouched: 2026-10-16T23:12:54Z
lines: 114-794
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-15T10:17:55Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 3b6eafcbe8faee26
raw_hash: 67f0e882288fa8ce
vouched: 2026-10-16T22:27:53Z
body_hash: 3d7816658a739ed7
raw_hash: 85f3d4da91b05dae
vouched: 2026-10-16T23:12:54Z
lines: 121-817
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-16T22:11:35Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 3b6eafcbe8faee26
raw_hash: 67f0e882288fa8ce
vouched: 2026-10-16T22:27:53Z
body_hash: 3d7816658a739ed7
raw_hash: 85f3d4da91b05dae
vouched: 2026-10-16T23:12:54Z
lines: 127-873
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M53FTPSETKJ5KVVERXS3KAHK
anchor: function failed
created: 2026-10-16T23:12:51Z
norm: '1'
sig: ef0729315ff1212a
body_hash: d35c24211c9f9acd
raw_hash: a835bc42c69d89ed
lines: 1033-1039
---

overflow errors are explained in the projection, not stored, so replies already stored by older channels get the recovery hint too
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M53FTPRW0WRSS93WVMFYZBKE
anchor: function checkContext
created: 2026-10-16T23:12:51Z
norm: '1'
sig: 9bf4c87f920895b7
body_hash: 393fd86707463e3c
raw_hash: 338145fbeb8c2ee8
lines: 100-116
---

estimate from UTF-8 bytes, not chars; pi-ai's chars/4 undercounts non-Latin text by ~2x, which is how multilingual PDFs overflowed
37 changes: 33 additions & 4 deletions packages/channel/src/channel.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { getSupportedThinkingLevels, type Model, type Models } from "@earendil-works/pi-ai";
import {
type AssistantMessage,
getSupportedThinkingLevels,
isContextOverflow,
type Model,
type Models,
} from "@earendil-works/pi-ai";
import { BACKGROUND_CONTEXT as context } from "@earendil-works/chord/context";
import {
AgentDoc,
Expand Down Expand Up @@ -589,6 +595,10 @@ export class Channel {
this.#author(request.author);
const conversation = await this.#conversation(request.chat);
room.checkAttachments(request);
// The chat's next run reads this message, so it must fit the model the chat has now.
const { model } = await conversation.agent(context);
const selected = model && this.#options.models.getModel(model.provider, model.modelId);
if (selected) room.checkContext(selected, request);
const entry = room.draft(request.author, request.text, Date.now(), request);
const submission = await conversation.submit({
type: "write",
Expand Down Expand Up @@ -616,7 +626,7 @@ export class Channel {
room.checkAttachments(request);
await this.#mayInvoke(request.author);
const conversation = await this.#conversation(request.chat);
await this.#select(conversation, request.model, request.effort);
await this.#select(conversation, request, request.model, request.effort);
const submission = await conversation.submit({
type: "input",
content: room.content(request.author, request.text, request),
Expand All @@ -637,7 +647,12 @@ export class Channel {
}

/** Model and effort apply to a whole run, never just its later turns. */
async #select(conversation: Conversation, requested: ModelRef | undefined, effort?: Effort) {
async #select(
conversation: Conversation,
attached: room.Attached,
requested: ModelRef | undefined,
effort?: Effort,
) {
const agent = await conversation.agent(context);
const current = agent.model;
// A chat's inherited model can lose its provider key; only an explicit request must keep it.
Expand All @@ -651,6 +666,8 @@ export class Channel {
if (!selected) {
throw new Error(`Unknown model ${model.provider}/${model.modelId}`);
}
// Before configuring, so a refused message leaves the chat's model as it was.
room.checkContext(selected, attached);
const levels = getSupportedThinkingLevels(selected);
if (effort !== undefined && effort !== "off" && !levels.includes(effort)) {
throw new Error(
Expand Down Expand Up @@ -1009,6 +1026,18 @@ function readable(error: string | undefined): string {
}
}

/**
* The transcript keeps whatever overflowed, so every later run in the chat fails the same way until
* a person removes it; channels from before attachments were checked against the model can hold one.
*/
function failed(message: AssistantMessage): string {
const error = readable(message.errorMessage);
if (!isContextOverflow(message)) return error;
const hint = "This chat no longer fits the model's context, so every run fails this way until "
+ "you delete the message with the large attachment or pick a model with a larger context.";
return `${error.replace(/\.?$/, ".")} ${hint}`;
}

function live(chat: ChatId, event: AgentEvent): Event[] {
if (event.type === "message_end" || event.type === "entry_appended") return events(event.entry);
if (event.type === "message_update") {
Expand Down Expand Up @@ -1047,7 +1076,7 @@ function events(entry: EntryRecord): Event[] {
at,
model,
text,
...(error ? { error: readable(message.errorMessage) } : {}),
...(error ? { error: failed(message) } : {}),
...(stopped ? { stopped } : {}),
});
}
Expand Down
39 changes: 38 additions & 1 deletion packages/channel/src/room.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import type { ImageContent, Message, TextContent, UserMessage } from "@earendil-works/pi-ai";
import type {
Api,
ImageContent,
Message,
Model,
TextContent,
UserMessage,
} from "@earendil-works/pi-ai";
import {
defineEntry,
type EntryRecord,
Expand Down Expand Up @@ -80,6 +87,36 @@ export function checkAttachments({ images = [], files = [] }: Attached) {
if (size > MAX_BYTES) throw new Error("Attachments are too large; send smaller or fewer files");
}

/**
* Bytes, not characters: non-Latin text costs more tokens per character, and Keynote's
* multilingual license PDF measured about 2.8 bytes per token on Claude.
*/
const BYTES_PER_TOKEN = 3;

/**
* A message whose files can't fit the model's context fails its run and every later one, since
* the transcript keeps it. The rest of the window holds the prompt, tools, history, and answer.
*/
export function checkContext(model: Model<Api>, { files = [] }: Attached) {
if (!files.length) return;
const encoder = new TextEncoder();
const tokens = files.reduce(
(sum, file) => sum + Math.ceil(encoder.encode(file.text).length / BYTES_PER_TOKEN),
0,
);
const limit = Math.floor(model.contextWindow * 0.75);
if (tokens <= limit) return;
const names = files.map((file) => file.name).join(", ");
const verb = files.length === 1 ? "is" : "are";
throw new Error(
`${names} ${verb} about ${thousands(tokens)} tokens; ${model.name} reads at most about `
+ `${thousands(limit)} tokens of attachments in one message. Attach a smaller file or an `
+ "excerpt, or pick a model with a larger context.",
);
}

const thousands = (tokens: number) => `${Math.round(tokens / 1000)}K`;

export function draft(author: string, text: string, timestamp: number, attached?: Attached) {
return {
kind: MessageEntry.kind,
Expand Down