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
96 changes: 95 additions & 1 deletion src/agents/dispatched-agent.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 20 additions & 1 deletion src/agents/dispatched-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -98,6 +99,19 @@ export async function runDispatchedAgent(
params: DispatchedAgentParams,
): Promise<DispatchedAgentResult> {
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
Comment on lines +112 to +114
Comment on lines +112 to +114
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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 10 additions & 2 deletions src/agents/run-subagent.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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/
Expand All @@ -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
Expand Down
14 changes: 7 additions & 7 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -64,7 +64,7 @@ export async function* streamChat(
},
): AsyncGenerator<StreamEvent> {
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
Expand Down Expand Up @@ -239,7 +239,7 @@ export async function describeImage(
},
): Promise<DescribeImageResult> {
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) {
Expand Down Expand Up @@ -353,7 +353,7 @@ export function resolveDescribeRoute(input?: {
provider: ReturnType<typeof getProviderFor>['provider']
entry: ReturnType<typeof getProviderFor>['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) {
Expand All @@ -379,7 +379,7 @@ export async function transcribeAudio(
},
): Promise<TranscribeAudioResult> {
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) {
Expand Down Expand Up @@ -475,7 +475,7 @@ export function resolveWebFetchSummarizeRoute(input?: {
provider: ReturnType<typeof getProviderFor>['provider']
entry: ReturnType<typeof getProviderFor>['entry']
} {
const config = input?.config ?? getConfig()
const config = input?.config ?? getSessionConfig()
const displayModel =
input?.model ?? resolveToolModuleModel('webSearch', config)
const { provider, entry } = getProviderFor(config, displayModel)
Expand Down