Skip to content

feat(ai-chat): route Auto model by task signals#599

Open
v-rdyy wants to merge 5 commits into
ThinkEx-OSS:mainfrom
v-rdyy:feat/auto-model-routing
Open

feat(ai-chat): route Auto model by task signals#599
v-rdyy wants to merge 5 commits into
ThinkEx-OSS:mainfrom
v-rdyy:feat/auto-model-routing

Conversation

@v-rdyy

@v-rdyy v-rdyy commented Jul 7, 2026

Copy link
Copy Markdown

Closes #570.

Root cause

Auto is the default picker choice but resolves to one hardcoded backend (gatewayModel: "moonshotai/kimi-k2.6" in models.ts), while the picker copy claims "Picks a good fit for you". There was no task-aware selection step anywhere between the picker and the gateway.

Approach and routing policy

Routing resolves at turn time only, in AIThread.beforeTurn, through a new pure router (src/features/workspaces/ai/model-router.ts). The user-facing id stays "auto" everywhere (picker state, persisted UI store, request body). When the resolved id is auto, beforeTurn extracts task signals from the assembled turn context and routes; the routed picker id then flows into the existing helpers for the language model, gateway provider options, the access check, usage metering, and telemetry. Non-auto picks pass through untouched.

Policy is a data table (thresholds + rules), first match wins; capability rules precede cost rules:

# reason condition target
1 attachment-heavy ≥ 1 image/file part in user messages gemini (Gemini 3 Flash)
2 long-context total text ≥ 24 000 chars, or ≥ 40 messages, or ≥ 4 workspace references gemini
3 tool-heavy code markers in latest user message, or ≥ 3 prior tool-call parts chatgpt-mini
4 simple-chat latest user message ≤ 280 chars and ≤ 12 messages claude-haiku
high-reasoning-default no rule matched auto (Kimi K2.6)
fallback-unavailable routed target removed or no longer standard tier auto (Kimi K2.6)

Billing: routing is deliberately standard-tier-only — a rule can never land on a premium model; a re-tiered or removed target degrades to the default at route time (fallback-unavailable, covered by tests). Usage is metered on the routed model id via activeUsageContext, which now carries both requestedModelId and the routed modelId; under standard-only routing this maps to the same Autumn feature id as before and stays correct by construction if policy ever changes. Premium-tier routing is a product decision deliberately left to the maintainer. The (currently disabled) access check receives the routed id, with a comment at the call site that enforcement must gate on the routed tier.

Telemetry/inspector: recordTurnStarted now takes modelId (routed) plus requestedModelId/routingReason. PostHog ai_turn_started gains requested_model_id and routing_reason; TCC run metadata carries the same pair; the inspector records them on turn.started, parses them into the run view, and the run summary badge shows auto → claude-haiku plus the reason. Because the routed id is a picker id, gateway tags, per-model reasoning options, and gateway-model derivation in every recorder stay correct with no extra plumbing.

Determinism/latency: the router and signal extraction are pure and synchronous — zero model calls, zero IO. Extraction is defensive: malformed messages or body fields become zero-value signals (default route), never an error. Continuation turns reuse the id routed at run start so a run never switches models mid-flight; if that state is lost (recovery), signals are re-extracted deterministically.

Decisions and rejected alternatives

  • Route to picker ids, not gateway slugs. Rejected returning raw gateway model strings: picker ids reuse getWorkspaceAiLanguageModel / getWorkspaceAiGatewayProviderOptions / billing-tier lookup unchanged, so routed turns behave exactly like a user picking that model. This was the materially simpler insertion.
  • Heuristic rules, not a classifier. A classifier prompt adds a model call and latency to every turn and is untestable as a fixture table; the issue allows rule-based v1.
  • Route in beforeTurn, not getWorkspaceAiLanguageModel. The helper is also used by non-chat paths (base model, compaction) that must stay pinned; beforeTurn is the single choke point with access to messages/body.
  • Continuation without usage context re-routes rather than erroring: deterministic given the same turn context, and safer than failing recovery. A continuation whose run started from an explicit (non-auto) pick is never re-routed even if its body lost the selection.
  • Rejected recording requested-vs-routed in the Autumn track properties to avoid touching the billing payload shape; telemetry sinks cover observability (noted as a follow-up option).

Fact corrections vs the issue prompt

  • Chat attachments are images only (chat-attachment-policy.ts accepts image/*); there are no PDF chat attachments. The attachment/long-context rules key on image parts and workspace references (selected items/quotes, which include PDF selections) instead.
  • The getWorkspaceAiChatModel(DEFAULT_…) call around ai-thread.ts:405 is the compaction summarizer, not title generation. Titles use a dedicated AI_THREAD_TITLE_GATEWAY_MODEL constant in ai-thread-runtime.ts. Both paths are untouched.

Follow-ups noticed

  • No UI shows which model Auto picked for a response (out of scope per issue; the inspector badge now shows it for debugging).
  • TurnInput.body on continuation turns appears to replay the original body, but if it were ever absent, an explicit non-auto run's continuation would resolve to "auto" pre-existing behavior; this PR guards the routing side of that edge but the underlying resolution quirk remains.
  • Autumn track properties could also carry requested_model_id for billing-side analytics.
  • TCC step payloads label the picker id as model.requested; with routing, a clearer name would be effective/used split — left as-is to avoid changing the TCC schema here.

How verified

  • pnpm check, pnpm test, and pnpm verify (check + test + production build) pass locally on every commit.
  • 14 new tests: the four routing fixture cases, threshold boundary values, rule-priority, premium-tier and removed-model fallbacks, signal extraction (text/attachments/tool calls/workspace refs, code detection, malformed-input zeroing), and inspector run-view parsing of requested vs routed ids.
  • Not manually driven against a running app (no local secrets in this environment); the routed-id flow into model, provider options, metering, and all three telemetry sinks is exercised by types plus the tests above.

Reviewer pre-empts: no premium bypass (tier enforced at route time, tested); tests are deterministic (pure router, fixed thresholds); fallback covered; non-auto behavior bit-for-bit unchanged (router returns undefined for any non-auto id; only additive telemetry fields differ).

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

Review in cubic

Summary by CodeRabbit

  • New Features
    • AI chat can now automatically choose a model per message, with updated “Auto” messaging to explain how selection works.
    • AI inspector views now show both the requested model and the model actually used when they differ.
  • Bug Fixes
    • Improved consistency in AI turn tracking so routed model details are preserved across turn records and telemetry.
  • Tests
    • Added coverage for model routing and inspector run view data to verify the new behavior across common scenarios.

v-rdy and others added 5 commits July 6, 2026 18:23
Auto currently resolves every turn to one hardcoded gateway model. Add a
pure, deterministic router: task signals in, standard-tier picker model
out, driven by a data table of thresholds and rules. Targets that go
missing or leave the standard billing tier degrade to the existing
default model instead of failing the turn or reaching premium pricing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Derive the router's task signals (message and text volume, image
attachments, prior tool calls, code markers, workspace references) from
the assembled turn context and request body. Extraction is best-effort:
missing or malformed fields become zero values so a bad payload can
never fail the turn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the resolved picker id is auto, beforeTurn now routes the turn from
extracted task signals and uses the routed id for the language model,
gateway provider options, access check, and usage context — so metering
follows the model that actually ran. Continuation turns reuse the id
routed at run start, and explicit picker choices pass through untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Auto turns now report both the picker selection and the routed model:
PostHog ai_turn_started gains requested_model_id and routing_reason, TCC
run metadata carries the same pair, and the inspector records them on
turn.started, parses them into the run view, and shows requested vs
routed in the run summary badge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The picker described Auto as picking a good fit while it always called
one hardcoded model. Now that turns are actually routed, say so plainly
and note in the catalog comment that the auto gateway slug is the
router's default/fallback and the base model for non-chat paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces a rule-based auto model router for workspace AI chat that selects a gateway model based on request signals (attachments, context length, tool usage). Wires routing into AIThread.beforeTurn, propagates requestedModelId/routingReason through telemetry recorders and PostHog events, updates the inspector run view and UI, and revises the Auto model's picker copy.

Changes

Auto Model Routing

Layer / File(s) Summary
Model router contracts and rule-based logic
src/features/workspaces/ai/model-router.ts, src/features/workspaces/ai/model-router.test.ts
New router defines routing signals, reasons, thresholds, and ordered rules; routeWorkspaceAiAutoModel matches rules with standard-tier fallback handling, and extractWorkspaceAiRoutingSignals derives signals from turn context, with unit tests covering multiple scenarios and edge cases.
AIThread turn integration and picker copy
src/features/workspaces/ai/ai-thread.ts, src/features/workspaces/ai/models.ts
beforeTurn resolves requestedModelId, invokes new _resolveAutoModelRoute helper to compute a routed modelId/reason, preserves routing across continuation turns, and forwards it to usage context and telemetry; Auto model's tagline/description text updated.
Recorder telemetry propagation
src/features/workspaces/ai/ai-thread-inspector-recorder.ts, ai-thread-posthog-recorder.ts, ai-thread-tcc-recorder.ts, ai-thread-telemetry-recorder.ts, src/integrations/posthog/events.ts
Extends recordTurnStarted input types and event payloads across recorders to carry requestedModelId and optional routingReason; adds matching PostHog event property types.
Inspector run view mapping and UI display
src/features/workspaces/ai/ai-inspector-view-model.ts, ai-inspector-view-types.ts, ai-inspector-view-model.test.ts, src/features/workspaces/components/ai-chat/AiChatInspectorViews.tsx
Adds requestedModelId/routingReason to AIInspectorRunView and buildRunView mapping; RunSummary badge now shows requestedModelId → modelId when they differ.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AIThread
  participant ModelRouter as model-router
  participant TelemetryRecorders as Telemetry/PostHog/TCC recorders
  participant Inspector as AI Inspector UI

  AIThread->>AIThread: beforeTurn resolves requestedModelId
  AIThread->>ModelRouter: extractWorkspaceAiRoutingSignals(ctx)
  ModelRouter-->>AIThread: signals
  AIThread->>ModelRouter: routeWorkspaceAiAutoModel(signals)
  ModelRouter-->>AIThread: { modelId, reason }
  AIThread->>TelemetryRecorders: recordTurnStarted(modelId, requestedModelId, routingReason)
  TelemetryRecorders->>TelemetryRecorders: emit requested_model_id, routing_reason
  AIThread->>Inspector: turn.started event payload includes requestedModelId, routingReason
  Inspector->>Inspector: render requestedModelId → modelId badge
Loading

Suggested labels: capy

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: routing Auto model selection by task signals.
Linked Issues check ✅ Passed The changes implement task-aware Auto routing, update telemetry/inspector/copy, and add routing tests, matching #570's acceptance criteria.
Out of Scope Changes check ✅ Passed The PR stays focused on Auto routing, related telemetry, inspector, copy, and tests with no clear unrelated additions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@v-rdyy v-rdyy marked this pull request as ready for review July 7, 2026 03:12
@greptile-apps

greptile-apps Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds task-aware routing for the Auto AI chat model. The main changes are:

  • A pure Auto router based on attachments, context length, tool/code signals, and simple-chat thresholds.
  • Turn-time resolution of Auto into a routed picker model for execution, access checks, usage metering, and telemetry.
  • Continuation handling that keeps a run on the model chosen at run start.
  • Inspector, PostHog, and TCC telemetry fields for requested model, routed model, and routing reason.
  • Tests for routing policy, fallbacks, signal extraction, malformed inputs, and inspector parsing.

Confidence Score: 5/5

Safe to merge with low risk.

The changes are localized to Auto model routing and telemetry, the router is pure and defensive, and standard-tier fallback behavior is covered by tests.

No files require special attention.

T-Rex T-Rex Logs

What T-Rex did

  • An initial wrapper attempt around the Vitest run was executed, but the shell failed because /bin/sh does not support PIPESTATUS, causing the wrapper to exit with code 2.
  • The focused Vitest rerun completed successfully and produced a valid proof, reporting 2 test files passed and 14 tests passed.
  • An optional gate check was performed via pnpm check, and it reported formatting issues found in three .greptile and .greptile-internal files.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
src/features/workspaces/ai/ai-thread.ts Resolves Auto to a routed model at turn start, reuses the routed id for continuations, and meters/telemeters the effective model.
src/features/workspaces/ai/model-router.ts Introduces a pure, defensive Auto router based on task signals with standard-tier-only target validation.
src/features/workspaces/ai/model-router.test.ts Covers routing policy, threshold boundaries, standard-tier fallbacks, signal extraction, and malformed input handling.
src/features/workspaces/ai/ai-thread-telemetry-recorder.ts Threads requested model id and routing reason through the telemetry fan-out API.
src/features/workspaces/ai/ai-thread-inspector-recorder.ts Records requested model id and routing reason in inspector turn-started payloads.
src/features/workspaces/ai/ai-thread-posthog-recorder.ts Adds routed/requested model telemetry and routing reason to PostHog turn-started events.
src/features/workspaces/ai/ai-thread-tcc-recorder.ts Includes routed/requested model metadata and routing reason in TCC run metadata while using the routed model for gateway details.
src/features/workspaces/components/ai-chat/AiChatInspectorViews.tsx Displays requested-to-routed model and routing reason badges in the AI inspector run summary.
src/features/workspaces/ai/models.ts Updates Auto model copy to describe per-message routing while retaining the existing fallback gateway slug.

Reviews (1): Last reviewed commit: "fix(ai-chat): align Auto picker copy wit..." | Re-trigger Greptile

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
src/features/workspaces/ai/model-router.ts (1)

56-63: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Exclude auto from rule targets.

WorkspaceAiChatModelId includes the user-facing "auto" id, and getStandardTierRouteTarget currently accepts it because it is standard-tier. A future rule tuned to targetModelId: "auto" would be reported as a successful route with a non-fallback reason instead of a concrete backend choice.

Proposed tightening
 export interface WorkspaceAiAutoRoutingRule {
 	matches: (
 		signals: WorkspaceAiRoutingSignals,
 		thresholds: WorkspaceAiAutoRoutingThresholds,
 	) => boolean;
 	reason: Exclude<WorkspaceAiAutoRouteReason, "high-reasoning-default" | "fallback-unavailable">;
-	targetModelId: WorkspaceAiChatModelId;
+	targetModelId: Exclude<WorkspaceAiChatModelId, "auto">;
 }
 function getStandardTierRouteTarget(modelId: WorkspaceAiChatModelId) {
 	const model = getWorkspaceAiChatModelById(modelId);
 
 	// getWorkspaceAiChatModelById falls back to the default entry for unknown
 	// ids; treat that as "unavailable" rather than silently routing there.
-	if (model.id !== modelId || model.billingTier !== "standard") {
+	if (modelId === "auto" || model.id !== modelId || model.billingTier !== "standard") {
 		return undefined;
 	}

Also applies to: 139-148

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/workspaces/ai/model-router.ts` around lines 56 - 63, The routing
rule target type currently allows the user-facing "auto" model id, which can be
returned as if it were a concrete backend choice. Tighten the type used by
WorkspaceAiAutoRoutingRule.targetModelId and the related
getStandardTierRouteTarget path so rules can only point to actual backend
models, excluding "auto" while keeping standard-tier validation intact. Use the
existing symbols WorkspaceAiAutoRoutingRule, WorkspaceAiChatModelId, and
getStandardTierRouteTarget to update the rule target typing and any affected
checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/features/workspaces/ai/ai-thread.ts`:
- Around line 318-332: The explicit-picker continuation path in
_resolveAutoModelRoute is returning undefined, which lets beforeTurn fall back
to the Auto default instead of preserving the original model. Update
_resolveAutoModelRoute so that when ctx.continuation is true and
this.activeUsageContext.requestedModelId is not "auto", it routes back to that
original explicit modelId with an appropriate reason. Keep the existing
auto-originated continuation handling intact, and make sure beforeTurn still
uses route?.modelId to prevent mid-flight model switches.
- Around line 333-345: Persist or rehydrate the routed context in ai-thread.ts
so resumed continuations keep an activeUsageContext instead of only rerouting
from current signals. Update the logic around the existing activeContext check
and the routeWorkspaceAiAutoModel/extractWorkspaceAiRoutingSignals path so the
returned model/thread routing data is also stored on the thread state and
available to _trackCompletedMessageUsage. This should ensure hibernation or
recovery restores the same usage context before completed-message billing runs.

In `@src/features/workspaces/ai/model-router.test.ts`:
- Around line 249-265: The test expectation in extractWorkspaceAiRoutingSignals
should be updated to match the new parsed-message counting behavior for
malformed turn data. Adjust the fixture in model-router.test.ts so it no longer
asserts messageCount: 3 when null/invalid messages are ignored, and keep the
rest of the expected routing signals aligned with the output from
buildTurnContext and extractWorkspaceAiRoutingSignals.

In `@src/features/workspaces/ai/model-router.ts`:
- Around line 180-205: The message count is currently using the raw array length
in the model-router message analysis, which can include malformed entries that
were skipped earlier. Update the message-count logic in the workspace AI routing
helper around the loop that uses asRecord, getMessageText, and countParts so it
counts only successfully parsed records rather than messages.length, and return
that parsed count from the same object that includes attachmentCount,
hasCodeSignals, lastUserTextChars, and priorToolCallCount.

---

Nitpick comments:
In `@src/features/workspaces/ai/model-router.ts`:
- Around line 56-63: The routing rule target type currently allows the
user-facing "auto" model id, which can be returned as if it were a concrete
backend choice. Tighten the type used by
WorkspaceAiAutoRoutingRule.targetModelId and the related
getStandardTierRouteTarget path so rules can only point to actual backend
models, excluding "auto" while keeping standard-tier validation intact. Use the
existing symbols WorkspaceAiAutoRoutingRule, WorkspaceAiChatModelId, and
getStandardTierRouteTarget to update the rule target typing and any affected
checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2dbb9319-1704-4da0-b644-e1176d5a86fc

📥 Commits

Reviewing files that changed from the base of the PR and between f4d1022 and 944f556.

📒 Files selected for processing (13)
  • src/features/workspaces/ai/ai-inspector-view-model.test.ts
  • src/features/workspaces/ai/ai-inspector-view-model.ts
  • src/features/workspaces/ai/ai-inspector-view-types.ts
  • src/features/workspaces/ai/ai-thread-inspector-recorder.ts
  • src/features/workspaces/ai/ai-thread-posthog-recorder.ts
  • src/features/workspaces/ai/ai-thread-tcc-recorder.ts
  • src/features/workspaces/ai/ai-thread-telemetry-recorder.ts
  • src/features/workspaces/ai/ai-thread.ts
  • src/features/workspaces/ai/model-router.test.ts
  • src/features/workspaces/ai/model-router.ts
  • src/features/workspaces/ai/models.ts
  • src/features/workspaces/components/ai-chat/AiChatInspectorViews.tsx
  • src/integrations/posthog/events.ts

Comment on lines +318 to +332
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;
}

Copy link
Copy Markdown

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 returns undefined. Since beforeTurn computes modelId = route?.modelId ?? requestedModelId, and requestedModelId for 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
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 reuse that model, not silently
// fall back to Auto's default.
return { modelId: activeContext.modelId, reason: "turn-continuation" };
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/workspaces/ai/ai-thread.ts` around lines 318 - 332, The
explicit-picker continuation path in _resolveAutoModelRoute is returning
undefined, which lets beforeTurn fall back to the Auto default instead of
preserving the original model. Update _resolveAutoModelRoute so that when
ctx.continuation is true and this.activeUsageContext.requestedModelId is not
"auto", it routes back to that original explicit modelId with an appropriate
reason. Keep the existing auto-originated continuation handling intact, and make
sure beforeTurn still uses route?.modelId to prevent mid-flight model switches.

Comment on lines +333 to +345

if (activeContext) {
return {
modelId: activeContext.modelId,
reason: activeContext.routingReason ?? "turn-continuation",
};
}

const routed = routeWorkspaceAiAutoModel(extractWorkspaceAiRoutingSignals(ctx));

return { modelId: routed.modelId, reason: routed.reason };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.ts

Repository: 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 2

Repository: 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.ts

Repository: ThinkEx-OSS/thinkex

Length of output: 13924


🌐 Web query:

Cloudflare Think Durable Object continuation recovery instance fields hibernation activeUsageContext beforeTurn continuation onChatResponse

💡 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:

Cloudflare Durable Objects instance fields lost on hibernation state.storage persistence docs

💡 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 serializeAttachment and deserializeAttachment to persist per-connection data [8][9]. * These attachments survive hibernation as long as the WebSocket connection remains healthy [8]. * If you need to store larger amounts of data or information that must persist beyond the lifetime of a specific WebSocket connection, you should use the standard Storage API and store only the reference key in the WebSocket attachment [8]. In summary, you should treat all instance variables as ephemeral and assume they will be re-initialized from persistent storage upon the next request [1][4].

Citations:


Persist activeUsageContext for resumed continuations. A fresh instance can reroute the turn from current signals, but _trackCompletedMessageUsage will then skip billing because activeUsageContext is still unset. Store or rehydrate the routed model/thread context so hibernation or recovery doesn’t drop usage tracking for completed runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/workspaces/ai/ai-thread.ts` around lines 333 - 345, Persist or
rehydrate the routed context in ai-thread.ts so resumed continuations keep an
activeUsageContext instead of only rerouting from current signals. Update the
logic around the existing activeContext check and the
routeWorkspaceAiAutoModel/extractWorkspaceAiRoutingSignals path so the returned
model/thread routing data is also stored on the thread state and available to
_trackCompletedMessageUsage. This should ensure hibernation or recovery restores
the same usage context before completed-message billing runs.

Comment on lines +249 to +265
it("returns zero signals for missing or malformed turn data", () => {
expect(
extractWorkspaceAiRoutingSignals(
buildTurnContext({
body: { workspaceAiContext: "garbage" },
messages: [null, 42, { role: "user", content: { nested: true } }],
} as unknown as Partial<TurnContext>),
),
).toEqual({
attachmentCount: 0,
hasCodeSignals: false,
lastUserTextChars: 0,
messageCount: 3,
priorToolCallCount: 0,
totalTextChars: 0,
workspaceReferenceCount: 0,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update this expectation with parsed-message counting.

If extractWorkspaceAiRoutingSignals stops counting ignored malformed entries, this fixture should no longer expect messageCount: 3; otherwise it locks in the inflated-count behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/workspaces/ai/model-router.test.ts` around lines 249 - 265, The
test expectation in extractWorkspaceAiRoutingSignals should be updated to match
the new parsed-message counting behavior for malformed turn data. Adjust the
fixture in model-router.test.ts so it no longer asserts messageCount: 3 when
null/invalid messages are ignored, and keep the rest of the expected routing
signals aligned with the output from buildTurnContext and
extractWorkspaceAiRoutingSignals.

Comment on lines +180 to +205
for (const message of messages) {
const record = asRecord(message);

if (!record) {
continue;
}

const text = getMessageText(record.content);
totalTextChars += text.length;

if (record.role === "user") {
attachmentCount += countParts(record.content, ["file", "image"]);
lastUserText = text;
} else if (record.role === "assistant") {
priorToolCallCount += countParts(record.content, ["tool-call"]);
}
}

return {
attachmentCount,
hasCodeSignals: WORKSPACE_AI_CODE_SIGNAL_PATTERNS.some((pattern) =>
pattern.test(lastUserText),
),
lastUserTextChars: lastUserText.length,
messageCount: messages.length,
priorToolCallCount,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Count parsed messages, not raw array entries.

messageCount: messages.length lets malformed entries like null/numbers inflate routing signals and can trigger the long-context rule even though those entries were ignored during extraction.

Proposed fix
 		let priorToolCallCount = 0;
 		let totalTextChars = 0;
 		let lastUserText = "";
+		let messageCount = 0;
 
 		for (const message of messages) {
 			const record = asRecord(message);
 
 			if (!record) {
 				continue;
 			}
 
+			messageCount += 1;
+
 			const text = getMessageText(record.content);
 			totalTextChars += text.length;
@@
 			lastUserTextChars: lastUserText.length,
-			messageCount: messages.length,
+			messageCount,
 			priorToolCallCount,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const message of messages) {
const record = asRecord(message);
if (!record) {
continue;
}
const text = getMessageText(record.content);
totalTextChars += text.length;
if (record.role === "user") {
attachmentCount += countParts(record.content, ["file", "image"]);
lastUserText = text;
} else if (record.role === "assistant") {
priorToolCallCount += countParts(record.content, ["tool-call"]);
}
}
return {
attachmentCount,
hasCodeSignals: WORKSPACE_AI_CODE_SIGNAL_PATTERNS.some((pattern) =>
pattern.test(lastUserText),
),
lastUserTextChars: lastUserText.length,
messageCount: messages.length,
priorToolCallCount,
let priorToolCallCount = 0;
let totalTextChars = 0;
let lastUserText = "";
let messageCount = 0;
for (const message of messages) {
const record = asRecord(message);
if (!record) {
continue;
}
messageCount += 1;
const text = getMessageText(record.content);
totalTextChars += text.length;
if (record.role === "user") {
attachmentCount += countParts(record.content, ["file", "image"]);
lastUserText = text;
} else if (record.role === "assistant") {
priorToolCallCount += countParts(record.content, ["tool-call"]);
}
}
return {
attachmentCount,
hasCodeSignals: WORKSPACE_AI_CODE_SIGNAL_PATTERNS.some((pattern) =>
pattern.test(lastUserText),
),
lastUserTextChars: lastUserText.length,
messageCount,
priorToolCallCount,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/workspaces/ai/model-router.ts` around lines 180 - 205, The
message count is currently using the raw array length in the model-router
message analysis, which can include malformed entries that were skipped earlier.
Update the message-count logic in the workspace AI routing helper around the
loop that uses asRecord, getMessageText, and countParts so it counts only
successfully parsed records rather than messages.length, and return that parsed
count from the same object that includes attachmentCount, hasCodeSignals,
lastUserTextChars, and priorToolCallCount.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 13 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/features/workspaces/ai/model-router.ts">

<violation number="1" location="src/features/workspaces/ai/model-router.ts:204">
P2: Malformed or non-record messages are skipped when computing text, attachments, and tool-call signals, but `messageCount` is set to the raw `messages.length` regardless. This means invalid entries still count toward routing thresholds—for example, an array of 40 malformed messages yields `messageCount: 40` and incorrectly triggers the `long-context` rule, routing Auto to Gemini instead of the default route. It can also incorrectly suppress the `simple-chat` rule. To match the defensive contract that malformed input produces zero-value signals, only valid messages should be counted.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

pattern.test(lastUserText),
),
lastUserTextChars: lastUserText.length,
messageCount: messages.length,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Malformed or non-record messages are skipped when computing text, attachments, and tool-call signals, but messageCount is set to the raw messages.length regardless. This means invalid entries still count toward routing thresholds—for example, an array of 40 malformed messages yields messageCount: 40 and incorrectly triggers the long-context rule, routing Auto to Gemini instead of the default route. It can also incorrectly suppress the simple-chat rule. To match the defensive contract that malformed input produces zero-value signals, only valid messages should be counted.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/features/workspaces/ai/model-router.ts, line 204:

<comment>Malformed or non-record messages are skipped when computing text, attachments, and tool-call signals, but `messageCount` is set to the raw `messages.length` regardless. This means invalid entries still count toward routing thresholds—for example, an array of 40 malformed messages yields `messageCount: 40` and incorrectly triggers the `long-context` rule, routing Auto to Gemini instead of the default route. It can also incorrectly suppress the `simple-chat` rule. To match the defensive contract that malformed input produces zero-value signals, only valid messages should be counted.</comment>

<file context>
@@ -0,0 +1,263 @@
+				pattern.test(lastUserText),
+			),
+			lastUserTextChars: lastUserText.length,
+			messageCount: messages.length,
+			priorToolCallCount,
+			totalTextChars,
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

Replace hardcoded Auto model with real model routing

2 participants