-
Notifications
You must be signed in to change notification settings - Fork 11
feat(ai-chat): route Auto model by task signals #599
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
91f3abb
2cefa29
9fef534
b8ed446
944f556
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import type { AIInspectorEvent } from "#/features/workspaces/ai/ai-inspector"; | ||
| import { getAIInspectorRunViews } from "#/features/workspaces/ai/ai-inspector-view-model"; | ||
|
|
||
| describe("AI inspector run views", () => { | ||
| it("surfaces the requested and routed model for auto turns", () => { | ||
| const [run] = getAIInspectorRunViews([ | ||
| { | ||
| id: "event-1", | ||
| runId: "run-1", | ||
| sequence: 1, | ||
| createdAt: 1, | ||
| type: "turn.started", | ||
| payload: { | ||
| modelId: "claude-haiku", | ||
| requestedModelId: "auto", | ||
| routingReason: "simple-chat", | ||
| system: "system prompt", | ||
| }, | ||
| } satisfies AIInspectorEvent, | ||
| ]); | ||
|
|
||
| expect(run.modelId).toBe("claude-haiku"); | ||
| expect(run.requestedModelId).toBe("auto"); | ||
| expect(run.routingReason).toBe("simple-chat"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,10 @@ import { | |
| getWorkspaceAiGatewayProviderOptions, | ||
| getWorkspaceAiLanguageModel, | ||
| } from "#/features/workspaces/ai/ai-thread-runtime"; | ||
| import { | ||
| extractWorkspaceAiRoutingSignals, | ||
| routeWorkspaceAiAutoModel, | ||
| } from "#/features/workspaces/ai/model-router"; | ||
| import { | ||
| DEFAULT_WORKSPACE_AI_CHAT_MODEL_ID, | ||
| getWorkspaceAiChatModel, | ||
|
|
@@ -69,7 +73,11 @@ type AIThreadRunSettlement = | |
| }; | ||
|
|
||
| interface AIThreadUsageContext { | ||
| /** Model actually run and metered; equals requestedModelId unless Auto routed. */ | ||
| modelId: WorkspaceAiChatModelId; | ||
| /** Model the user picked; "auto" when the router chose modelId. */ | ||
| requestedModelId: WorkspaceAiChatModelId; | ||
| routingReason?: string; | ||
| thread: AIThreadContext; | ||
| } | ||
|
|
||
|
|
@@ -173,9 +181,14 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) { | |
| void this.keepAliveWhile(() => this._maybeGenerateThreadTitle()); | ||
| } | ||
|
|
||
| const modelId = resolveWorkspaceAiChatModelId(ctx.body?.modelId); | ||
| const requestedModelId = resolveWorkspaceAiChatModelId(ctx.body?.modelId); | ||
| const route = this._resolveAutoModelRoute(requestedModelId, ctx); | ||
| const modelId = route?.modelId ?? requestedModelId; | ||
|
|
||
| if (!ctx.continuation) { | ||
| // Enforcement is currently disabled inside this check. When it lands | ||
| // it must gate on the routed model's billing tier (modelId here), not | ||
| // the requested picker id, so Auto turns cannot dodge premium gating. | ||
| const access = await checkWorkspaceAiMessageAccess({ | ||
| env: this.env, | ||
| modelId, | ||
|
|
@@ -188,6 +201,8 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) { | |
|
|
||
| this.activeUsageContext = { | ||
| modelId, | ||
| requestedModelId, | ||
| routingReason: route?.reason, | ||
| thread, | ||
| }; | ||
| } | ||
|
|
@@ -213,6 +228,8 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) { | |
| await this.telemetry.recordTurnStarted({ | ||
| ctx, | ||
| modelId, | ||
| requestedModelId, | ||
| routingReason: route?.reason, | ||
| system, | ||
| thread, | ||
| tools: activeTools, | ||
|
|
@@ -293,6 +310,39 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) { | |
| return this.telemetry.getInspectorSnapshot(this.name); | ||
| } | ||
|
|
||
| // Auto resolves to a concrete standard-tier model at turn time; every | ||
| // other picker id passes through untouched. Continuation turns reuse the | ||
| // id routed when the run started so a run never switches models | ||
| // mid-flight; if that state is gone (e.g. recovery), we route again from | ||
| // the current turn's signals. | ||
| private _resolveAutoModelRoute( | ||
| requestedModelId: WorkspaceAiChatModelId, | ||
| ctx: TurnContext, | ||
| ): { modelId: WorkspaceAiChatModelId; reason: string } | undefined { | ||
| if (requestedModelId !== "auto") { | ||
| return undefined; | ||
| } | ||
|
|
||
| const activeContext = ctx.continuation ? this.activeUsageContext : undefined; | ||
|
|
||
| if (activeContext && activeContext.requestedModelId !== "auto") { | ||
| // The run started from an explicit picker choice; a continuation | ||
| // whose body lost the selection must not be re-routed. | ||
| return undefined; | ||
| } | ||
|
|
||
| if (activeContext) { | ||
| return { | ||
| modelId: activeContext.modelId, | ||
| reason: activeContext.routingReason ?? "turn-continuation", | ||
| }; | ||
| } | ||
|
|
||
| const routed = routeWorkspaceAiAutoModel(extractWorkspaceAiRoutingSignals(ctx)); | ||
|
|
||
| return { modelId: routed.modelId, reason: routed.reason }; | ||
| } | ||
|
|
||
|
Comment on lines
+333
to
+345
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check if activeUsageContext (or equivalent) is persisted to durable storage anywhere,
# and how "continuation" is defined/triggered relative to DO hibernation.
rg -n "activeUsageContext" -C3 --type=ts
rg -n "continuation" -C3 --type=ts src/features/workspaces/ai/ai-thread.tsRepository: ThinkEx-OSS/thinkex Length of output: 157 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Map the target file and read the relevant sections.
wc -l src/features/workspaces/ai/ai-thread.ts
sed -n '160,460p' src/features/workspaces/ai/ai-thread.ts
# Search for persistence/restoration and continuation-related paths.
rg -n "activeUsageContext|usageContext|routeWorkspaceAiAutoModel|extractWorkspaceAiRoutingSignals|beforeTurn|_trackCompletedMessageUsage|continuation|durable|storage|hibernate|hibernation|restore|recover" src/features/workspaces/ai -C 3
rg -n "activeUsageContext|usageContext|continuation|hibernate|hibernation|durable|storage|restore|recover" src -C 2Repository: ThinkEx-OSS/thinkex Length of output: 50377 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Find durable-object / hibernation / state-restoration references near AIThread.
rg -n "hibernate|hibernation|durable object|Durable Object|storage|persist|restore|recover|recovery" src/features/workspaces/ai -C 2
# Show the AIThread class definition and any storage-related fields/methods.
sed -n '1,160p' src/features/workspaces/ai/ai-thread.tsRepository: ThinkEx-OSS/thinkex Length of output: 13924 🌐 Web query:
💡 Result: In the context of Cloudflare Agents and Durable Objects, the terms you mentioned pertain to the lifecycle, lifecycle hooks, and operational behavior of agentic applications built on the Cloudflare platform [1][2]. ActiveUsageContext and BeforeTurn BeforeTurn is a lifecycle hook in the Cloudflare Agents framework that fires before streamText is executed [3][2]. It receives a TurnContext object containing the assembled system prompt, messages, tools, and model. This hook allows developers to return a TurnConfig object to override parts of the turn (e.g., model, system prompt, tools, maxSteps) [3][2]. Continuation Continuation refers to a specific type of turn in an agent's lifecycle, often used for auto-continuing a conversation after tool results are coalesced [3][2]. In the TurnContext provided to lifecycle hooks like beforeTurn, the continuation field is a boolean indicating whether the current turn is an auto-continuation [2]. Hibernation and Durable Objects Durable Objects support WebSocket Hibernation, which allows objects to be removed from memory to save costs while maintaining active WebSocket connections [4][5]. When an agent relies on Durable Objects for state, hibernation can impact certain behaviors: - Programmatic turns and sub-agent RPCs may be affected, as certain signals (like AbortSignal) do not persist across hibernation boundaries [6]. - Lifecycle hooks and responses: Some responses triggered from within hooks like onChatResponse do not recursively fire onChatResponse again, which is a consideration when designing agent communication flows [7][8]. Recovery Durable recovery refers to mechanisms designed to manage stability and handle interruptions (such as stream-stall watchdogs) [1]. The chatStreamStallTimeoutMs field in TurnConfig is specifically mentioned as a way to override the watchdog for a specific turn, such as when executing a known-slow tool, and it is explicitly noted to refer to Durable recovery documentation [2][9]. Citations:
🌐 Web query:
💡 Result: When a Cloudflare Durable Object enters a hibernation state—or is evicted from memory due to inactivity or system events like code deployments—all in-memory instance fields and variables are discarded [1][2][3][4]. This occurs because the object is removed from the active memory of the runtime [1][5]. To maintain state across these events, you must explicitly persist any necessary information to the Durable Object's durable storage using the Storage API [1][2][3]. The Durable Object Storage API provides transactional, strongly consistent, and private storage (including SQL and key-value options) that survives hibernation and eviction [1][6][5][7]. Special handling is required for WebSocket Hibernation: * While general in-memory state is lost, you can use the Hibernation WebSocket API methods Citations:
Persist 🤖 Prompt for AI Agents |
||
| private async _getThreadContext() { | ||
| const directory = await this.parentAgent(getUserAIStore()); | ||
| return directory.getThreadContext(this.name); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Explicit-picker continuation branch still switches models mid-flight.
When a continuation's
activeContext.requestedModelId !== "auto"(original turn used an explicit model, e.g.claude-sonnet), this branch returnsundefined. SincebeforeTurncomputesmodelId = route?.modelId ?? requestedModelId, andrequestedModelIdfor this continuation call is"auto"(the body lost the explicit selection), the effective model silently becomes Auto's default (moonshotai/kimi-k2.6) instead of the originally selected explicit model. This directly contradicts the function's own header comment that "a run never switches models mid-flight" — continuity is only preserved for the auto-originated branch (lines 334-339), not for the explicit-picker branch.🐛 Proposed fix to preserve the original explicit model
if (activeContext && activeContext.requestedModelId !== "auto") { - // The run started from an explicit picker choice; a continuation - // whose body lost the selection must not be re-routed. - return undefined; + // The run started from an explicit picker choice; a continuation + // whose body lost the selection must reuse that model, not silently + // fall back to Auto's default. + return { modelId: activeContext.modelId, reason: "turn-continuation" }; }📝 Committable suggestion
🤖 Prompt for AI Agents