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
1 change: 0 additions & 1 deletion apps/example/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,4 @@ AI_MODEL_PROFILES=
AI_EMBEDDING_MODEL=
AI_VISION_MODEL=
AI_WEB_SEARCH_MODEL=
QUEUE_CALLBACK_MAX_DURATION_SECONDS=
AGENT_TURN_TIMEOUT_MS=
1 change: 1 addition & 0 deletions packages/junior-dashboard/tests/dashboard-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function dashboard(session: DashboardSession | null) {
function mockDashboardVirtualConfig() {
vi.doMock("#junior/config", () => ({
createDashboardApp,
functionMaxDurationSeconds: undefined,
dashboard: undefined,
pluginRuntimeRegistrations: [],
pluginSet: undefined,
Expand Down
1 change: 0 additions & 1 deletion packages/junior/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ AI_HANDOFF_MODEL= # Defaults to openai/gpt-5.6-sol; model for the defa
AI_MODEL_PROFILES= # Optional JSON object of additional named handoff profiles to model IDs
AI_VISION_MODEL= # Dedicated image-understanding model; unset disables vision features
AI_WEB_SEARCH_MODEL= # Override for the webSearch tool; defaults to xai/grok-4-fast-reasoning
QUEUE_CALLBACK_MAX_DURATION_SECONDS= # Defaults to 800; used to cap AGENT_TURN_TIMEOUT_MS budget
AGENT_TURN_TIMEOUT_MS= # Defaults to 720000 (12m), clamped under queue max duration budget
REDIS_URL=
SKILL_DIRS= # Additional skill directories (colon-separated)
Expand Down
14 changes: 13 additions & 1 deletion packages/junior/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import {
getConfigDefaults,
setConfigDefaults,
} from "@/chat/configuration/defaults";
import { getSlackReactionConfig, setSlackReactionConfig } from "@/chat/config";
import {
configureFunctionMaxDurationSeconds,
getSlackReactionConfig,
setSlackReactionConfig,
} from "@/chat/config";
import { getDb } from "@/chat/db";
import { logException } from "@/chat/logging";
import { executeAgentRun } from "@/chat/agent";
Expand Down Expand Up @@ -140,6 +144,7 @@ type CreateDashboardApp = (
) => DashboardApp;

interface JuniorVirtualConfig {
functionMaxDurationSeconds?: number;
createDashboardApp?: CreateDashboardApp;
dashboard?: JuniorVirtualDashboardOptions;
pluginSet?: JuniorPluginSet;
Expand Down Expand Up @@ -179,13 +184,15 @@ async function resolveVirtualConfig(): Promise<
try {
const mod: {
createDashboardApp?: CreateDashboardApp;
functionMaxDurationSeconds?: number;
dashboard?: JuniorVirtualDashboardOptions;
pluginSet?: JuniorPluginSet;
plugins?: PluginCatalogConfig;
pluginRuntimeRegistrations?: string[];
} = await import("#junior/config");
return {
createDashboardApp: mod.createDashboardApp,
functionMaxDurationSeconds: mod.functionMaxDurationSeconds,
dashboard: mod.dashboard,
pluginSet: mod.pluginSet,
plugins: mod.plugins,
Expand Down Expand Up @@ -531,6 +538,11 @@ function mountRoutes(app: Hono, routes: HostRouteRegistration[]): void {
/** Create a Hono app with all Junior routes. */
export async function createApp(options?: JuniorAppOptions): Promise<Hono> {
const virtualConfig = await resolveVirtualConfig();
if (virtualConfig?.functionMaxDurationSeconds !== undefined) {
configureFunctionMaxDurationSeconds(
virtualConfig.functionMaxDurationSeconds,
);
}
const dashboard = options?.dashboard ?? virtualConfig?.dashboard;
const configuredPlugins = options?.plugins ?? virtualConfig?.pluginSet;
const plugins = pluginRuntimeRegistrationsFromPluginSet(configuredPlugins);
Expand Down
4 changes: 4 additions & 0 deletions packages/junior/src/build/virtual-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function dashboardEnabled(

/** Render the virtual config module consumed by createApp(). */
export function renderVirtualConfig(options: {
functionMaxDurationSeconds?: number;
dashboard?: JuniorDashboardOptions;
plugins?: PluginCatalogConfig;
pluginModule?: RuntimePluginModule;
Expand All @@ -53,6 +54,7 @@ export function renderVirtualConfig(options: {
`export const plugins = ${JSON.stringify(options.plugins ?? { packages: [] })};`,
`export const pluginRuntimeRegistrations = ${JSON.stringify(options.pluginRuntimeRegistrations ?? [])};`,
`export const dashboard = ${JSON.stringify(options.dashboard)};`,
`export const functionMaxDurationSeconds = ${JSON.stringify(options.functionMaxDurationSeconds)};`,
];

return lines.join("\n");
Expand All @@ -62,6 +64,7 @@ export function renderVirtualConfig(options: {
export function injectVirtualConfig(
nitro: Nitro,
options: {
functionMaxDurationSeconds?: number;
loadPluginSet?: () => Promise<JuniorPluginSet | undefined>;
pluginModule?: RuntimePluginModule;
plugins?: PluginCatalogConfig;
Expand All @@ -77,6 +80,7 @@ export function injectVirtualConfig(
const pluginSet = await options.loadPluginSet();

return renderVirtualConfig({
functionMaxDurationSeconds: options.functionMaxDurationSeconds,
pluginModule: options.pluginModule,
plugins: pluginCatalogConfigFromPluginSet(pluginSet),
pluginRuntimeRegistrations: pluginRuntimeRegistrationsFromPluginSet(
Expand Down
59 changes: 46 additions & 13 deletions packages/junior/src/chat/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const DEFAULT_COMPLETED_REACTION_EMOJI = "white_check_mark";
* Junior can abort, persist, and schedule continuation before host teardown.
*/
export const FUNCTION_TIMEOUT_BUFFER_SECONDS = 20;
/** Additional buffer that makes conversation work yield before the hard request deadline. */
export const CONVERSATION_WORK_SOFT_YIELD_BUFFER_SECONDS = 40;
const DEFAULT_ASSISTANT_LOADING_MESSAGES = [
"Consulting the orb",
"Bribing the gremlins",
Expand Down Expand Up @@ -58,6 +60,7 @@ export type SqlDriver = "neon" | "postgres";
export interface ChatConfig {
bot: BotConfig;
functionMaxDurationSeconds: number;
conversationWorkSoftYieldAfterMs: number;
sql: {
databaseUrl: string;
driver: SqlDriver;
Expand Down Expand Up @@ -92,15 +95,20 @@ function parseAgentTurnTimeoutMs(
return Math.max(MIN_AGENT_TURN_TIMEOUT_MS, Math.min(value, maxTimeoutMs));
}

function resolveFunctionMaxDurationSeconds(env: NodeJS.ProcessEnv): number {
const raw =
env.FUNCTION_MAX_DURATION_SECONDS ??
env.QUEUE_CALLBACK_MAX_DURATION_SECONDS;
const value = Number.parseInt(raw ?? "", 10);
if (Number.isNaN(value) || value <= 0) {
return DEFAULT_FUNCTION_MAX_DURATION_SECONDS;
}
return value;
function resolveFunctionMaxDurationSeconds(
functionMaxDurationSeconds?: number,
): number {
return functionMaxDurationSeconds ?? DEFAULT_FUNCTION_MAX_DURATION_SECONDS;
}

function resolveConversationWorkSoftYieldAfterMs(
functionMaxDurationSeconds: number,
): number {
return Math.max(
MIN_AGENT_TURN_TIMEOUT_MS,
(functionMaxDurationSeconds - CONVERSATION_WORK_SOFT_YIELD_BUFFER_SECONDS) *
1000,
);
}

function resolveMaxTurnTimeoutMs(functionMaxDurationSeconds: number): number {
Expand Down Expand Up @@ -252,8 +260,10 @@ function parseReactionEmoji(
return normalized;
}

function readBotConfig(env: NodeJS.ProcessEnv): BotConfig {
const functionMaxDurationSeconds = resolveFunctionMaxDurationSeconds(env);
function readBotConfig(
env: NodeJS.ProcessEnv,
functionMaxDurationSeconds: number,
): BotConfig {
const maxTurnTimeoutMs = resolveMaxTurnTimeoutMs(functionMaxDurationSeconds);
const modelId = validateGatewayModelId(env.AI_MODEL) ?? DEFAULT_MODEL_ID;
const reasoningLevel = toOptionalTrimmed(env.AI_REASONING_LEVEL);
Expand Down Expand Up @@ -326,11 +336,18 @@ function readSqlDriver(env: NodeJS.ProcessEnv, databaseUrl: string): SqlDriver {
/** Parse all chat configuration from environment variables. */
export function readChatConfig(
env: NodeJS.ProcessEnv = process.env,
functionMaxDurationSeconds = DEFAULT_FUNCTION_MAX_DURATION_SECONDS,
): ChatConfig {
const databaseUrl = readDatabaseUrl(env);
const resolvedFunctionMaxDurationSeconds = resolveFunctionMaxDurationSeconds(
functionMaxDurationSeconds,
);
return {
bot: readBotConfig(env),
functionMaxDurationSeconds: resolveFunctionMaxDurationSeconds(env),
bot: readBotConfig(env, resolvedFunctionMaxDurationSeconds),
functionMaxDurationSeconds: resolvedFunctionMaxDurationSeconds,
conversationWorkSoftYieldAfterMs: resolveConversationWorkSoftYieldAfterMs(
resolvedFunctionMaxDurationSeconds,
),
sql: {
databaseUrl,
driver: readSqlDriver(env, databaseUrl),
Expand Down Expand Up @@ -360,6 +377,22 @@ export function readChatConfig(
/** Chat configuration parsed once at module load from the process environment. */
const chatConfig: ChatConfig = readChatConfig(process.env);

/** Apply the host execution budget injected by juniorNitro(). */
export function configureFunctionMaxDurationSeconds(
functionMaxDurationSeconds: number,
): void {
const resolved = resolveFunctionMaxDurationSeconds(
functionMaxDurationSeconds,
);
chatConfig.functionMaxDurationSeconds = resolved;
chatConfig.conversationWorkSoftYieldAfterMs =
resolveConversationWorkSoftYieldAfterMs(resolved);
chatConfig.bot.turnTimeoutMs = parseAgentTurnTimeoutMs(
process.env.AGENT_TURN_TIMEOUT_MS,
resolveMaxTurnTimeoutMs(resolved),
);
}

/** Return the chat configuration (parsed once at startup). */
export function getChatConfig(): ChatConfig {
return chatConfig;
Expand Down
5 changes: 3 additions & 2 deletions packages/junior/src/chat/task-execution/worker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { StateAdapter } from "chat";
import type { Destination } from "@sentry/junior-plugin-api";
import { getChatConfig } from "@/chat/config";
import { logException, logInfo, logWarn } from "@/chat/logging";
import type { ConversationStore } from "@/chat/conversations/store";
import { isProviderRetryError } from "@/chat/services/provider-retry";
Expand Down Expand Up @@ -31,7 +32,6 @@ import {
} from "./store";

export const CONVERSATION_WORK_DEFER_DELAY_MS = 15_000;
export const CONVERSATION_WORK_SOFT_YIELD_AFTER_MS = 240_000;

export interface ConversationWorkerContext {
attempt: InboxAttempt;
Expand Down Expand Up @@ -309,7 +309,8 @@ export async function processConversationWork(
const startedAtMs = now(options);
const softYieldDeadlineMs =
startedAtMs +
(options.softYieldAfterMs ?? CONVERSATION_WORK_SOFT_YIELD_AFTER_MS);
(options.softYieldAfterMs ??
getChatConfig().conversationWorkSoftYieldAfterMs);
const leasedWork = await getConversationWorkState({
conversationId,
state: options.state,
Expand Down
21 changes: 14 additions & 7 deletions packages/junior/src/nitro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,10 @@ function bundleOpenTelemetryLoaderHooks(nitro: Nitro): void {
}
}

function configureVercelDeployment(nitro: Nitro, options: JuniorNitroOptions) {
function configureVercelDeployment(
nitro: Nitro,
options: JuniorNitroOptions,
): number {
const defaultMaxDuration =
options.maxDuration ?? DEFAULT_FUNCTION_MAX_DURATION_SECONDS;
const queueTopic = resolveConversationWorkQueueTopic({
Expand Down Expand Up @@ -175,9 +178,7 @@ function configureVercelDeployment(nitro: Nitro, options: JuniorNitroOptions) {
}

nitro.options.vercel.functions ??= {};
nitro.options.vercel.functions.maxDuration ??= defaultMaxDuration;
const callbackMaxDuration =
nitro.options.vercel.functions.maxDuration ?? defaultMaxDuration;
nitro.options.vercel.functions.maxDuration = defaultMaxDuration;

nitro.options.vercel.functionRules ??= {};
const existingRule =
Expand All @@ -193,8 +194,8 @@ function configureVercelDeployment(nitro: Nitro, options: JuniorNitroOptions) {

nitro.options.vercel.functionRules[JUNIOR_CONVERSATION_WORK_CALLBACK_ROUTE] =
{
maxDuration: callbackMaxDuration,
...existingRule,
maxDuration: defaultMaxDuration,
experimentalTriggers: [
...otherTriggers,
{
Expand All @@ -216,8 +217,8 @@ function configureVercelDeployment(nitro: Nitro, options: JuniorNitroOptions) {
);

nitro.options.vercel.functionRules[JUNIOR_PLUGIN_TASK_CALLBACK_ROUTE] = {
maxDuration: callbackMaxDuration,
...existingPluginTaskRule,
maxDuration: defaultMaxDuration,
experimentalTriggers: [
...otherPluginTaskTriggers,
{
Expand All @@ -226,6 +227,8 @@ function configureVercelDeployment(nitro: Nitro, options: JuniorNitroOptions) {
},
],
};

return defaultMaxDuration;
}

/** Nitro module that configures deployment wiring and copies app/plugin content into the Vercel build output. */
Expand All @@ -239,7 +242,10 @@ export function juniorNitro(options: JuniorNitroOptions = {}): {
options.cwd ?? nitro.options.rootDir ?? process.cwd(),
);

configureVercelDeployment(nitro, options);
const functionMaxDurationSeconds = configureVercelDeployment(
nitro,
options,
);
bundleOpenTelemetryLoaderHooks(nitro);

applyRolldownTreeshakeWorkaround(nitro);
Expand Down Expand Up @@ -267,6 +273,7 @@ export function juniorNitro(options: JuniorNitroOptions = {}): {
(plugin) => plugin.manifest.name,
);
injectVirtualConfig(nitro, {
functionMaxDurationSeconds,
...(pluginModule
? {
loadPluginSet: loadConfiguredPluginSet,
Expand Down
1 change: 1 addition & 0 deletions packages/junior/src/virtual-modules.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ declare module "#junior/config" {
})
| undefined;
export const dashboard: VirtualDashboardConfig | undefined;
export const functionMaxDurationSeconds: number;
export const pluginSet: JuniorPluginSet | undefined;
export const plugins: PluginCatalogConfig;
export const pluginRuntimeRegistrations: string[];
Expand Down
12 changes: 3 additions & 9 deletions packages/junior/tests/component/config/chat-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,18 +344,12 @@ describe("chat config", () => {
expect(botConfig.turnTimeoutMs).toBe(280000);
});

it("derives AGENT_TURN_TIMEOUT_MS cap from FUNCTION_MAX_DURATION_SECONDS", async () => {
it("ignores legacy host duration environment variables", async () => {
process.env.FUNCTION_MAX_DURATION_SECONDS = "500";
process.env.QUEUE_CALLBACK_MAX_DURATION_SECONDS = "600";
process.env.AGENT_TURN_TIMEOUT_MS = "999999";
const { botConfig } = await loadConfig();
expect(botConfig.turnTimeoutMs).toBe(480000);
});

it("falls back to QUEUE_CALLBACK_MAX_DURATION_SECONDS for backward compat", async () => {
process.env.QUEUE_CALLBACK_MAX_DURATION_SECONDS = "500";
process.env.AGENT_TURN_TIMEOUT_MS = "999999";
const { botConfig } = await loadConfig();
expect(botConfig.turnTimeoutMs).toBe(480000);
expect(botConfig.turnTimeoutMs).toBe(280000);
});
});

Expand Down
Loading
Loading