From 71a6274541b23b48670a725752f8632ecc9200db Mon Sep 17 00:00:00 2001 From: zouyicheng Date: Fri, 26 Jun 2026 16:43:56 +0800 Subject: [PATCH] fix(model-resolution): resolve internal/background roles' model against owner BYO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internal roles (memoryExtractor / memoryCurator), background fires, and the imageRead / webSearch / compact sub-LLMs resolved their model against the global admin config. In a BYO-only deployment (admin base has zero models, each user brings their own), that yields an empty registry and fails with "No model is configured / Registered: (none)" — breaking memory extraction, auto-dream, and any background work, while the user's own DM keeps working via its per-session resolved config. Resolve against the OWNER's config everywhere a dispatched agent or sub-LLM selects a model: - dispatched-agent.ts: resolveUserConfig(canonicalUser, config) feeds query + childCtx.config — the single chokepoint every worker / internal / bg fire flows through (also fixes getSessionConfig()-driven imageRead / webSearch). - run-subagent.ts: resolve owner config before the getProviderFor precheck that actually threw the extraction / dream error. - api.ts: imageRead / webSearch / describe sub-LLM fallbacks getConfig() -> getSessionConfig() so they honor the session's resolved BYO. resolveUserConfig is an idempotent union merge, so passing an already-resolved config back through it is a no-op. Regression test (dispatched-agent.test.ts): empty admin base + on-disk user BYO model -> the config handed to query() and the dispatched session-context both carry the owner BYO model. Fails on old code with `got []`. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agents/dispatched-agent.test.ts | 96 ++++++++++++++++++++++++++++- src/agents/dispatched-agent.ts | 21 ++++++- src/agents/run-subagent.ts | 12 +++- src/api.ts | 14 ++--- 4 files changed, 132 insertions(+), 11 deletions(-) diff --git a/src/agents/dispatched-agent.test.ts b/src/agents/dispatched-agent.test.ts index 1ba4292c..34f322a5 100644 --- a/src/agents/dispatched-agent.test.ts +++ b/src/agents/dispatched-agent.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import test from 'node:test' @@ -10,6 +10,7 @@ import { createAssistantMessage, createUserMessage } from '../messages.js' import type { Runtime } from '../runtime/index.js' import { createSessionContext, runWithSessionContext } from '../session-context.js' import { setLightclawHomeOverride } from '../paths.js' +import { userHome } from '../identity/paths.js' import { setEnabled, setUserSecret } from '../secrets/store.js' import type { ChainState } from '../signal-bus/chain-state.js' import { buildDispatchedInitialMessages, runDispatchedAgent } from './dispatched-agent.js' @@ -187,6 +188,99 @@ test('worker ALS sessionId aligns with chainState path末端 + chainState rides } }) +test('BYO-only: a dispatched agent resolves its model against the owner config (empty admin base + user BYO)', async () => { + // Regression for the BYO-only deployment gap: when the admin global config + // has zero models/endpoints and the owner brings their own, a dispatched + // agent (worker / internal / bg fire) must resolve against the owner's + // resolved config — not the empty admin base, which would fail with + // "No model is configured / Registered: (none)". + const tempDir = mkdtempSync(path.join(os.tmpdir(), 'lightclaw-dispatched-byo-')) + setLightclawHomeOverride(tempDir) + try { + // Admin global config: empty model registry (a real BYO-only deployment). + writeFileSync(path.join(tempDir, 'config.json'), JSON.stringify({ + endpoints: {}, + models: {}, + defaultModel: '', + autoMemory: false, + })) + // Owner 'alice' brings her own apiKey-backed endpoint + model. + setUserSecret('alice', 'BYO_KEY', 'sk-alice-byo') + setEnabled('alice', 'BYO_KEY', true) + const aliceConfigPath = path.join(userHome('alice'), 'config.json') + mkdirSync(path.dirname(aliceConfigPath), { recursive: true }) + writeFileSync(aliceConfigPath, JSON.stringify({ + endpoints: { aliceapi: { apiKeyRef: 'BYO_KEY', baseUrl: 'http://example.test' } }, + models: { 'alice-byo': { endpoint: 'aliceapi', schema: 'anthropic', upstreamModel: 'm' } }, + defaultModel: 'alice-byo', + })) + + const adminBase = getConfig() + // Sanity: the admin base genuinely has no models (otherwise the test is moot). + assert.deepEqual(Object.keys(adminBase.models), []) + + // Parent context mirrors a background/internal trigger that holds only the + // empty admin base (no per-user resolved config) — the exact gap. + const parentCtx = createSessionContext({ + cwd: tempDir, + model: 'fake-model', + sessionsDir: path.join(tempDir, 'sessions'), + memoryDir: path.join(tempDir, 'memory', 'alice'), + currentUserId: 'alice', + runtime: fakeRuntime(tempDir), + sessionId: 'main-session', + config: adminBase, + }) + + let observedQueryModels: string[] | undefined + let observedSessionModels: string[] | undefined + await runWithSessionContext(parentCtx, async () => runDispatchedAgent({ + dispatchPrompt: 'do internal work', + role: internalRole(), + tools: [], + config: adminBase, // caller passes the empty admin base, as the bg/internal paths do + canonicalUser: 'alice', + label: 'memoryCurator', + queryImpl: async params => { + observedQueryModels = params.config ? Object.keys(params.config.models) : undefined + const inner = await import('../session-context.js').then(m => m.getCurrentSessionContext()) + observedSessionModels = inner?.config ? Object.keys(inner.config.models) : undefined + return { + messages: [ + ...params.messages, + createAssistantMessage({ + content: [{ type: 'text', text: 'done' }], + stopReason: 'end_turn', + usage: emptyUsage(), + }), + ], + assistantText: 'done', + finalReplyText: 'done', + stopReason: 'end_turn', + didCompact: false, + usage: emptyUsage(), + } + }, + })) + + // The config handed to query() (and thus to compact / session-memory / + // role-model resolution) must carry the owner's BYO model. + assert.ok( + observedQueryModels?.includes('alice-byo'), + `expected owner BYO model in query config, got ${JSON.stringify(observedQueryModels)}`, + ) + // getSessionConfig() inside the dispatched agent (imageRead / webSearch + // sub-LLMs read this) must also see the owner BYO model. + assert.ok( + observedSessionModels?.includes('alice-byo'), + `expected owner BYO model in session-context config, got ${JSON.stringify(observedSessionModels)}`, + ) + } finally { + setLightclawHomeOverride(undefined) + rmSync(tempDir, { recursive: true, force: true }) + } +}) + test('dispatched worker shift does NOT clear reply-codes at turn end (they live to run terminal)', async () => { const tempDir = mkdtempSync(path.join(os.tmpdir(), 'lightclaw-dispatched-reply-code-')) setLightclawHomeOverride(tempDir) diff --git a/src/agents/dispatched-agent.ts b/src/agents/dispatched-agent.ts index a775885f..1788313c 100644 --- a/src/agents/dispatched-agent.ts +++ b/src/agents/dispatched-agent.ts @@ -10,6 +10,7 @@ import { randomUUID } from 'node:crypto' import type { LightClawConfig } from '../config.js' +import { resolveUserConfig } from '../config/user-override.js' import { channelInterjectionQueue } from '../channels/feishu/interjection-queue.js' import { buildWorkerProgressForwarder } from '../taskrun/worker-progress.js' import { createUserMessage } from '../messages.js' @@ -98,6 +99,19 @@ export async function runDispatchedAgent( params: DispatchedAgentParams, ): Promise { const currentCtx = getCurrentSessionContext() + // Resolve the model registry against the OWNER, not whatever config the + // caller happened to hold. Background fires, post-turn internal roles + // (memoryExtractor / memoryCurator), and worker dispatches all converge here, + // and several of them are triggered with only the global admin base — which + // in a BYO-only deployment has zero models, so every downstream model + // resolution (the agent's own role model, its compaction / session-memory + // sub-LLMs, and getSessionConfig()-driven imageRead / webSearch) would fail + // with "No model is configured / Registered: (none)". resolveUserConfig is a + // union merge and idempotent, so passing an already-resolved config back + // through it is a no-op. + const effectiveConfig = params.canonicalUser + ? resolveUserConfig(params.canonicalUser, params.config) + : params.config const messages = [...buildDispatchedInitialMessages(params.dispatchPrompt)] // Top-level secrets (Phase 18 follow-up, 2026-06-14): a background/scheduled // fire dispatched DIRECTLY by main carries the owner's enabled secrets so @@ -211,7 +225,7 @@ export async function runDispatchedAgent( }), messages, tools: params.tools, - config: params.config, + config: effectiveConfig, maxTurns: params.maxTurns, }) // Dispatches always start fresh. The marker is kept at zero so fork @@ -233,6 +247,11 @@ export async function runDispatchedAgent( sessionId: chainSessionId ?? currentCtx.sessionId, chainState: params.chainState, currentTaskRunId: params.currentTaskRunId, + // Pin the session config to the owner-resolved registry so every + // `getSessionConfig()` read inside the dispatched agent (imageRead / + // webSearch sub-LLMs, that user's lang / permissionMode) honors the + // owner's BYO, not the parent's possibly-empty admin base. + config: effectiveConfig, discoveredTools: new Map(), turnCounter: 0, // Per-user runtime secrets (Phase 18). The childCtx is the single diff --git a/src/agents/run-subagent.ts b/src/agents/run-subagent.ts index 37878ff8..242e4098 100644 --- a/src/agents/run-subagent.ts +++ b/src/agents/run-subagent.ts @@ -1,4 +1,5 @@ import { getConfig } from '../config.js' +import { resolveUserConfig } from '../config/user-override.js' import { resolveRoleModel } from '../model-resolution.js' import { getProviderFor } from '../provider/index.js' import { getCurrentUserId } from '../state.js' @@ -43,7 +44,15 @@ export async function runSubagent(params: { detail: 'Pick one of the available subagent types.', }) } - const config = getConfig() + // Resolve the model registry against the OWNER (canonicalUserOverride for + // post-turn internal roles like memoryExtractor / memoryCurator that outlive + // the triggering ALS scope, else the current user). In a BYO-only deployment + // the global admin base has zero models, so resolving from getConfig() alone + // makes resolveRoleModel return '' and getProviderFor throw "No model is + // configured" before the dispatch even starts — the exact extraction / dream + // failure. resolveUserConfig is a union merge keyed on the owner's BYO. + const cacheUserKey = params.canonicalUserOverride ?? getCurrentUserId() + const config = resolveUserConfig(cacheUserKey, getConfig()) const roleModel = resolveRoleModel(agent, config) const provider = getProviderFor(config, roleModel).provider // Dynamic import: tools.ts → tools/background-task.ts → background-task/ @@ -65,7 +74,6 @@ export async function runSubagent(params: { // Dispatch semantics: the worker starts from exactly one caller-authored // prompt. The runner does not inherit the parent transcript; callers that // want context (extract / autoDream) must include it in params.prompt. - const cacheUserKey = params.canonicalUserOverride ?? getCurrentUserId() // Resolve the subagent turn cap: only an explicit caller override applies // (autoDream pulls from config.memory.curator.maxTurns), otherwise no cap diff --git a/src/api.ts b/src/api.ts index d4493cb5..d1561483 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,4 +1,4 @@ -import { getConfig, type LightClawConfig } from './config.js' +import { type LightClawConfig } from './config.js' import { resolveToolModuleModel } from './model-resolution.js' import { getProviderFor } from './provider/index.js' import { resetAllFailureCountersFor } from './provider/capability-cache.js' @@ -16,7 +16,7 @@ import { type ApiLogKind, type ApiLogTurnRecord, } from './api-logs/storage.js' -import { getCurrentUserId, getRuntimeIfInitialized, getSessionId } from './state.js' +import { getCurrentUserId, getRuntimeIfInitialized, getSessionConfig, getSessionId } from './state.js' /** * Tag describing which subsystem is making this streamChat call. Plumbed @@ -64,7 +64,7 @@ export async function* streamChat( }, ): AsyncGenerator { const { config: paramConfig, apiLogContext, ...rest } = params - const config = paramConfig ?? getConfig() + const config = paramConfig ?? getSessionConfig() // The caller passes a display model name (the key in config.models). // Resolve it once here to (a) pick the right provider instance, and // (b) substitute the real upstream id for the wire request. Logging @@ -239,7 +239,7 @@ export async function describeImage( }, ): Promise { const { config: paramConfig, model: requestedModel, ...rest } = params - const config = paramConfig ?? getConfig() + const config = paramConfig ?? getSessionConfig() const model = requestedModel ?? resolveToolModuleModel('imageRead', config) const { provider, entry } = getProviderFor(config, model) if (!provider.describeImage) { @@ -353,7 +353,7 @@ export function resolveDescribeRoute(input?: { provider: ReturnType['provider'] entry: ReturnType['entry'] } { - const config = input?.config ?? getConfig() + const config = input?.config ?? getSessionConfig() const model = input?.model ?? resolveToolModuleModel('imageRead', config) const { provider, entry } = getProviderFor(config, model) if (!provider.describeImage) { @@ -379,7 +379,7 @@ export async function transcribeAudio( }, ): Promise { const { config: paramConfig, model: requestedModel, ...rest } = params - const config = paramConfig ?? getConfig() + const config = paramConfig ?? getSessionConfig() const model = requestedModel ?? resolveToolModuleModel('imageRead', config) const { provider } = getProviderFor(config, model) if (!provider.transcribeAudio) { @@ -475,7 +475,7 @@ export function resolveWebFetchSummarizeRoute(input?: { provider: ReturnType['provider'] entry: ReturnType['entry'] } { - const config = input?.config ?? getConfig() + const config = input?.config ?? getSessionConfig() const displayModel = input?.model ?? resolveToolModuleModel('webSearch', config) const { provider, entry } = getProviderFor(config, displayModel)