From ca26ace78c07cfdac60ab9d23e4188248708b9bc Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+its-janghoon@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:11:14 +0000 Subject: [PATCH 1/3] feat(chat): report token usage on the streamed finish frame The browser runs memory extraction over streaming /v1/chat/completions, and the stream never carried usage: the JSON body mapped it, the finish chunk did not. Memory runs therefore recorded 0 tokens. The finish chunk now carries `usage` in the same OpenAI shape the JSON body uses (one shared mapping), falling back to the last step-finish usage when the provider reports only per step. ChatCompletionChunk already declared `usage`, so the contract and SDK are unchanged. --- .../server/src/handlers/chat-completion.ts | 50 +++++++++++++------ 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/packages/server/src/handlers/chat-completion.ts b/packages/server/src/handlers/chat-completion.ts index 8d3570005f..a2637170b3 100644 --- a/packages/server/src/handlers/chat-completion.ts +++ b/packages/server/src/handlers/chat-completion.ts @@ -342,20 +342,27 @@ function toJsonBody(id: string, model: string, response: LLMResponse): unknown { finish_reason: toFinishReason(response.finishReason), }, ], - ...(usage - ? { - usage: { - prompt_tokens: usage.inputTokens ?? 0, - completion_tokens: usage.outputTokens ?? 0, - total_tokens: usage.totalTokens ?? 0, - }, - } - : {}), + ...(usage ? { usage: toWireUsage(usage) } : {}), + } +} + +/** The engine's usage, in OpenAI's `usage` shape. One mapping for the JSON body and the stream. */ +function toWireUsage(usage: NonNullable) { + return { + prompt_tokens: usage.inputTokens ?? 0, + completion_tokens: usage.outputTokens ?? 0, + total_tokens: usage.totalTokens ?? 0, } } /** One SSE frame carrying an OpenAI chunk. */ -function chunkFrame(id: string, model: string, delta: unknown, finishReason: string | null): Sse.Event { +function chunkFrame( + id: string, + model: string, + delta: unknown, + finishReason: string | null, + usage?: ReturnType, +): Sse.Event { return { _tag: "Event", event: "message", @@ -366,6 +373,7 @@ function chunkFrame(id: string, model: string, delta: unknown, finishReason: str created: seconds(), model, choices: [{ index: 0, delta, finish_reason: finishReason }], + ...(usage ? { usage } : {}), }), } } @@ -386,13 +394,25 @@ function doneFrame(): Sse.Event { * caller would read a truncated answer as a complete one. */ function toChunks(id: string, model: string, events: Stream.Stream) { + // Usage rides on the finish frame, as OpenAI's own final chunk carries it. A provider that + // reports it only per step still gets counted: the last step's usage stands in. Without this the + // streaming path dropped usage entirely, and the browser's memory runs recorded 0 tokens. + let stepUsage: LLMResponse["usage"] return events.pipe( - Stream.flatMap((event: LLMEvent) => Stream.fromIterable(eventFrames(id, model, event))), + Stream.flatMap((event: LLMEvent) => { + if (event.type === "step-finish" && event.usage !== undefined) stepUsage = event.usage + return Stream.fromIterable(eventFrames(id, model, event, stepUsage)) + }), Stream.concat(Stream.make(doneFrame())), ) } -function eventFrames(id: string, model: string, event: LLMEvent): ReadonlyArray { +function eventFrames( + id: string, + model: string, + event: LLMEvent, + stepUsage?: LLMResponse["usage"], +): ReadonlyArray { switch (event.type) { case "text-delta": return [chunkFrame(id, model, { content: event.text }, null)] @@ -402,8 +422,10 @@ function eventFrames(id: string, model: string, event: LLMEvent): ReadonlyArray< const [call] = toWireToolCalls([{ id: event.id, name: event.name, input: event.input }]) return [chunkFrame(id, model, { tool_calls: [{ index: 0, ...call }] }, null)] } - case "finish": - return [chunkFrame(id, model, {}, toFinishReason(event.reason))] + case "finish": { + const usage = event.usage ?? stepUsage + return [chunkFrame(id, model, {}, toFinishReason(event.reason), usage ? toWireUsage(usage) : undefined)] + } case "provider-error": // Surfaced as data, not as a stream failure: headers are already sent, so // this is the only way the caller learns the turn broke. From 678c61802a1f5a94d9ec240119089677847cbab8 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+its-janghoon@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:13:48 +0000 Subject: [PATCH 2/3] feat(session): arm skills by the caller's tab URL in agent sessions Agent sessions armed skills by keywords only; the URL half waited for a tab URL the engine did not hold. The session prompt body now takes an optional `pageUrl`, stored on the user message, and the loop passes it to SystemPrompt.armedSkills -- the same SkillArming.arm the chat-completions route runs on `redrob_context.page_url`. The prompt body and UserMessage schemas gained an optional field, so the generated SDK clients need `bun run generate` before a caller can send it typed. --- docs/LOCAL-ENGINE-API.md | 3 ++- packages/redrob/src/session/prompt.ts | 17 +++++++++++------ packages/schema/src/v1/session.ts | 6 ++++++ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/LOCAL-ENGINE-API.md b/docs/LOCAL-ENGINE-API.md index 1e2a9471c5..a02626e022 100644 --- a/docs/LOCAL-ENGINE-API.md +++ b/docs/LOCAL-ENGINE-API.md @@ -91,7 +91,8 @@ keywords: so `홈택스` matches `홈택스에서`. - **urls** match `redrob_context.page_url`. The scheme is ignored, `*` stays inside one path segment, `**` crosses segments, and a trailing `/*` in `urls:` covers the whole - site including its root. + site including its root. In agent sessions the same matcher reads the optional + `pageUrl` field of the session prompt body. - Armed bodies go in as one extra `system` message after the caller's own system messages, labelled with what armed each one. A skill with neither list never arms and behaves as before; in agent sessions an armable skill is left out of the standing diff --git a/packages/redrob/src/session/prompt.ts b/packages/redrob/src/session/prompt.ts index 2eb8313f96..86a7c23811 100644 --- a/packages/redrob/src/session/prompt.ts +++ b/packages/redrob/src/session/prompt.ts @@ -698,6 +698,7 @@ const layer = Layer.effect( }, system: input.system, format: input.format, + ...(input.pageUrl?.trim() ? { pageUrl: input.pageUrl.trim() } : {}), } const current = yield* sessions.get(input.sessionID).pipe(Effect.orDie) @@ -1300,18 +1301,18 @@ const layer = Layer.effect( * otherwise arm on half of what the user said. * * The URL is deliberately NOT fetched. Arming by URL glob needs the active - * tab, and the only way to get it is a browser request -- a round trip on - * every single message, which with no extension attached spends the request - * deadline before the model sees anything. A twenty second pause before each - * reply is a worse product than URL arming is a better one. Keywords arm - * today; the URL half arms once a session carries its tab URL as state the - * engine already holds, rather than as a question it has to ask. + * tab, and asking the browser for it is a round trip on every single message, + * which with no extension attached spends the request deadline before the + * model sees anything. Instead the caller states it: `pageUrl` on the prompt + * is stored on the user message, and arms through the same matcher the + * chat-completions route runs on `redrob_context.page_url`. */ const armedSkills = yield* sys.armedSkills(agent, { prompt: (lastUserMsg?.parts ?? []) .filter((part) => part.type === "text") .map((part) => (part.type === "text" ? part.text : "")) .join("\n"), + url: lastUser.pageUrl, }) const system = [ ...env, @@ -1563,6 +1564,10 @@ export const PromptInput = Schema.Struct({ format: Schema.optional(SessionV1.Format), system: Schema.optional(Schema.String), variant: Schema.optional(Schema.String), + pageUrl: Schema.optional(Schema.String).annotate({ + description: + "URL of the caller's active browser tab. Skills whose `urls:` globs match it arm for this turn, as on /v1/chat/completions.", + }), parts: Schema.Array( Schema.Union([ SessionV1.TextPartInput, diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts index 17e7a2c5aa..062506c964 100644 --- a/packages/schema/src/v1/session.ts +++ b/packages/schema/src/v1/session.ts @@ -351,6 +351,12 @@ export const User = Schema.Struct({ }), system: Schema.optional(Schema.String), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), + /** + * The URL of the browser tab the caller was on when it sent this message. Skills whose + * `urls:` globs match it arm for the turn, the same matcher `/v1/chat/completions` runs + * on `redrob_context.page_url`. Absent for callers with no tab. + */ + pageUrl: Schema.optional(Schema.String), }).annotate({ identifier: "UserMessage" }) export type User = Types.DeepMutable> From 9e9893be16289af06c421883ef1cae4c451da49e Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+its-janghoon@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:57:57 +0000 Subject: [PATCH 3/3] chore(sdk): regenerate v2 client for the session prompt pageUrl field Only the pageUrl hunks are taken. A full regeneration also pulls in channel and bridged-app types that develop has not regenerated yet, and those collide with names src/v2/client.ts already exports. --- packages/sdk/js/src/v2/gen/sdk.gen.ts | 4 ++++ packages/sdk/js/src/v2/gen/types.gen.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 08c950a1db..1d2609c0bb 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -3871,6 +3871,7 @@ export class Session2 extends HeyApiClient { format?: OutputFormat system?: string variant?: string + pageUrl?: string parts?: Array }, options?: Options, @@ -3891,6 +3892,7 @@ export class Session2 extends HeyApiClient { { in: "body", key: "format" }, { in: "body", key: "system" }, { in: "body", key: "variant" }, + { in: "body", key: "pageUrl" }, { in: "body", key: "parts" }, ], }, @@ -4224,6 +4226,7 @@ export class Session2 extends HeyApiClient { format?: OutputFormat system?: string variant?: string + pageUrl?: string parts?: Array }, options?: Options, @@ -4244,6 +4247,7 @@ export class Session2 extends HeyApiClient { { in: "body", key: "format" }, { in: "body", key: "system" }, { in: "body", key: "variant" }, + { in: "body", key: "pageUrl" }, { in: "body", key: "parts" }, ], }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 91f487c01a..5a62723601 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -310,6 +310,7 @@ export type UserMessage = { tools?: { [key: string]: boolean } + pageUrl?: string } export type ProviderAuthError = { @@ -10384,6 +10385,7 @@ export type SessionPromptData = { format?: OutputFormat system?: string variant?: string + pageUrl?: string parts: Array } path: { @@ -10731,6 +10733,7 @@ export type SessionPromptAsyncData = { format?: OutputFormat system?: string variant?: string + pageUrl?: string parts: Array } path: {