From e21b72559d556e06f69c64dfa618e6e5f4df2414 Mon Sep 17 00:00:00 2001 From: Mariya Davydova Date: Wed, 21 Oct 2026 13:32:31 +0300 Subject: [PATCH 01/18] fix: support service-funded Soul generation through Surplus --- CHANGELOG.md | 3 + .../agent/src/providers/surplus-attempt.ts | 5 +- packages/agent/src/providers/universal.ts | 11 +- packages/agent/src/soul/generate-soul-file.ts | 499 ++++++++++++++++-- .../agent/src/usage/personal-llm-attempt.ts | 3 +- .../surplus-reasoning-parity.test.ts | 54 ++ .../tests/unit/personal-llm-attempt.test.ts | 69 +++ packages/agent/tests/unit/soul-file.test.ts | 419 +++++++++++++++ .../tests/unit/universal-retry-policy.test.ts | 41 ++ ...nal-soul-setup-service.integration.test.ts | 235 ++++++++- .../profile-soul-stream-route.test.ts | 10 +- 11 files changed, 1289 insertions(+), 60 deletions(-) create mode 100644 packages/agent/tests/unit/universal-retry-policy.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b76f8683..940e20ed5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ See [`RELEASE.md`](RELEASE.md) for publication and verification procedures. ## [Unreleased] +- Soul generation supports a server Surplus key without a direct provider key, + including streamed customization previews and service cost tracking. + - Background Tasks can save Workspace files using their authorized namespace room while keeping their transcripts separate from the originating chat. diff --git a/packages/agent/src/providers/surplus-attempt.ts b/packages/agent/src/providers/surplus-attempt.ts index 25148d2ae..20e3e47b4 100644 --- a/packages/agent/src/providers/surplus-attempt.ts +++ b/packages/agent/src/providers/surplus-attempt.ts @@ -45,6 +45,8 @@ export interface SurplusChatAttemptInput { readonly hasDeliveredOutput: () => boolean; readonly invokeModel: InvokeModel; readonly validateResponse?: (response: AIMessage) => void; + /** Narrow service callers may retry direct only after a proven pre-service refusal. */ + readonly directFallbackPolicy?: "pre_service_only"; } export type SurplusChatAttemptResult = @@ -464,7 +466,8 @@ export async function invokeSurplusChatAttempt(input: SurplusChatAttemptInput): warn("[nautilo/surplus] attempt settlement failed", { failureCode: "attempt_settlement_failed" }); } if (cancelled) throw input.config.signal?.reason ?? error; - if (disposition.directFallback) { + if (disposition.directFallback + && (input.directFallbackPolicy !== "pre_service_only" || disposition.outcome === "failed")) { return { kind: "direct_fallback", ...(input.providerMode !== "any" && disposition.failureCode === "no_sellers_for_model" diff --git a/packages/agent/src/providers/universal.ts b/packages/agent/src/providers/universal.ts index e46de9cc8..416c1d335 100644 --- a/packages/agent/src/providers/universal.ts +++ b/packages/agent/src/providers/universal.ts @@ -418,6 +418,14 @@ async function createUniversalModelInternal( const factoryOpts: CreateModelOptions = { modelId: id }; // The durable personal attempt owner accounts for each retry before the wire. if (personalCredential) factoryOpts.maxRetries = 0; + if (!personalCredential && cleanOptions["maxRetries"] !== undefined) { + const maxRetries = cleanOptions["maxRetries"]; + if (typeof maxRetries !== "number" || !Number.isSafeInteger(maxRetries) || maxRetries < 0) { + throw new Error("Provider retry count must be a nonnegative integer."); + } + factoryOpts.maxRetries = maxRetries; + } + delete cleanOptions["maxRetries"]; if (usageCallbacks) factoryOpts.callbacks = usageCallbacks; const apiKey = personalCredential?.apiKey ?? cleanOptions["apiKey"] as string | undefined; const baseUrl = (cleanOptions["baseUrl"] ?? cleanOptions["baseURL"]) as string | undefined; @@ -529,7 +537,7 @@ async function createUniversalModelInternal( usageCallbacks, personalCredential, ); - if (personalCredential) orOpts.maxRetries = 0; + if (factoryOpts.maxRetries !== undefined) orOpts.maxRetries = factoryOpts.maxRetries; if (resolvedMaxTokens !== undefined) orOpts.maxTokens = resolvedMaxTokens; if (resolvedTimeoutMs !== undefined) orOpts.timeoutMs = resolvedTimeoutMs; orOpts.reasoningOutput = reasoningOutput; @@ -541,6 +549,7 @@ async function createUniversalModelInternal( } case "gateway": { const gateway = buildGatewayCreateModelOptions(id, cleanOptions, usageCallbacks); + if (factoryOpts.maxRetries !== undefined) gateway.options.maxRetries = factoryOpts.maxRetries; if (resolvedMaxTokens !== undefined) gateway.options.maxTokens = resolvedMaxTokens; if (resolvedTimeoutMs !== undefined) gateway.options.timeoutMs = resolvedTimeoutMs; gateway.options.reasoningOutput = reasoningOutput; diff --git a/packages/agent/src/soul/generate-soul-file.ts b/packages/agent/src/soul/generate-soul-file.ts index 38c8bad60..375e47e1d 100644 --- a/packages/agent/src/soul/generate-soul-file.ts +++ b/packages/agent/src/soul/generate-soul-file.ts @@ -1,12 +1,36 @@ -import { HumanMessage, SystemMessage } from "@langchain/core/messages"; +import { + AIMessage, + AIMessageChunk, + HumanMessage, + SystemMessage, + type BaseMessage, +} from "@langchain/core/messages"; +import type { RunnableConfig } from "@langchain/core/runnables"; import { fromRuntimeConfig } from "@nautilo/config"; +import { getCachedServerModelConfigRow } from "@nautilo/db"; import { warn } from "@nautilo/logger"; import { createUniversalModel } from "../providers/universal"; +import { hasStubModelForTests } from "../providers/stub-model-state"; import { modelRouteProvider } from "../providers/model-route"; import { resolveOpenRouterTransport } from "../providers/openrouter-transport"; +import { resolveProviderKey } from "../resolve-provider-key"; import { runWithUsageContext } from "../usage/usage-context"; +import { runPersonalLlmAttempt } from "../usage/personal-llm-attempt"; import { normalizeSoulFileInput, type SoulFileInput } from "./types"; import { resolveModelRole } from "../config/model-role-resolution"; +import { modelHasRunnableCredentials } from "../chat/model-runtime-credentials"; +import { + invokeSurplusChatAttempt, + type SurplusChatAttemptResult, +} from "../providers/surplus-attempt"; +import { + resolveSurplusChatServingAvailability, + SurplusDirectFallbackUnavailableError, + type QualifiedSurplusChatRoute, +} from "../providers/surplus-route"; +import { SurplusOutcomeUnknownError } from "../providers/surplus-transport"; +import type { ChatModel } from "../providers/types"; +import { resolveCompletionBudget } from "../utils/chat-model-invocation"; import { assertCanUseServerFundedOwnSoul, assertCanUseServerProviderCredentials, @@ -273,6 +297,7 @@ function soulUsageProviderRoute(modelId: string): string { function soulUsageContext( modelId: string, authorization: SoulGenerationAuthorization, + providerRoute = soulUsageProviderRoute(modelId), ) { return { callType: "soul" as const, @@ -280,7 +305,7 @@ function soulUsageContext( funding: { kind: "service" as const, humanUserId: authorization.humanUserId, - providerRoute: soulUsageProviderRoute(modelId), + providerRoute, }, ...(authorization.agentId ? { metadata: { agentId: authorization.agentId, service: "soul_generation" } } @@ -288,6 +313,183 @@ function soulUsageContext( }; } +type SoulMessages = readonly [SystemMessage, HumanMessage]; + +interface ResolvedSoulInvocation { + readonly modelId: string; + readonly messages: SoulMessages; + readonly maxOutputTokens: number; + readonly surplusPolicyEnabled: boolean; + readonly surplusKey: string | null; + readonly surplusRoute: QualifiedSurplusChatRoute | null; +} + +function sameSurplusRoute( + left: QualifiedSurplusChatRoute | null, + right: QualifiedSurplusChatRoute | null, +): boolean { + if (left === null || right === null) return left === right; + return left.catalogModelId === right.catalogModelId + && left.surplusModelId === right.surplusModelId + && left.providerPin === right.providerPin + && left.supportsTools === right.supportsTools + && left.supportsVision === right.supportsVision + && left.supportsReasoning === right.supportsReasoning + && left.maxContextTokens === right.maxContextTokens + && left.maxOutputTokens === right.maxOutputTokens; +} + +function currentSoulSurplusRoute( + modelId: string, + policyEnabled: boolean, + surplusKey: string | null, +): QualifiedSurplusChatRoute | null { + const availability = resolveSurplusChatServingAvailability({ + catalogModelId: modelId, + policyEnabled, + keyConfigured: surplusKey !== null, + fundingKind: "server", + }); + return availability.status === "available" ? availability.route : null; +} + +function assertSoulInvocationSnapshotCurrent(invocation: ResolvedSoulInvocation): void { + const policyEnabled = getCachedServerModelConfigRow()?.preferSurplus === true; + const surplusKey = resolveProviderKey("surplus"); + const surplusRoute = currentSoulSurplusRoute(invocation.modelId, policyEnabled, surplusKey); + if (policyEnabled !== invocation.surplusPolicyEnabled + || surplusKey !== invocation.surplusKey + || !sameSurplusRoute(surplusRoute, invocation.surplusRoute)) { + throw new Error("Soul generation funding route changed before dispatch."); + } +} + +async function resolveSoulInvocation(input: SoulFileInput): Promise { + const config = fromRuntimeConfig(); + const configuredModelId = config.nautilo_soul_generator_model ?? config.nautilo_model; + const policyEnabled = getCachedServerModelConfigRow()?.preferSurplus === true; + const surplusKey = resolveProviderKey("surplus"); + const modelId = resolveModelRole("systemTasks", { + ...(configuredModelId ? { configuredId: configuredModelId } : {}), + serverSurplus: { + policyEnabled, + keyConfigured: surplusKey !== null, + }, + }); + const surplusRoute = currentSoulSurplusRoute(modelId, policyEnabled, surplusKey); + const messages: SoulMessages = [ + new SystemMessage(SOUL_FILE_SYSTEM_PROMPT), + new HumanMessage(buildSoulPrompt(input)), + ]; + return { + modelId, + messages, + maxOutputTokens: await resolveCompletionBudget(modelId, [...messages]), + surplusPolicyEnabled: policyEnabled, + surplusKey, + surplusRoute, + }; +} + +async function runDirectSoulAttempt( + modelId: string, + signal: AbortSignal, + authorization: SoulGenerationAuthorization, + invoke: () => Promise, + hasObservedProviderWork?: () => boolean, +): Promise { + const context = soulUsageContext(modelId, authorization); + return runWithUsageContext(context, () => hasStubModelForTests() + ? invoke() + : runPersonalLlmAttempt({ + modelId, + endpoint: "/v1/chat/completions", + signal, + ...(hasObservedProviderWork ? { hasObservedProviderWork } : {}), + invoke, + })); +} + +async function invokeDirectSoul( + invocation: ResolvedSoulInvocation, + signal: AbortSignal, + authorization: SoulGenerationAuthorization, +): Promise { + await assertSoulServerFunding(authorization); + assertSoulInvocationSnapshotCurrent(invocation); + const model = await createUniversalModel(invocation.modelId, { + maxRetries: 0, + maxTokens: invocation.maxOutputTokens, + }); + return await runDirectSoulAttempt( + invocation.modelId, + signal, + authorization, + async () => (await model.invoke([...invocation.messages], { signal })) as AIMessage, + ); +} + +async function invokeSurplusSoul( + invocation: ResolvedSoulInvocation & { + readonly surplusKey: string; + readonly surplusRoute: QualifiedSurplusChatRoute; + }, + signal: AbortSignal, + authorization: SoulGenerationAuthorization, + hasDeliveredOutput: () => boolean, + invokeModel?: ( + model: ChatModel, + messages: BaseMessage[], + config: RunnableConfig, + ) => Promise, +): Promise { + await assertSoulServerFunding(authorization); + assertSoulInvocationSnapshotCurrent(invocation); + const context = soulUsageContext(invocation.modelId, authorization, "surplus"); + return await runWithUsageContext(context, () => invokeSurplusChatAttempt({ + route: invocation.surplusRoute, + apiKey: invocation.surplusKey, + messages: [...invocation.messages], + tools: [], + config: { signal }, + maxOutputTokens: invocation.maxOutputTokens, + funding: { + kind: "service", + humanUserId: authorization.humanUserId, + providerRoute: "surplus", + }, + hasDeliveredOutput, + directFallbackPolicy: "pre_service_only", + validateResponse: (response) => { + if (!looksLikeSoulFile(extractMessageText(response.content).trim())) { + throw new Error("Soul generation returned incomplete markdown."); + } + }, + invokeModel: invokeModel + ? (model, messages, config) => invokeModel( + model as ChatModel, + messages, + config, + ) + : async (model, messages, config) => (await model.invoke(messages, config)) as AIMessage, + })); +} + +function hasSurplusRoute( + invocation: ResolvedSoulInvocation, +): invocation is ResolvedSoulInvocation & { + readonly surplusKey: string; + readonly surplusRoute: QualifiedSurplusChatRoute; +} { + return invocation.surplusKey !== null && invocation.surplusRoute !== null; +} + +function assertDirectSoulCredential(modelId: string): void { + if (!modelHasRunnableCredentials(modelId, process.env)) { + throw new SurplusDirectFallbackUnavailableError(); + } +} + /** * Provider errors may include API credentials, endpoints, or account details. * Keep logs useful for operations without ever serializing the provider's @@ -323,6 +525,7 @@ function extractMessageText(content: unknown): string { function createGenerationAbortContext(externalSignal?: AbortSignal): { readonly signal: AbortSignal; + readonly abort: () => void; readonly dispose: () => void; } { const controller = new AbortController(); @@ -339,6 +542,7 @@ function createGenerationAbortContext(externalSignal?: AbortSignal): { return { signal: controller.signal, + abort, dispose: () => { clearTimeout(timeout); externalSignal?.removeEventListener("abort", abort); @@ -346,6 +550,164 @@ function createGenerationAbortContext(externalSignal?: AbortSignal): { }; } +interface SoulStreamRelayItem { + readonly text: string; + readonly release: () => void; +} + +class SoulStreamRelay { + private readonly queued: SoulStreamRelayItem[] = []; + private waiter: (() => void) | undefined; + private finished = false; + + async emit(text: string): Promise { + if (!text) return; + await new Promise((resolve) => { + this.queued.push({ text, release: resolve }); + this.waiter?.(); + this.waiter = undefined; + }); + } + + finish(): void { + this.finished = true; + this.waiter?.(); + this.waiter = undefined; + } + + cancel(): void { + this.finished = true; + for (const item of this.queued.splice(0)) item.release(); + this.waiter?.(); + this.waiter = undefined; + } + + async next(): Promise { + for (;;) { + const item = this.queued.shift(); + if (item) return item; + if (this.finished) return null; + await new Promise((resolve) => { + this.waiter = resolve; + }); + } + } +} + +function asAIMessageChunk(value: unknown): AIMessageChunk { + if (AIMessageChunk.isInstance(value)) return value; + const chunk = value && typeof value === "object" + ? value as Record + : {}; + const fields = { + content: chunk["content"] ?? "", + ...(chunk["additional_kwargs"] && typeof chunk["additional_kwargs"] === "object" + ? { additional_kwargs: chunk["additional_kwargs"] } + : {}), + ...(chunk["response_metadata"] && typeof chunk["response_metadata"] === "object" + ? { response_metadata: chunk["response_metadata"] } + : {}), + ...(chunk["usage_metadata"] && typeof chunk["usage_metadata"] === "object" + ? { usage_metadata: chunk["usage_metadata"] } + : {}), + }; + return new AIMessageChunk(fields as never); +} + +async function consumeSoulModelStream( + model: ChatModel, + messages: BaseMessage[], + config: RunnableConfig, + relay: SoulStreamRelay, + observeProviderWork?: () => void, +): Promise { + if (!model.stream) { + return (await model.invoke(messages, config as Record)) as AIMessage; + } + const stream = await model.stream(messages, config as Record); + let aggregate: AIMessageChunk | undefined; + for await (const rawChunk of stream) { + observeProviderWork?.(); + const chunk = asAIMessageChunk(rawChunk); + aggregate = aggregate ? aggregate.concat(chunk) : chunk; + await relay.emit(extractMessageText(chunk.content)); + } + return aggregate ?? new AIMessage({ content: "" }); +} + +async function* relaySoulAttempt( + start: (relay: SoulStreamRelay) => Promise, +): AsyncGenerator { + const relay = new SoulStreamRelay(); + const attempt = start(relay); + void attempt.then( + () => relay.finish(), + () => relay.finish(), + ); + try { + for (;;) { + const item = await relay.next(); + if (!item) break; + item.release(); + yield item.text; + } + return await attempt; + } finally { + relay.cancel(); + } +} + +async function invokeDirectSoulStream( + invocation: ResolvedSoulInvocation, + signal: AbortSignal, + authorization: SoulGenerationAuthorization, + relay: SoulStreamRelay, + fallback: string, +): Promise { + await assertSoulServerFunding(authorization); + assertSoulInvocationSnapshotCurrent(invocation); + const model = await createUniversalModel(invocation.modelId, { + maxRetries: 0, + maxTokens: invocation.maxOutputTokens, + }); + let providerWorkObserved = false; + if (!model.stream) { + try { + const response = await runDirectSoulAttempt( + invocation.modelId, + signal, + authorization, + async () => (await model.invoke([...invocation.messages], { signal })) as AIMessage, + ); + const content = extractMessageText(response.content).trim(); + if (looksLikeSoulFile(content)) return response; + warn( + `[soul] LLM returned short/non-markdown content (${content.length} chars) for model ${invocation.modelId} — using fallback`, + ); + return new AIMessage({ content: fallback }); + } catch (error) { + if (error instanceof ServerProviderCredentialsDeniedError) throw error; + warnSoulGenerationFailure(invocation.modelId, signal, false); + return new AIMessage({ content: fallback }); + } + } + return await runDirectSoulAttempt( + invocation.modelId, + signal, + authorization, + () => consumeSoulModelStream( + model as ChatModel, + [...invocation.messages], + { signal }, + relay, + () => { + providerWorkObserved = true; + }, + ), + () => providerWorkObserved, + ); +} + export async function generateSoulFile( input: Partial, externalSignal: AbortSignal | undefined, @@ -357,22 +719,25 @@ export async function generateSoulFile( let modelId: string | undefined; try { - const config = fromRuntimeConfig(); - const configuredModelId = config.nautilo_soul_generator_model ?? config.nautilo_model; - modelId = resolveModelRole("systemTasks", { - ...(configuredModelId ? { configuredId: configuredModelId } : {}), - }); - const model = await createUniversalModel(modelId); - await assertSoulServerFunding(authorization); - const response = (await runWithUsageContext(soulUsageContext(modelId, authorization), () => - model.invoke( - [ - new SystemMessage(SOUL_FILE_SYSTEM_PROMPT), - new HumanMessage(buildSoulPrompt(normalized)), - ], - { signal: abortContext.signal }, - ), - )) as { content: unknown }; + const invocation = await resolveSoulInvocation(normalized); + modelId = invocation.modelId; + let response: AIMessage; + if (hasSurplusRoute(invocation)) { + const surplus = await invokeSurplusSoul( + invocation, + abortContext.signal, + authorization, + () => false, + ); + if (surplus.kind === "served") { + response = surplus.response; + } else { + assertDirectSoulCredential(invocation.modelId); + response = await invokeDirectSoul(invocation, abortContext.signal, authorization); + } + } else { + response = await invokeDirectSoul(invocation, abortContext.signal, authorization); + } const content = extractMessageText(response.content).trim(); @@ -402,40 +767,78 @@ export async function* generateSoulFileStream( const fallback = generateSoulFileFallback(normalized); const abortContext = createGenerationAbortContext(externalSignal); let modelId: string | undefined; + let activeAttempt: AsyncGenerator | undefined; yield { type: "started" }; try { - const config = fromRuntimeConfig(); - const configuredModelId = config.nautilo_soul_generator_model ?? config.nautilo_model; - modelId = resolveModelRole("systemTasks", { - ...(configuredModelId ? { configuredId: configuredModelId } : {}), - }); - const model = await createUniversalModel(modelId); - if (!model.stream) { - const soulFile = await generateSoulFile(normalized, abortContext.signal, authorization); - yield { type: "completed", soulFile }; - return; - } - + const invocation = await resolveSoulInvocation(normalized); + modelId = invocation.modelId; let content = ""; - await assertSoulServerFunding(authorization); - const usageContext = soulUsageContext(modelId, authorization); - const stream = await runWithUsageContext(usageContext, () => model.stream!( - [ - new SystemMessage(SOUL_FILE_SYSTEM_PROMPT), - new HumanMessage(buildSoulPrompt(normalized)), - ], - { signal: abortContext.signal }, - )); - const iterator = stream[Symbol.asyncIterator](); + let deliveredOutput = false; + + const streamAttempt = hasSurplusRoute(invocation) + ? relaySoulAttempt((relay) => invokeSurplusSoul( + invocation, + abortContext.signal, + authorization, + () => deliveredOutput, + (model, messages, config) => consumeSoulModelStream(model, messages, config, relay), + )) + : relaySoulAttempt((relay) => invokeDirectSoulStream( + invocation, + abortContext.signal, + authorization, + relay, + fallback, + )); + activeAttempt = streamAttempt; + + let firstResult: AIMessage | SurplusChatAttemptResult; for (;;) { - const next = await runWithUsageContext(usageContext, () => iterator.next()); - if (next.done) break; - const chunk = next.value; - const delta = extractMessageText((chunk as { content?: unknown }).content); - if (!delta) continue; - content += delta; - yield { type: "delta", text: delta }; + const next = await streamAttempt.next(); + if (next.done) { + firstResult = next.value; + activeAttempt = undefined; + break; + } + deliveredOutput = true; + content += next.value; + yield { type: "delta", text: next.value }; + } + + if (hasSurplusRoute(invocation)) { + const surplus = firstResult as SurplusChatAttemptResult; + if (surplus.kind === "served" && !content) { + content = extractMessageText(surplus.response.content); + } else if (surplus.kind === "direct_fallback") { + if (deliveredOutput) throw new SurplusOutcomeUnknownError(); + assertDirectSoulCredential(invocation.modelId); + const directAttempt = relaySoulAttempt((relay) => invokeDirectSoulStream( + invocation, + abortContext.signal, + authorization, + relay, + fallback, + )); + activeAttempt = directAttempt; + let directResponse: AIMessage | undefined; + for (;;) { + const next = await directAttempt.next(); + if (next.done) { + directResponse = next.value; + activeAttempt = undefined; + break; + } + deliveredOutput = true; + content += next.value; + yield { type: "delta", text: next.value }; + } + if (!content && directResponse) { + content = extractMessageText(directResponse.content); + } + } + } else if (!content) { + content = extractMessageText((firstResult as AIMessage).content); } const soulFile = content.trim(); @@ -457,6 +860,8 @@ export async function* generateSoulFileStream( warnSoulGenerationFailure(modelId, abortContext.signal, true); yield { type: "error", error: SOUL_GENERATION_FAILED_MESSAGE, fallback }; } finally { + abortContext.abort(); + await activeAttempt?.return(undefined); abortContext.dispose(); } } diff --git a/packages/agent/src/usage/personal-llm-attempt.ts b/packages/agent/src/usage/personal-llm-attempt.ts index 77d685406..6c7853f02 100644 --- a/packages/agent/src/usage/personal-llm-attempt.ts +++ b/packages/agent/src/usage/personal-llm-attempt.ts @@ -152,8 +152,9 @@ export async function runPersonalLlmAttempt(input: { const funding = context?.funding; const isMemoryWork = context && ["memory_review", "room_stenographer", "room_reflection", "room_event_compaction"].includes(context.callType); const isServerImageAssistance = context?.callType === "image_assistance" && funding?.kind === "server"; + const isSoulService = context?.callType === "soul" && funding?.kind === "service"; if (!context || !funding || funding.providerRoute === "surplus" - || (funding.kind !== "personal" && !isMemoryWork && !isServerImageAssistance)) return input.invoke(); + || (funding.kind !== "personal" && !isMemoryWork && !isServerImageAssistance && !isSoulService)) return input.invoke(); const nonpersonalBegin = funding.kind === "service" ? deps.beginService : deps.beginServer; const persistSettlement = funding.kind === "personal" ? deps.settle : funding.kind === "service" ? deps.settleService : deps.settleServer; diff --git a/packages/agent/tests/unit-isolated/surplus-reasoning-parity.test.ts b/packages/agent/tests/unit-isolated/surplus-reasoning-parity.test.ts index fe5b6cdcd..cd44bbbb0 100644 --- a/packages/agent/tests/unit-isolated/surplus-reasoning-parity.test.ts +++ b/packages/agent/tests/unit-isolated/surplus-reasoning-parity.test.ts @@ -588,3 +588,57 @@ test("personal marketplace attempts retain exact credential provenance and actua expect(settleAttempt.mock.calls.at(-1)?.[0]).toMatchObject({ outcome: "succeeded", costState: "actual", actualCostUsd: 0.000047 }); expect(JSON.stringify(beginAttempt.mock.calls.at(-1))).not.toContain("personal-surplus-only"); }); + +describe("strict service fallback policy", () => { + test("keeps a charged uncertain attempt unresolved instead of admitting direct replay", async () => { + await Promise.resolve(expect(invokeSurplusChatAttempt({ + route: ROUTE, apiKey: "synthetic-service-key", messages: [new HumanMessage("Synthetic Soul")], + tools: [], config: {}, maxOutputTokens: 256, + funding: { kind: "service", providerRoute: "surplus", humanUserId: "user-1" }, + directFallbackPolicy: "pre_service_only", hasDeliveredOutput: () => false, + invokeModel: async () => { + await observedResponse!({ requestId: "uncertain-soul", providerFamily: "openrouter", + buyerCostMicro: 17, truncated: false }, 200); + throw new Error("synthetic connection loss"); + }, + })).rejects.toBeInstanceOf(MockSurplusOutcomeUnknownError)); + expect(beginAttempt).toHaveBeenCalledTimes(1); + expect(settleAttempt).toHaveBeenCalledTimes(1); + expect(settleAttempt.mock.calls[0]?.[0]).toMatchObject({ + outcome: "unknown", costState: "actual", actualCostUsd: 0.000017, + }); + }); + + test("retains same-model direct eligibility after a proven zero-attempt refusal", async () => { + const result = await invokeSurplusChatAttempt({ + route: ROUTE, apiKey: "synthetic-service-key", messages: [new HumanMessage("Synthetic Soul")], + tools: [], config: {}, maxOutputTokens: 256, + funding: { kind: "service", providerRoute: "surplus", humanUserId: "user-1" }, + directFallbackPolicy: "pre_service_only", hasDeliveredOutput: () => false, + invokeModel: async () => { + await observedResponse!({ requestId: "refused-soul", marketplaceAttempts: 0, truncated: false }, 404); + throw Object.assign(new Error("Synthetic no-seller refusal"), { code: "no_sellers_for_model" }); + }, + }); + expect(result).toEqual({ kind: "direct_fallback", retryUnpinned: true }); + expect(settleAttempt.mock.calls[0]?.[0]).toMatchObject({ + outcome: "failed", costState: "pending", failureCode: "no_sellers_for_model", + }); + }); + + test("does not admit direct replay for an incomplete successful envelope", async () => { + await Promise.resolve(expect(invokeSurplusChatAttempt({ + route: ROUTE, apiKey: "synthetic-service-key", messages: [new HumanMessage("Synthetic Soul")], + tools: [], config: {}, maxOutputTokens: 256, + funding: { kind: "service", providerRoute: "surplus", humanUserId: "user-1" }, + directFallbackPolicy: "pre_service_only", hasDeliveredOutput: () => false, + invokeModel: async () => { + await observedResponse!({ requestId: "incomplete-soul", providerFamily: "openrouter", truncated: false }, 200); + return new AIMessage("Soul without terminal metadata"); + }, + })).rejects.toBeInstanceOf(MockSurplusOutcomeUnknownError)); + expect(settleAttempt.mock.calls[0]?.[0]).toMatchObject({ + outcome: "interrupted", costState: "pending", failureCode: "incomplete_response", + }); + }); +}); diff --git a/packages/agent/tests/unit/personal-llm-attempt.test.ts b/packages/agent/tests/unit/personal-llm-attempt.test.ts index 1864c22d7..82745bbf3 100644 --- a/packages/agent/tests/unit/personal-llm-attempt.test.ts +++ b/packages/agent/tests/unit/personal-llm-attempt.test.ts @@ -255,6 +255,75 @@ describe("explicit nonpersonal Memory model attempts", () => { }); }); +describe("Soul service direct attempts", () => { + const soulScope = (fn: () => T, providerRoute = "openai") => runWithUsageContext({ + callType: "soul", userId: funding.humanUserId, + funding: { kind: "service", humanUserId: funding.humanUserId, providerRoute }, + metadata: { agentId: "synthetic-agent", service: "soul_generation" }, + }, fn); + + test("opens a service row before dispatch and retains actual zero without personal provenance", async () => { + const ordinary = harness(); + const begun: BeginServiceLlmAttemptInput[] = []; + const settled: SettlePersonalLlmAttemptInput[] = []; + await soulScope(() => runPersonalLlmAttempt({ modelId, endpoint: "/v1/chat/completions", invoke: async () => { + expect(begun).toHaveLength(1); + expect(getUsageContext()?.trackedAttemptId).toBe(begun[0]!.id); + getUsageContext()?.onAttemptUsage?.({ inputTokens: 10, outputTokens: 4, + reasoningTokens: 0, cachedInputTokens: 0, cacheCreationTokens: 0, actualCostUsd: 0 }); + return "generated Soul"; + } }, { ...ordinary.deps, + beginService: async (row) => { begun.push(row); }, + settleService: async (row) => { settled.push(row); }, + })); + expect(ordinary.begun).toHaveLength(0); + expect(begun[0]).toMatchObject({ callType: "soul", userId: funding.humanUserId, + model: modelId, providerRoute: "openai", metadata: { agentId: "synthetic-agent" } }); + expect(begun[0]).not.toHaveProperty("credentialId"); + expect(settled).toHaveLength(1); + expect(settled[0]).toMatchObject({ outcome: "succeeded", costState: "actual", actualCostUsd: 0 }); + }); + + test("cannot dispatch without its durable service owner", async () => { + const h = harness(); + let calls = 0; + await Promise.resolve(expect(soulScope(() => runPersonalLlmAttempt({ modelId, endpoint: "/v1/chat/completions", + invoke: async () => { calls++; }, + }, { ...h.deps, owner: () => null, beginService: async () => {}, settleService: async () => {} }))) + .rejects.toBeInstanceOf(PersonalAttemptLedgerUnavailableError)); + expect(calls).toBe(0); + }); + + test("retains cancellation with unknown cost without replaying a streamed direct call", async () => { + const h = harness(); + const settled: SettlePersonalLlmAttemptInput[] = []; + const abort = new AbortController(); + let calls = 0; + await Promise.resolve(expect(soulScope(() => runPersonalLlmAttempt({ modelId, endpoint: "/v1/chat/completions", + signal: abort.signal, hasObservedProviderWork: () => true, invoke: async () => { + calls++; + abort.abort(); + throw new Error("synthetic stream closed"); + }, + }, { ...h.deps, beginService: async () => {}, settleService: async (row) => { settled.push(row); } }))) + .rejects.toBeInstanceOf(PersonalAttemptInvocationError)); + expect(calls).toBe(1); + expect(settled).toHaveLength(1); + expect(settled[0]).toMatchObject({ outcome: "cancelled", costState: "unknown" }); + expect(settled[0]).not.toHaveProperty("actualCostUsd"); + }); + + test("leaves Surplus Soul attempts to the existing marketplace ledger", async () => { + const h = harness(); + let calls = 0; + await soulScope(() => runPersonalLlmAttempt({ modelId, endpoint: "/v1/chat/completions", + invoke: async () => { calls++; return "marketplace Soul"; }, + }, { ...h.deps, owner: () => null }), "surplus"); + expect(calls).toBe(1); + expect(h.begun).toHaveLength(0); + }); +}); + describe("server-funded image assistance attempts", () => { function imageHarness() { const begun: BeginServiceLlmAttemptInput[] = []; diff --git a/packages/agent/tests/unit/soul-file.test.ts b/packages/agent/tests/unit/soul-file.test.ts index d0884829d..a06681f03 100644 --- a/packages/agent/tests/unit/soul-file.test.ts +++ b/packages/agent/tests/unit/soul-file.test.ts @@ -1,12 +1,17 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { AIMessage, AIMessageChunk } from "@langchain/core/messages"; import { setLogOutput } from "@nautilo/logger"; import { setConfigOverrides } from "@nautilo/config"; +import * as db from "@nautilo/db"; import { generateSoulFileFallback, normalizeSoulFileInput } from "../../src/index"; import * as soulModule from "../../src/soul/generate-soul-file"; import { __setStubModelForTests } from "../../src/providers/universal"; +import * as universalProvider from "../../src/providers/universal"; import type { ChatModel } from "../../src/providers/types"; import { ServerProviderCredentialsDeniedError } from "@nautilo/trust"; import { getUsageContext } from "../../src/usage/usage-context"; +import * as surplusAttempt from "../../src/providers/surplus-attempt"; +import * as personalAttempt from "../../src/usage/personal-llm-attempt"; const SOUL_AUTHORIZATION = { humanUserId: "user-1", @@ -14,10 +19,13 @@ const SOUL_AUTHORIZATION = { } as const; let originalOpenRouterKey: string | undefined; +let originalSurplusKey: string | undefined; beforeEach(() => { originalOpenRouterKey = process.env["OPENROUTER_API_KEY"]; + originalSurplusKey = process.env["SURPLUS_API_KEY"]; process.env["OPENROUTER_API_KEY"] = "test-openrouter-key"; + delete process.env["SURPLUS_API_KEY"]; }); afterEach(() => { @@ -28,6 +36,8 @@ afterEach(() => { delete process.env["NAUTILO_TEST_MODE"]; if (originalOpenRouterKey === undefined) delete process.env["OPENROUTER_API_KEY"]; else process.env["OPENROUTER_API_KEY"] = originalOpenRouterKey; + if (originalSurplusKey === undefined) delete process.env["SURPLUS_API_KEY"]; + else process.env["SURPLUS_API_KEY"] = originalSurplusKey; setLogOutput("stderr"); }); @@ -417,3 +427,412 @@ describe("streaming soul generation safety", () => { expect(streamed).toBeFalse(); }); }); + +describe("Surplus-funded soul generation", () => { + const modelId = "openrouter:openai/gpt-5.6-sol"; + const generatedSoul = [ + "## Essence", + "A precise and distinctive generated identity with enough detail to be useful.", + "", + "## Tone", + "Warm, exact, and candid without becoming performative.", + ].join("\n"); + + function preferSurplus(enabled: boolean) { + return spyOn(db, "getCachedServerModelConfigRow").mockReturnValue({ + preferSurplus: enabled, + } as NonNullable>); + } + + test("uses the selected Surplus-only system-task model in non-streaming mode", async () => { + delete process.env["OPENROUTER_API_KEY"]; + process.env["SURPLUS_API_KEY"] = "synthetic-surplus-key"; + setConfigOverrides({ nautilo_soul_generator_model: modelId }); + const configSpy = preferSurplus(true); + let observedContext: unknown; + const attemptSpy = spyOn(surplusAttempt, "invokeSurplusChatAttempt") + .mockImplementation(async (input) => { + observedContext = getUsageContext(); + expect(input).toMatchObject({ + route: { catalogModelId: modelId }, + apiKey: "synthetic-surplus-key", + funding: { kind: "service", humanUserId: "user-1", providerRoute: "surplus" }, + directFallbackPolicy: "pre_service_only", + }); + const response = new AIMessage({ + content: generatedSoul, + response_metadata: { finish_reason: "stop" }, + }); + expect(() => input.validateResponse?.(response)).not.toThrow(); + return { kind: "served", response }; + }); + + try { + expect(await soulModule.generateSoulFile( + { name: "Vex" }, undefined, SOUL_AUTHORIZATION, + )).toBe(generatedSoul); + expect(attemptSpy).toHaveBeenCalledTimes(1); + expect(observedContext).toMatchObject({ + callType: "soul", + userId: "user-1", + funding: { kind: "service", humanUserId: "user-1", providerRoute: "surplus" }, + }); + } finally { + attemptSpy.mockRestore(); + configSpy.mockRestore(); + } + }); + + test("rejects malformed Surplus output inside the paid attempt boundary", async () => { + delete process.env["OPENROUTER_API_KEY"]; + process.env["SURPLUS_API_KEY"] = "synthetic-surplus-key"; + setConfigOverrides({ nautilo_soul_generator_model: modelId }); + const configSpy = preferSurplus(true); + const attemptSpy = spyOn(surplusAttempt, "invokeSurplusChatAttempt") + .mockImplementation(async (input) => { + const malformed = new AIMessage({ + content: "This is not a structured soul file.", + response_metadata: { finish_reason: "stop" }, + }); + input.validateResponse?.(malformed); + return { kind: "served", response: malformed }; + }); + const stderr = spyOn(console, "error").mockImplementation(() => {}); + + try { + const result = await soulModule.generateSoulFile( + { name: "Vex" }, undefined, SOUL_AUTHORIZATION, + ); + expect(result).toContain("# Vex — Soul File"); + expect(attemptSpy).toHaveBeenCalledTimes(1); + } finally { + stderr.mockRestore(); + attemptSpy.mockRestore(); + configSpy.mockRestore(); + } + }); + + test("stops before dispatch when the Surplus policy or credential changes during authorization", async () => { + delete process.env["OPENROUTER_API_KEY"]; + process.env["SURPLUS_API_KEY"] = "synthetic-surplus-key"; + setConfigOverrides({ nautilo_soul_generator_model: modelId }); + let policyEnabled = true; + const configSpy = spyOn(db, "getCachedServerModelConfigRow").mockImplementation(() => ({ + preferSurplus: policyEnabled, + } as NonNullable>)); + const attemptSpy = spyOn(surplusAttempt, "invokeSurplusChatAttempt"); + const stderr = spyOn(console, "error").mockImplementation(() => {}); + + try { + const policyChanged = await soulModule.generateSoulFile({ name: "Vex" }, undefined, { + humanUserId: "user-1", + assertServerProviderCredentials: async () => { + policyEnabled = false; + }, + }); + expect(policyChanged).toContain("# Vex — Soul File"); + expect(attemptSpy).not.toHaveBeenCalled(); + + policyEnabled = true; + process.env["SURPLUS_API_KEY"] = "synthetic-surplus-key"; + const credentialChanged = await soulModule.generateSoulFile({ name: "Vex" }, undefined, { + humanUserId: "user-1", + assertServerProviderCredentials: async () => { + process.env["SURPLUS_API_KEY"] = "replacement-surplus-key"; + }, + }); + expect(credentialChanged).toContain("# Vex — Soul File"); + expect(attemptSpy).not.toHaveBeenCalled(); + } finally { + stderr.mockRestore(); + attemptSpy.mockRestore(); + configSpy.mockRestore(); + } + }); + + test("streams Surplus deltas while retaining terminal metadata on the aggregate", async () => { + delete process.env["OPENROUTER_API_KEY"]; + process.env["SURPLUS_API_KEY"] = "synthetic-surplus-key"; + setConfigOverrides({ nautilo_soul_generator_model: modelId }); + const configSpy = preferSurplus(true); + let aggregate: AIMessage | undefined; + const first = "## Essence\nA precise and distinctive generated identity "; + const second = "with enough detail to be useful.\n\n## Tone\nWarm, exact, and candid."; + const attemptSpy = spyOn(surplusAttempt, "invokeSurplusChatAttempt") + .mockImplementation(async (input) => { + aggregate = await input.invokeModel({ + async invoke() { + throw new Error("streaming path must not call invoke"); + }, + async stream() { + return { + async *[Symbol.asyncIterator]() { + yield new AIMessageChunk({ content: first }); + yield new AIMessageChunk({ + content: second, + response_metadata: { + finish_reason: "stop", + usage: { prompt_tokens: 17, completion_tokens: 23, buyer_cost_micro: 41 }, + }, + usage_metadata: { input_tokens: 17, output_tokens: 23, total_tokens: 40 }, + } as never); + }, + }; + }, + } as never, input.messages, input.config); + return { kind: "served", response: aggregate }; + }); + + try { + const events = []; + for await (const event of soulModule.generateSoulFileStream( + { name: "Vex" }, undefined, SOUL_AUTHORIZATION, + )) events.push(event); + + expect(events).toEqual([ + { type: "started" }, + { type: "delta", text: first }, + { type: "delta", text: second }, + { type: "completed", soulFile: first + second }, + ]); + expect(aggregate?.response_metadata).toMatchObject({ + finish_reason: "stop", + usage: { buyer_cost_micro: 41 }, + }); + expect(aggregate?.usage_metadata).toMatchObject({ + input_tokens: 17, + output_tokens: 23, + total_tokens: 40, + }); + } finally { + attemptSpy.mockRestore(); + configSpy.mockRestore(); + } + }); + + test("keeps policy-off Soul generation on the direct service-funded path", async () => { + process.env["NAUTILO_TEST_MODE"] = "stub"; + process.env["SURPLUS_API_KEY"] = "synthetic-surplus-key"; + setConfigOverrides({ nautilo_soul_generator_model: modelId }); + const configSpy = preferSurplus(false); + const attemptSpy = spyOn(surplusAttempt, "invokeSurplusChatAttempt"); + let observedContext: unknown; + __setStubModelForTests({ + async invoke() { + observedContext = getUsageContext(); + return new AIMessage({ content: generatedSoul }); + }, + }); + + try { + expect(await soulModule.generateSoulFile( + { name: "Vex" }, undefined, SOUL_AUTHORIZATION, + )).toBe(generatedSoul); + expect(attemptSpy).not.toHaveBeenCalled(); + expect(observedContext).toMatchObject({ + callType: "soul", + funding: { kind: "service", providerRoute: "openrouter" }, + }); + } finally { + attemptSpy.mockRestore(); + configSpy.mockRestore(); + } + }); + + test("marks provider work observed when a direct stream emits before a 429", async () => { + setConfigOverrides({ nautilo_soul_generator_model: modelId }); + const configSpy = preferSurplus(false); + const partial = "## Essence\nVisible direct-provider output."; + const providerError = Object.assign(new Error("late rate-limit response"), { status: 429 }); + const modelSpy = spyOn(universalProvider, "createUniversalModel").mockResolvedValue({ + async invoke() { + throw new Error("streaming path must not call invoke"); + }, + async stream() { + return { + async *[Symbol.asyncIterator]() { + yield new AIMessageChunk({ content: partial }); + throw providerError; + }, + }; + }, + }); + let observedAtFailure = false; + let failureClassification: ReturnType | undefined; + const personalAttemptSpy = spyOn(personalAttempt, "runPersonalLlmAttempt") + .mockImplementation(async (input) => { + try { + return await input.invoke(); + } catch (error) { + observedAtFailure = input.hasObservedProviderWork?.() === true; + failureClassification = personalAttempt.classifyPersonalAttemptFailure( + error, + false, + observedAtFailure, + ); + throw error; + } + }); + const stderr = spyOn(console, "error").mockImplementation(() => {}); + + try { + const events = []; + for await (const event of soulModule.generateSoulFileStream( + { name: "Vex" }, undefined, SOUL_AUTHORIZATION, + )) events.push(event); + expect(events[0]).toEqual({ type: "started" }); + expect(events[1]).toEqual({ type: "delta", text: partial }); + expect(events[2]).toMatchObject({ + type: "error", + error: soulModule.SOUL_GENERATION_FAILED_MESSAGE, + }); + expect(observedAtFailure).toBeTrue(); + expect(failureClassification).toEqual({ + outcome: "unknown", + failureCode: "outcome_unknown", + disposition: "terminal_unknown", + }); + expect(personalAttemptSpy).toHaveBeenCalledTimes(1); + } finally { + stderr.mockRestore(); + personalAttemptSpy.mockRestore(); + modelSpy.mockRestore(); + configSpy.mockRestore(); + } + }); + + test("rechecks live authorization before a safe direct fallback", async () => { + process.env["NAUTILO_TEST_MODE"] = "stub"; + process.env["SURPLUS_API_KEY"] = "synthetic-surplus-key"; + setConfigOverrides({ nautilo_soul_generator_model: modelId }); + const configSpy = preferSurplus(true); + const attemptSpy = spyOn(surplusAttempt, "invokeSurplusChatAttempt") + .mockResolvedValue({ kind: "direct_fallback" }); + let directInvoked = false; + __setStubModelForTests({ + async invoke() { + directInvoked = true; + return new AIMessage({ content: generatedSoul }); + }, + }); + let checks = 0; + + try { + await Promise.resolve(expect(soulModule.generateSoulFile({ name: "Vex" }, undefined, { + humanUserId: "user-1", + assertServerProviderCredentials: async (humanUserId, origin) => { + checks++; + if (checks > 1) throw new ServerProviderCredentialsDeniedError(humanUserId, origin); + }, + })).rejects.toBeInstanceOf(ServerProviderCredentialsDeniedError)); + expect(checks).toBe(2); + expect(directInvoked).toBeFalse(); + } finally { + attemptSpy.mockRestore(); + configSpy.mockRestore(); + } + }); + + test("does not replay a streaming attempt after it delivered visible output", async () => { + process.env["NAUTILO_TEST_MODE"] = "stub"; + process.env["SURPLUS_API_KEY"] = "synthetic-surplus-key"; + setConfigOverrides({ nautilo_soul_generator_model: modelId }); + const configSpy = preferSurplus(true); + let directInvoked = false; + __setStubModelForTests({ + async invoke() { + directInvoked = true; + return new AIMessage({ content: generatedSoul }); + }, + }); + const attemptSpy = spyOn(surplusAttempt, "invokeSurplusChatAttempt") + .mockImplementation(async (input) => { + await input.invokeModel({ + async invoke() { + throw new Error("streaming path must not call invoke"); + }, + async stream() { + return { + async *[Symbol.asyncIterator]() { + yield new AIMessageChunk({ content: "## Essence\nVisible partial output." }); + }, + }; + }, + } as never, input.messages, input.config); + return { kind: "direct_fallback" }; + }); + + try { + const events = []; + for await (const event of soulModule.generateSoulFileStream( + { name: "Vex" }, undefined, SOUL_AUTHORIZATION, + )) events.push(event); + expect(events[0]).toEqual({ type: "started" }); + expect(events[1]).toEqual({ type: "delta", text: "## Essence\nVisible partial output." }); + expect(events[2]).toMatchObject({ + type: "error", + error: soulModule.SOUL_GENERATION_FAILED_MESSAGE, + }); + expect(directInvoked).toBeFalse(); + expect(attemptSpy).toHaveBeenCalledTimes(1); + } finally { + attemptSpy.mockRestore(); + configSpy.mockRestore(); + } + }); + + test("aborts a Surplus stream when its consumer closes after visible output", async () => { + process.env["NAUTILO_TEST_MODE"] = "stub"; + process.env["SURPLUS_API_KEY"] = "synthetic-surplus-key"; + setConfigOverrides({ nautilo_soul_generator_model: modelId }); + const configSpy = preferSurplus(true); + let directInvoked = false; + let providerSignal: AbortSignal | undefined; + __setStubModelForTests({ + async invoke() { + directInvoked = true; + return new AIMessage({ content: generatedSoul }); + }, + }); + const attemptSpy = spyOn(surplusAttempt, "invokeSurplusChatAttempt") + .mockImplementation(async (input) => { + providerSignal = input.config.signal; + await input.invokeModel({ + async invoke() { + throw new Error("streaming path must not call invoke"); + }, + async stream() { + return { + async *[Symbol.asyncIterator]() { + yield new AIMessageChunk({ content: "## Essence\nVisible before close." }); + await new Promise((resolve) => { + if (providerSignal?.aborted) resolve(); + else providerSignal?.addEventListener("abort", () => resolve(), { once: true }); + }); + throw new Error("cancelled provider stream"); + }, + }; + }, + } as never, input.messages, input.config); + throw new Error("expected cancellation"); + }); + + try { + const stream = soulModule.generateSoulFileStream( + { name: "Vex" }, undefined, SOUL_AUTHORIZATION, + ); + expect((await stream.next()).value).toEqual({ type: "started" }); + expect((await stream.next()).value).toEqual({ + type: "delta", + text: "## Essence\nVisible before close.", + }); + await stream.return(undefined); + expect(providerSignal?.aborted).toBeTrue(); + expect(directInvoked).toBeFalse(); + expect(attemptSpy).toHaveBeenCalledTimes(1); + } finally { + attemptSpy.mockRestore(); + configSpy.mockRestore(); + } + }); +}); diff --git a/packages/agent/tests/unit/universal-retry-policy.test.ts b/packages/agent/tests/unit/universal-retry-policy.test.ts new file mode 100644 index 000000000..146aba327 --- /dev/null +++ b/packages/agent/tests/unit/universal-retry-policy.test.ts @@ -0,0 +1,41 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createUniversalModel } from "../../src/providers/universal"; +import { resetRuntimeModelCatalog } from "../../src/config/model-catalog/runtime-catalog"; +import { activateModelCatalogForTests } from "../helpers/activate-model-catalog"; + +const MODEL_ID = "openai:gpt-5.6-luna"; +const OPENROUTER_MODEL_ID = "openrouter:minimax/minimax-m3"; +beforeAll(async () => activateModelCatalogForTests([MODEL_ID, OPENROUTER_MODEL_ID])); +afterAll(() => resetRuntimeModelCatalog()); + +describe("explicit provider retry ownership", () => { + test("a service-owned attempt can disable SDK retries without changing the wire", async () => { + const model = await createUniversalModel(MODEL_ID, { apiKey: "synthetic-service-key", maxRetries: 0 }); + const sdk = model as unknown as { caller: { maxRetries: number }; modelKwargs?: Record }; + expect(sdk.caller.maxRetries).toBe(0); + expect(sdk.modelKwargs).not.toHaveProperty("maxRetries"); + }); + + test("invalid retry counts fail before constructing a provider", async () => { + for (const maxRetries of [-1, 0.5, Number.NaN, Number.POSITIVE_INFINITY, "0"]) { + await Promise.resolve(expect(createUniversalModel(MODEL_ID, { apiKey: "synthetic-service-key", maxRetries })) + .rejects.toThrow("Provider retry count must be a nonnegative integer.")); + } + }); + + test("OpenRouter preserves explicit retry ownership through its separate factory options", async () => { + const model = await createUniversalModel(OPENROUTER_MODEL_ID, { + apiKey: "synthetic-service-key", maxRetries: 0, + }); + const sdk = model as unknown as { caller: { maxRetries: number }; modelKwargs?: Record }; + expect(sdk.caller.maxRetries).toBe(0); + expect(sdk.modelKwargs).not.toHaveProperty("maxRetries"); + }); + + test("a caller cannot restore SDK retries for an admitted personal credential", async () => { + const model = await createUniversalModel(MODEL_ID, { + personalCredential: { apiKey: "synthetic-personal-key" }, maxRetries: 3, + }); + expect((model as unknown as { caller: { maxRetries: number } }).caller.maxRetries).toBe(0); + }); +}); diff --git a/packages/server/tests/integration/personal-soul-setup-service.integration.test.ts b/packages/server/tests/integration/personal-soul-setup-service.integration.test.ts index 26c184467..a84e0999e 100644 --- a/packages/server/tests/integration/personal-soul-setup-service.integration.test.ts +++ b/packages/server/tests/integration/personal-soul-setup-service.integration.test.ts @@ -1,4 +1,6 @@ +import { randomUUID } from "node:crypto"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { installPersonalLlmAttemptOwner } from "@nautilo/agent"; import { actors, agents, @@ -30,6 +32,10 @@ import { authedInject } from "./helpers/request-helpers"; const SOUL_MODEL = "openrouter:minimax/minimax-m3"; const SERVER_KEY = "sk-synthetic-soul-setup-service"; +const SURPLUS_KEY = "sk-synthetic-surplus-soul-setup-service"; +const SURPLUS_CHAT_URL = "https://api.surplusintelligence.ai/v1/chat/completions"; +const REQUEST_RUN_ID = randomUUID(); +const TIKTOKEN_GPT2_URL = "https://tiktoken.pages.dev/js/gpt2.json"; const GENERATED_SOUL = [ "# Vela — Soul File", "", @@ -39,18 +45,43 @@ const GENERATED_SOUL = [ "## Tone", "She writes with warmth and precision, keeps promises concrete, and lets a small dry joke through when it helps.", ].join("\n"); +const SURPLUS_SAVED_SOUL = [ + "# Sable — Soul File", + "", + "## Essence", + "Sable notices the useful edge of a problem and answers with patient precision.", + "", + "## Tone", + "Calm, exact, and lightly playful.", +].join("\n"); +const SURPLUS_PREVIEW_SOUL = [ + "# Sable — Preview Soul File", + "", + "## Essence", + "This second generated Soul is returned as a preview and is not saved by the stream route.", + "", + "## Tone", + "Direct and observant.", +].join("\n"); type Peer = Awaited>; let fx: AppFixture; let community: Peer; +let surplusCommunity: Peer; let guest: Peer; let originalFetch: typeof fetch; let priorOpenRouterKey: string | undefined; let priorGatewayKey: string | undefined; let priorGatewayUrl: string | undefined; let priorSoulModel: string | undefined; +let uninstallPersonalAttemptOwner: (() => void) | undefined; const providerRequests: Array<{ authorization: string | null; stream: boolean }> = []; +const surplusProviderRequests: Array<{ + authorization: string | null; + body: Record; + requestId: string; +}> = []; async function moveToCommunity(peer: Peer): Promise { const [communityGroup] = await fx.db @@ -92,6 +123,9 @@ function restoreEnvironment(): void { } beforeAll(async () => { + uninstallPersonalAttemptOwner = installPersonalLlmAttemptOwner({ + ownerToken: randomUUID(), + }); originalFetch = globalThis.fetch; priorOpenRouterKey = process.env["OPENROUTER_API_KEY"]; priorGatewayKey = process.env["NAUTILO_MANAGED_GATEWAY_API_KEY"]; @@ -110,6 +144,11 @@ beforeAll(async () => { groupType: "contributors", }); await moveToCommunity(community); + surplusCommunity = await seatPeerUser(fx.db, { + suiteName: "personalsoulsurplus", + groupType: "contributors", + }); + await moveToCommunity(surplusCommunity); guest = await seatPeerUser(fx.db, { suiteName: "personalsoul", groupType: "guests", @@ -117,39 +156,101 @@ beforeAll(async () => { globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url !== "https://openrouter.ai/api/v1/chat/completions") { - throw new Error(`Unexpected outbound request in Soul setup integration test: ${url}`); - } const request = input instanceof Request ? input : undefined; + const method = init?.method ?? request?.method ?? "GET"; + if (method === "GET" && url === TIKTOKEN_GPT2_URL) { + // Keep token-estimation fallback hermetic. This provider/persistence + // fixture deliberately uses LangChain's approximate count rather than + // turning the test into a tokenizer-asset network request. + return Response.json({}); + } const headers = new Headers(init?.headers ?? request?.headers); const rawBody = typeof init?.body === "string" ? init.body : request ? await request.clone().text() : "{}"; - const body = JSON.parse(rawBody) as { stream?: boolean }; - const stream = body.stream === true; + const body = JSON.parse(rawBody) as Record; + if (url === SURPLUS_CHAT_URL) { + const requestIndex = surplusProviderRequests.length; + const requestId = `surplus-soul-${REQUEST_RUN_ID}-${requestIndex + 1}`; + const soulFile = requestIndex === 0 ? SURPLUS_SAVED_SOUL : SURPLUS_PREVIEW_SOUL; + surplusProviderRequests.push({ + authorization: headers.get("authorization"), + body, + requestId, + }); + const midpoint = Math.floor(soulFile.length / 2); + const buyerCostMicro = 321 + requestIndex; + const chunks = [ + { + id: `chatcmpl-${requestId}`, + object: "chat.completion.chunk", + created: 1, + model: "minimax/minimax-m3", + choices: [{ index: 0, delta: { role: "assistant", content: soulFile.slice(0, midpoint) }, finish_reason: null }], + }, + { + id: `chatcmpl-${requestId}`, + object: "chat.completion.chunk", + created: 1, + model: "minimax/minimax-m3", + choices: [{ index: 0, delta: { content: soulFile.slice(midpoint) }, finish_reason: null }], + }, + { + id: `chatcmpl-${requestId}`, + object: "chat.completion.chunk", + created: 1, + model: "minimax/minimax-m3", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { + prompt_tokens: 18, + completion_tokens: 42, + total_tokens: 60, + buyer_cost_micro: buyerCostMicro, + }, + }, + ]; + return new Response( + `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`, + { + headers: { + "content-type": "text/event-stream", + "x-request-id": requestId, + "x-si-provider-family": "openrouter", + "x-si-marketplace-attempts": "1", + "x-si-buyer-cost-micro": String(buyerCostMicro), + "x-si-truncated": "0", + }, + }, + ); + } + if (url !== "https://openrouter.ai/api/v1/chat/completions") { + throw new Error(`Unexpected outbound request in Soul setup integration test: ${url}`); + } + const stream = body["stream"] === true; providerRequests.push({ authorization: headers.get("authorization"), stream }); + const requestId = `direct-soul-${REQUEST_RUN_ID}-${providerRequests.length}`; if (stream) { const midpoint = Math.floor(GENERATED_SOUL.length / 2); const chunks = [ { - id: "chatcmpl-soul-stream", + id: requestId, object: "chat.completion.chunk", created: 1, model: "minimax/minimax-m3", choices: [{ index: 0, delta: { role: "assistant", content: GENERATED_SOUL.slice(0, midpoint) }, finish_reason: null }], }, { - id: "chatcmpl-soul-stream", + id: requestId, object: "chat.completion.chunk", created: 1, model: "minimax/minimax-m3", choices: [{ index: 0, delta: { content: GENERATED_SOUL.slice(midpoint) }, finish_reason: null }], }, { - id: "chatcmpl-soul-stream", + id: requestId, object: "chat.completion.chunk", created: 1, model: "minimax/minimax-m3", @@ -164,7 +265,7 @@ beforeAll(async () => { } return Response.json({ - id: "chatcmpl-soul-nonstream", + id: requestId, object: "chat.completion", created: 1, model: "minimax/minimax-m3", @@ -179,11 +280,14 @@ beforeAll(async () => { }); afterAll(async () => { + uninstallPersonalAttemptOwner?.(); + uninstallPersonalAttemptOwner = undefined; globalThis.fetch = originalFetch; restoreEnvironment(); if (!fx) return; await upsertServerProviderPolicy(fx.db, { allowPersonalProviderKeys: false }); if (community) await removePeer(community); + if (surplusCommunity) await removePeer(surplusCommunity); if (guest) await removePeer(guest); await fx.cleanup(); }); @@ -245,6 +349,119 @@ describe.serial("personal-key own-Genie Soul setup service", () => { )).toBe(true); }); + test("uses Surplus for both real-generator endpoints while only the non-stream route persists", async () => { + const priorOpenRouter = process.env["OPENROUTER_API_KEY"]; + const priorSurplus = process.env["SURPLUS_API_KEY"]; + const ownerBearer = await fx.mintOwnerBearer(); + const priorModelsResponse = await authedInject(fx.app, { + method: "GET", + url: "/api/admin/server-models", + bearer: ownerBearer, + }); + expect(priorModelsResponse.statusCode, priorModelsResponse.body).toBe(200); + const priorPreferSurplus = priorModelsResponse.json<{ preferSurplus: boolean }>().preferSurplus; + const requestsBefore = surplusProviderRequests.length; + + try { + delete process.env["OPENROUTER_API_KEY"]; + process.env["SURPLUS_API_KEY"] = SURPLUS_KEY; + invalidateRuntimeConfigCache(); + expect(process.env["OPENROUTER_API_KEY"]).toBeUndefined(); + + const enabled = await authedInject(fx.app, { + method: "POST", + url: "/api/admin/server-models", + bearer: ownerBearer, + payload: { preferSurplus: true }, + }); + expect(enabled.statusCode, enabled.body).toBe(200); + + const generated = await authedInject(fx.app, { + method: "POST", + url: "/api/profile/generate-soul", + bearer: surplusCommunity.bearer, + payload: { name: "Sable", personalityPrompt: "Patient, exact, and lightly playful." }, + }); + expect(generated.statusCode, generated.body).toBe(200); + expect(JSON.parse(generated.body)).toEqual({ soulFile: SURPLUS_SAVED_SOUL }); + + const [savedAfterGenerate] = await fx.db + .select({ soulFile: profiles.soulFile }) + .from(profiles) + .where(eq(profiles.userId, surplusCommunity.userId)) + .limit(1); + expect(savedAfterGenerate?.soulFile).toBe(SURPLUS_SAVED_SOUL); + + const streamed = await authedInject(fx.app, { + method: "POST", + url: "/api/profile/generate-soul/stream", + bearer: surplusCommunity.bearer, + payload: { name: "Sable", personalityPrompt: "Direct, observant, and patient." }, + }); + expect(streamed.statusCode, streamed.body).toBe(200); + expect(streamed.body).toContain("event: soul.started"); + expect(streamed.body).toContain("event: soul.completed"); + expect(streamed.body).toContain("Preview Soul File"); + + const [savedAfterPreview] = await fx.db + .select({ soulFile: profiles.soulFile }) + .from(profiles) + .where(eq(profiles.userId, surplusCommunity.userId)) + .limit(1); + expect(savedAfterPreview?.soulFile).toBe(SURPLUS_SAVED_SOUL); + + const requests = surplusProviderRequests.slice(requestsBefore); + expect(requests).toHaveLength(2); + for (const request of requests) { + expect(request.authorization).toBe(`Bearer ${SURPLUS_KEY}`); + expect(request.body).toMatchObject({ + model: "minimax/minimax-m3", + provider: "openrouter", + stream: true, + }); + } + + const usage = await fx.db + .select() + .from(llmUsageEvents) + .where(eq(llmUsageEvents.userId, surplusCommunity.userId)); + const soulUsage = usage.filter((event) => event.callType === "soul"); + expect(soulUsage).toHaveLength(2); + expect(soulUsage.map((event) => event.providerRequestId).sort()).toEqual( + requests.map((request) => request.requestId).sort(), + ); + expect(soulUsage.map((event) => event.actualCostUsd).sort()).toEqual([ + "0.00032100", + "0.00032200", + ]); + for (const event of soulUsage) { + expect(event).toMatchObject({ + callType: "soul", + fundingKind: "service", + payerHumanId: null, + providerRoute: "surplus", + attemptOutcome: "succeeded", + costState: "actual", + servingProvider: "openrouter", + }); + expect(event.metadata?.["agentId"]).toBe(surplusCommunity.agentId); + } + } finally { + if (priorOpenRouter === undefined) delete process.env["OPENROUTER_API_KEY"]; + else process.env["OPENROUTER_API_KEY"] = priorOpenRouter; + if (priorSurplus === undefined) delete process.env["SURPLUS_API_KEY"]; + else process.env["SURPLUS_API_KEY"] = priorSurplus; + invalidateRuntimeConfigCache(); + const restored = await authedInject(fx.app, { + method: "POST", + url: "/api/admin/server-models", + bearer: ownerBearer, + payload: { preferSurplus: priorPreferSurplus }, + }); + expect(restored.statusCode, restored.body).toBe(200); + } + }); + test("a shared rolling limit counts sequential attempts across both routes but leaves server-authorized Humans unchanged", async () => { const third = await authedInject(fx.app, { method: "POST", url: "/api/profile/generate-soul", bearer: community.bearer, diff --git a/packages/server/tests/unit-isolated/profile-soul-stream-route.test.ts b/packages/server/tests/unit-isolated/profile-soul-stream-route.test.ts index e9223cdb3..a57f03388 100644 --- a/packages/server/tests/unit-isolated/profile-soul-stream-route.test.ts +++ b/packages/server/tests/unit-isolated/profile-soul-stream-route.test.ts @@ -10,6 +10,7 @@ const generateSoulFileMock = mock(async ( _signal?: AbortSignal, _authorization?: unknown, ) => "# Generated\n\n## Essence\nA usable generated Soul.\n\n## Tone\nExact."); +const upsertSoulProfileMock = mock(async (_userId: string, _agentId: string, _data: unknown) => undefined); const generateSoulFileStreamMock = mock( ( _input: Partial, @@ -28,6 +29,7 @@ describe("POST /api/profile/generate-soul/stream", () => { afterEach(async () => { generateSoulFileMock.mockClear(); + upsertSoulProfileMock.mockClear(); generateSoulFileStreamMock.mockReset(); await Promise.all(apps.splice(0).map((app) => app.close())); }); @@ -56,7 +58,7 @@ describe("POST /api/profile/generate-soul/stream", () => { }, generateSoulFile: generator, generateSoulFileStream: generateSoulFileStreamMock, - upsertSoulProfile: async () => undefined, + upsertSoulProfile: upsertSoulProfileMock, }); app.addHook("preHandler", async (request) => { request.sessionUserId = "test-user"; @@ -90,6 +92,7 @@ describe("POST /api/profile/generate-soul/stream", () => { expect(response.body).toContain("# Safe fallback"); expect(response.body).toContain("Unable to generate a soul file right now; using a fallback."); expect(response.body).not.toContain(secret); + expect(upsertSoulProfileMock).not.toHaveBeenCalled(); expect(generateSoulFileStreamMock.mock.calls[0]?.[2]).toMatchObject({ humanUserId: "test-user", agentId: "agent-own", @@ -117,6 +120,7 @@ describe("POST /api/profile/generate-soul/stream", () => { expect(response.statusCode).toBe(200); expect(signal).toBeDefined(); expect(signal?.aborted).toBe(false); + expect(upsertSoulProfileMock).not.toHaveBeenCalled(); }); test("denies before starting the soul provider stream without server funding", async () => { @@ -141,6 +145,10 @@ describe("POST /api/profile/generate-soul/stream", () => { }); expect(response.statusCode).toBe(200); expect(generateSoulFileMock).toHaveBeenCalledTimes(1); + expect(upsertSoulProfileMock).toHaveBeenCalledTimes(1); + expect(upsertSoulProfileMock).toHaveBeenCalledWith("test-user", "agent-own", { + soulFile: "# Generated\n\n## Essence\nA usable generated Soul.\n\n## Tone\nExact.", + }); expect(generateSoulFileMock.mock.calls[0]?.[2]).toMatchObject({ humanUserId: "test-user", agentId: "agent-own", From 95ac8292d5213cc720654a5994cd1f74ab76bdc0 Mon Sep 17 00:00:00 2001 From: Mariya Davydova Date: Wed, 21 Oct 2026 15:59:02 +0300 Subject: [PATCH 02/18] Support qualified Surplus embeddings with bound Memory disclosure Add the explicit Venice large embedding route with durable service usage and exact settlement recovery. Bind protected Memory writes and searches to the displayed model and processing route while preserving legacy wire formats and existing vector provenance. --- CHANGELOG.md | 5 + .../electron/foreground-shadow-controller.ts | 25 +- apps/desktop/electron/main.ts | 31 +- apps/desktop/electron/preload.ts | 3 + apps/desktop/package.json | 1 + .../unit/foreground-shadow-controller.test.ts | 83 +++++ .../foreground-shadow-main-contract.test.ts | 15 + ...foreground-shadow-preload-contract.test.ts | 7 + apps/workbench/package.json | 1 + apps/workbench/src/lib/desktop.ts | 3 + .../src/lib/memory-read-operations.ts | 25 +- .../lib/protected-human-memory-controller.ts | 10 +- .../pages/admin/sections/models-section.tsx | 2 + .../src/pages/memory/memory-page.tsx | 172 +++++++++- ...ge-protected-human-memory-contract.test.ts | 21 +- bun.lock | 3 + packages/agent/package.json | 1 + packages/agent/src/index.ts | 6 + .../src/providers/surplus-embedding-route.ts | 93 ++++++ packages/agent/src/store/embeddings.ts | 201 +++++++++++- .../src/usage/service-embedding-attempt.ts | 26 +- .../src/usage/surplus-embedding-attempt.ts | 276 ++++++++++++++++ .../memory-store-junction-m076.test.ts | 3 + packages/agent/tests/unit/embeddings.test.ts | 219 ++++++++++++- .../unit/service-embedding-attempt.test.ts | 38 ++- .../unit/surplus-embedding-attempt.test.ts | 244 ++++++++++++++ .../unit/surplus-embedding-route.test.ts | 59 ++++ packages/api-client/src/client.ts | 36 +++ .../src/schemas/memory-processor-transport.ts | 57 ++++ .../unit/memory-processor-transport.test.ts | 59 +++- packages/config/src/model-role-candidates.ts | 1 + .../tests/unit/model-role-candidates.test.ts | 1 + .../src/client/browser/index.ts | 2 + .../src/client/electron/index.ts | 2 + .../memory/authorized-human-memory-client.ts | 68 ++-- .../human-memory-processor-transport.ts | 87 ++++- .../vault-human-memory-device-content.ts | 60 ++-- .../foreground-shadow-client-composition.ts | 5 + packages/lattice-bridge/src/index.ts | 22 ++ .../memory/foreground-embedding-processor.ts | 30 +- .../foreground-memory-processor-transport.ts | 130 +++++++- .../human-memory-ordinary-fallback-request.ts | 187 ++++++++--- ...an-memory-ordinary-fallback-publication.ts | 43 ++- .../memory/human-memory-prepared-update.ts | 39 ++- .../human-memory-protected-route-ports.ts | 7 +- .../postgres-human-memory-product-update.ts | 13 +- ...-memory-product-crypto.integration.test.ts | 82 ++++- ...eground-memory-processor-transport.test.ts | 54 ++++ ...mory-ordinary-fallback-publication.test.ts | 54 +++- ...emory-ordinary-fallback-request-v2.test.ts | 131 ++++++++ .../unit/human-memory-prepared-update.test.ts | 34 ++ .../human-memory-processor-transport.test.ts | 123 +++++++ ...ory-foreground-embedding-processor.test.ts | 36 +++ .../vault-human-memory-device-content.test.ts | 87 ++++- .../scripts/mutation-scopes.json | 4 +- packages/lattice-crypto/src/internal-v2.ts | 25 ++ .../memory/content-embedding-request-v1.ts | 225 ++++++++----- .../memory/content-embedding-request-v3.ts | 53 +++ .../src/memory/embedding-disclosure-v2.ts | 111 +++++++ packages/lattice-crypto/src/wire.ts | 25 ++ .../tests/fixtures/public-api-allowlists.ts | 18 ++ .../integration/internal-v2-surface.test.ts | 13 + .../integration/public-api-contract.test.ts | 2 +- ...tent-embedding-request-v3.property.test.ts | 107 +++++++ ...emory-content-embedding-request-v3.test.ts | 109 +++++++ .../memory-embedding-disclosure-v2.test.ts | 74 +++++ .../tests/unit/mutation-governance.test.ts | 2 +- .../src/lib/surplus-cost-reconciliation.ts | 168 +++++++++- .../human-memory-embedding-processor.ts | 33 +- .../routes/human-memory-live-composition.ts | 5 +- packages/server/src/routes/memory.ts | 67 +++- .../routes/protected-memory-composition.ts | 7 + packages/server/src/routes/server-models.ts | 12 +- ...plus-embedding-service.integration.test.ts | 302 ++++++++++++++++++ .../tests/unit-isolated/memory-routes.test.ts | 154 ++++++++- .../human-memory-embedding-processor.test.ts | 50 +++ .../tests/unit/server-models-route.test.ts | 4 +- .../unit/surplus-cost-reconciliation.test.ts | 152 +++++++++ 78 files changed, 4441 insertions(+), 304 deletions(-) create mode 100644 packages/agent/src/providers/surplus-embedding-route.ts create mode 100644 packages/agent/src/usage/surplus-embedding-attempt.ts create mode 100644 packages/agent/tests/unit/surplus-embedding-attempt.test.ts create mode 100644 packages/agent/tests/unit/surplus-embedding-route.test.ts create mode 100644 packages/lattice-bridge/tests/unit/human-memory-ordinary-fallback-request-v2.test.ts create mode 100644 packages/lattice-crypto/src/memory/content-embedding-request-v3.ts create mode 100644 packages/lattice-crypto/src/memory/embedding-disclosure-v2.ts create mode 100644 packages/lattice-crypto/tests/property/human-memory-content-embedding-request-v3.property.test.ts create mode 100644 packages/lattice-crypto/tests/unit/human-memory-content-embedding-request-v3.test.ts create mode 100644 packages/lattice-crypto/tests/unit/memory-embedding-disclosure-v2.test.ts create mode 100644 packages/server/tests/integration/surplus-embedding-service.integration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 940e20ed5..3e16b5a06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ See [`RELEASE.md`](RELEASE.md) for publication and verification procedures. ## [Unreleased] +- Embeddings can use Surplus with an explicit Venice text-embedding-3-large + selection and Prefer Surplus enabled. Protected Memory shows and binds the + processing route before saving or searching, and usage receipts track + marketplace costs without replaying provider requests. + - Soul generation supports a server Surplus key without a direct provider key, including streamed customization previews and service cost tracking. diff --git a/apps/desktop/electron/foreground-shadow-controller.ts b/apps/desktop/electron/foreground-shadow-controller.ts index 820876e3c..050ed870e 100644 --- a/apps/desktop/electron/foreground-shadow-controller.ts +++ b/apps/desktop/electron/foreground-shadow-controller.ts @@ -1,4 +1,8 @@ -import type { NautiloApiClient, ProtectedMemoryProjectionV1 } from "@nautilo/api-client"; +import { type NautiloApiClient, type ProtectedMemoryProjectionV1 } from "@nautilo/api-client"; +import { + sameMemoryEmbeddingDisclosureV2, + type MemoryEmbeddingDisclosureV2, +} from "@nautilo/lattice-crypto/wire"; import { bindEncryptionDataOperationOwner, ClassifiedDataOperationError } from "@nautilo/lattice-bridge"; import { @@ -591,18 +595,27 @@ export class ElectronForegroundShadowController { type: string; content: string; importance: number; + requestedEmbedding?: MemoryEmbeddingDisclosureV2; }>) { return this.#runMemory(async () => { - const recipient = await this.#api.getMemoryProcessorRecipient(); - if (recipient.embedding === undefined) { - throw new TypeError("Memory embedding is unavailable"); + const embedding = input.requestedEmbedding === undefined + ? (await this.#api.getMemoryProcessorRecipient()).embedding + : (await this.#api.getMemoryProcessorRecipientV2()).embedding; + if (embedding === undefined) throw new TypeError("Memory embedding is unavailable"); + if (input.requestedEmbedding !== undefined + && (!("canonicalSpace" in embedding) + || !sameMemoryEmbeddingDisclosureV2(input.requestedEmbedding, embedding))) { + throw new TypeError( + "The Memory embedding disclosure changed. Reload it before trying again.", + ); } let item: unknown; const receipt = await this.#humanMemory.update(input.memoryId, { payload: { formatVersion: 1, type: input.type, content: input.content }, importance: input.importance, - requestedProvider: recipient.embedding.provider, - requestedModel: recipient.embedding.model, + ...("canonicalSpace" in embedding + ? { requestedEmbedding: input.requestedEmbedding! } + : { requestedProvider: embedding.provider, requestedModel: embedding.model }), }, (opened) => { item = protectedOpenedMemory(opened); }); diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index d2ccceb77..e4e7e913c 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -1,4 +1,9 @@ import { createChatGPTDesktopListener } from "./chatgpt-plan-loopback"; +import { + assertMemoryEmbeddingDisclosureV2, + decodeMemoryEmbeddingDisclosureV2, + encodeMemoryEmbeddingDisclosureV2, +} from "@nautilo/lattice-crypto/wire"; import "./instance-argv-bootstrap.ts"; import { LOCAL_EXECUTION_MAX_IDENTITIES } from "@nautilo/relay"; import { developmentProfileScope, projectAgentAccess, selectionForAgentAccess, selectionForReadyComponents } from "./agent-access"; @@ -8573,10 +8578,21 @@ ipcMain.handle("foregroundShadow:memory:list", async (e, raw: unknown) => { }); ipcMain.handle("foregroundShadow:memory:search", async (e, raw: unknown) => { - const options = boundedForegroundShadowValue(raw, "Protected Memory search") as - Parameters[0]; + const value = boundedForegroundShadowValue(raw, "Protected Memory search") as + Parameters[0] & { + embeddingDisclosure?: unknown; + }; + let embeddingDisclosure; + if (value.embeddingDisclosure !== undefined) { + assertMemoryEmbeddingDisclosureV2(value.embeddingDisclosure); + embeddingDisclosure = decodeMemoryEmbeddingDisclosureV2( + encodeMemoryEmbeddingDisclosureV2(value.embeddingDisclosure), + ); + } + const { embeddingDisclosure: _untrustedEmbedding, ...options } = value; const { controller } = await foregroundShadowControllerForSender(e); - return controller.memorySearch(options); + return controller.memorySearch({ ...options, + ...(embeddingDisclosure === undefined ? {} : { embeddingDisclosure }) }); }); ipcMain.handle("foregroundShadow:memory:detail", async (e, raw: unknown) => { @@ -8590,12 +8606,21 @@ ipcMain.handle("foregroundShadow:memory:detail", async (e, raw: unknown) => { ipcMain.handle("foregroundShadow:memory:update", async (e, raw: unknown) => { const value = boundedForegroundShadowValue(raw, "Protected Memory update") as { memoryId?: unknown; type?: unknown; content?: unknown; importance?: unknown; + requestedEmbedding?: unknown; }; + let requestedEmbedding; + if (value.requestedEmbedding !== undefined) { + assertMemoryEmbeddingDisclosureV2(value.requestedEmbedding); + requestedEmbedding = decodeMemoryEmbeddingDisclosureV2( + encodeMemoryEmbeddingDisclosureV2(value.requestedEmbedding), + ); + } const input = Object.freeze({ memoryId: foregroundShadowString(value.memoryId, "Memory id", 200), type: foregroundShadowString(value.type, "Memory type", 4_096), content: foregroundShadowString(value.content, "Memory content", 65_536), importance: value.importance, + ...(requestedEmbedding === undefined ? {} : { requestedEmbedding }), }); if (typeof input.importance !== "number" || !Number.isFinite(input.importance) || input.importance < 0 || input.importance > 1) { diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 3867ad000..6fc2c18d1 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -25,6 +25,7 @@ import type { } from "@nautilo/api-client"; import type { AtomicDocumentMutationEventBatch } from "@nautilo/document-mutations"; import type { MessagePayloadV2 } from "@nautilo/lattice-bridge"; +import type { MemoryEmbeddingDisclosureV2 } from "@nautilo/lattice-crypto/wire"; import type { BrowserRoomHistoryShadowAcknowledgementInput, HumanPeerLiveShadowReceiveResult, @@ -2048,12 +2049,14 @@ const foregroundShadowAPI = { search: (options: Readonly<{ q: string; mode: "text" | "semantic"; limit?: number; includeArchive?: boolean; + embeddingDisclosure?: MemoryEmbeddingDisclosureV2; }>) => ipcRenderer.invoke("foregroundShadow:memory:search", options), detail: (memoryId: string) => ipcRenderer.invoke( "foregroundShadow:memory:detail", { memoryId }, ), update: (input: Readonly<{ memoryId: string; type: string; content: string; importance: number; + requestedEmbedding?: MemoryEmbeddingDisclosureV2; }>) => ipcRenderer.invoke("foregroundShadow:memory:update", input), retryPendingMutations: () => ipcRenderer.invoke( "foregroundShadow:memory:retryPending", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2cfc37c53..e666dd646 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -62,6 +62,7 @@ "@nautilo/config": "workspace:*", "@nautilo/instance-discovery": "workspace:*", "@nautilo/lattice-bridge": "workspace:*", + "@nautilo/lattice-crypto": "workspace:*", "@nautilo/logger": "workspace:*", "@nautilo/mcp-client": "workspace:*", "@nautilo/relay": "workspace:*", diff --git a/apps/desktop/tests/unit/foreground-shadow-controller.test.ts b/apps/desktop/tests/unit/foreground-shadow-controller.test.ts index bc94fbc78..83528fed7 100644 --- a/apps/desktop/tests/unit/foreground-shadow-controller.test.ts +++ b/apps/desktop/tests/unit/foreground-shadow-controller.test.ts @@ -554,6 +554,89 @@ describe("ElectronForegroundShadowController", () => { await controller.dispose(); }); + test("signs Desktop Memory edits with the advertised marketplace disclosure", async () => { + let intent: unknown; + const embedding = { + contractVersion: 2 as const, + canonicalSpace: { provider: "venice" as const, + model: "text-embedding-3-large", dimensions: 1536 as const }, + processingRoute: { transport: "surplus_marketplace" as const, + marketplacePolicyVersion: "surplus-embedding-marketplace-v1" as const, + dataRecipientClasses: [ + "surplus", "marketplace_seller", "seller_upstream_processor", + ] as const }, + }; + const controller = createElectronForegroundShadowController(controllerInput({ + api: { + setToken: () => undefined, + admin: { encryptionTransition: { getPolicy: async () => ({ + policy: { mode: "shadow_encryption", shadowBehavior: "fallback", + revision: 1, updatedAt: "2026-09-01T00:00:00.000Z" }, + }) } }, + getMemoryProcessorRecipientV2: async () => ({ formatVersion: 2, + purpose: "memory.foreground_embedding", recipientId: "A".repeat(43), + publicKeyBase64url: "B".repeat(87), embedding }), + } as unknown as ElectronForegroundShadowApi, + factories: factories({ humanMemory: { + update: async (_memoryId: string, value: unknown) => { + intent = value; + return { status: "ordinary_fallback", memoryId: "memory-1", + reason: "target_encryption_not_ready" }; + }, + } }), + })); + expect(await controller.memoryUpdate({ memoryId: "memory-1", type: "fact", + content: "private", importance: 0.8, + requestedEmbedding: embedding })).toMatchObject({ + status: "ordinary_fallback", memoryId: "memory-1", + }); + expect(intent).toEqual({ + payload: { formatVersion: 1, type: "fact", content: "private" }, + importance: 0.8, + requestedEmbedding: embedding, + }); + await controller.dispose(); + }); + + test("rejects a Desktop Memory edit when the displayed route no longer matches", async () => { + const direct = { + contractVersion: 2 as const, + canonicalSpace: { provider: "venice" as const, + model: "text-embedding-3-large", dimensions: 1536 as const }, + processingRoute: { transport: "direct" as const }, + }; + const marketplace = { ...direct, processingRoute: { + transport: "surplus_marketplace" as const, + marketplacePolicyVersion: "surplus-embedding-marketplace-v1" as const, + dataRecipientClasses: [ + "surplus", "marketplace_seller", "seller_upstream_processor", + ] as const, + } }; + let updates = 0; + const controller = createElectronForegroundShadowController(controllerInput({ + api: { + setToken: () => undefined, + admin: { encryptionTransition: { getPolicy: async () => ({ + policy: { mode: "shadow_encryption", shadowBehavior: "fallback", + revision: 1, updatedAt: "2026-09-01T00:00:00.000Z" }, + }) } }, + getMemoryProcessorRecipientV2: async () => ({ formatVersion: 2, + purpose: "memory.foreground_embedding", recipientId: "A".repeat(43), + publicKeyBase64url: "B".repeat(87), embedding: marketplace }), + } as unknown as ElectronForegroundShadowApi, + factories: factories({ humanMemory: { + update: async () => { updates += 1; throw new Error("unexpected update"); }, + } }), + })); + const failure = await rejected(controller.memoryUpdate({ memoryId: "memory-1", + type: "fact", content: "private", importance: 0.8, + requestedEmbedding: direct })); + expect(failure).toBeInstanceOf(TypeError); + expect((failure as TypeError).message).toContain("embedding disclosure changed"); + expect(updates).toBe(0); + await controller.dispose(); + }); + test("withholds a Memory result when policy changes during the operation", async () => { const started = deferred(); const release = deferred(); diff --git a/apps/desktop/tests/unit/foreground-shadow-main-contract.test.ts b/apps/desktop/tests/unit/foreground-shadow-main-contract.test.ts index 49abe5b74..8d047c898 100644 --- a/apps/desktop/tests/unit/foreground-shadow-main-contract.test.ts +++ b/apps/desktop/tests/unit/foreground-shadow-main-contract.test.ts @@ -70,6 +70,21 @@ describe("foreground Shadow main-process contract", () => { } }); + test("strictly decodes the renderer-approved Memory disclosure before custody", () => { + const search = handler("foregroundShadow:memory:search", "foregroundShadow:memory:detail"); + const update = handler("foregroundShadow:memory:update", "foregroundShadow:memory:retryPending"); + for (const operation of [search, update]) { + expect(operation).toContain("assertMemoryEmbeddingDisclosureV2("); + expect(operation).toContain("decodeMemoryEmbeddingDisclosureV2("); + expect(operation).toContain("encodeMemoryEmbeddingDisclosureV2("); + expect(operation.indexOf("assertMemoryEmbeddingDisclosureV2(")).toBeLessThan( + operation.indexOf("foregroundShadowControllerForSender(e)"), + ); + } + expect(search).toContain("embeddingDisclosure"); + expect(update).toContain("requestedEmbedding"); + }); + test("binds one controller to the active sender/session and disposes on lifecycle exits", () => { const factory = main.slice( main.indexOf("async function foregroundShadowControllerForSender("), diff --git a/apps/desktop/tests/unit/foreground-shadow-preload-contract.test.ts b/apps/desktop/tests/unit/foreground-shadow-preload-contract.test.ts index 8b3d94225..e9e0154d1 100644 --- a/apps/desktop/tests/unit/foreground-shadow-preload-contract.test.ts +++ b/apps/desktop/tests/unit/foreground-shadow-preload-contract.test.ts @@ -73,4 +73,11 @@ describe("M300 foreground Shadow preload contract", () => { "DesktopForegroundShadowStoredMessage", ); }); + + test("carries the displayed Memory embedding disclosure across the IPC surface", () => { + expect(preload).toContain("embeddingDisclosure?: MemoryEmbeddingDisclosureV2"); + expect(preload).toContain("requestedEmbedding?: MemoryEmbeddingDisclosureV2"); + expect(workbenchDesktop).toContain("embeddingDisclosure?: MemoryEmbeddingDisclosureV2"); + expect(workbenchDesktop).toContain("requestedEmbedding?: MemoryEmbeddingDisclosureV2"); + }); }); diff --git a/apps/workbench/package.json b/apps/workbench/package.json index 103af6237..9f6145d44 100644 --- a/apps/workbench/package.json +++ b/apps/workbench/package.json @@ -106,6 +106,7 @@ "@nautilo/agent": "workspace:*", "@nautilo/computer-use-contracts": "workspace:*", "@nautilo/lattice-bridge": "workspace:*", + "@nautilo/lattice-crypto": "workspace:*", "@tailwindcss/typography": "0.5.19", "@tailwindcss/vite": "4.2.2", "@testing-library/react": "16.3.2", diff --git a/apps/workbench/src/lib/desktop.ts b/apps/workbench/src/lib/desktop.ts index 5ca39e377..d80d5384b 100644 --- a/apps/workbench/src/lib/desktop.ts +++ b/apps/workbench/src/lib/desktop.ts @@ -43,6 +43,7 @@ import type { NautiloApiClient, } from "@nautilo/api-client/browser"; import type { MessagePayloadV2 } from "@nautilo/lattice-bridge"; +import type { MemoryEmbeddingDisclosureV2 } from "@nautilo/lattice-crypto/wire"; import type { AuthorizedHumanMemoryClient, BrowserRoomHistoryShadowAcknowledgementInput, @@ -354,6 +355,7 @@ export interface DesktopForegroundShadowAPI { search(options: Readonly<{ q: string; mode: "text" | "semantic"; limit?: number; includeArchive?: boolean; + embeddingDisclosure?: MemoryEmbeddingDisclosureV2; }>): Promise>; update(input: Readonly<{ memoryId: string; type: string; content: string; importance: number; + requestedEmbedding?: MemoryEmbeddingDisclosureV2; }>): Promise Promise; resolveEmbedding?: () => Promise>; + }> | MemoryEmbeddingDisclosureV2>; }>): WorkbenchMemoryReadOperations { return Object.freeze({ async list(options: MemoryListOptions) { @@ -173,9 +174,17 @@ export function createWorkbenchMemoryReadOperations(input: Readonly<{ ...item, updatedAt: item.createdAt, namespaceIds: [], })); }, - protected: async () => (await protectedPort(input.protected).search({ - ...options, mode: "semantic", - })).items.map(row), + protected: async () => { + const embedding = await input.resolveEmbedding?.(); + if (embedding === undefined) throw new ClassifiedDataOperationError( + "unsupported", "Memory embedding is unavailable", + ); + return (await protectedPort(input.protected).search({ + ...options, mode: "semantic", + ...( "canonicalSpace" in embedding + ? { embeddingDisclosure: embedding } : {}), + })).items.map(row); + }, consumeOrdinary: (value) => value, consumeProtected: (value) => value, })).value; @@ -213,13 +222,17 @@ export function createWorkbenchMemoryReadOperations(input: Readonly<{ const embedding = await input.resolveEmbedding?.(); if (embedding === undefined) throw new ClassifiedDataOperationError("unsupported", "Memory embedding is unavailable"); return protectedMutationPort(input.protected).update({ memoryId, ...edit, - requestedProvider: embedding.provider, requestedModel: embedding.model }); + ...("canonicalSpace" in embedding + ? { requestedEmbedding: embedding } + : { requestedProvider: embedding.provider, requestedModel: embedding.model }) }); }, protected: async () => { const embedding = await input.resolveEmbedding?.(); if (embedding === undefined) throw new ClassifiedDataOperationError("unsupported", "Memory embedding is unavailable"); return protectedMutationPort(input.protected).update({ memoryId, ...edit, - requestedProvider: embedding.provider, requestedModel: embedding.model }); + ...("canonicalSpace" in embedding + ? { requestedEmbedding: embedding } + : { requestedProvider: embedding.provider, requestedModel: embedding.model }) }); }, }); }, diff --git a/apps/workbench/src/lib/protected-human-memory-controller.ts b/apps/workbench/src/lib/protected-human-memory-controller.ts index 135bd23f3..6b9420614 100644 --- a/apps/workbench/src/lib/protected-human-memory-controller.ts +++ b/apps/workbench/src/lib/protected-human-memory-controller.ts @@ -5,6 +5,7 @@ import type { AuthorizedHumanMemoryUnavailableReason, } from "@nautilo/lattice-bridge/client/browser"; import type { ProtectedMemoryProjectionV1 } from "@nautilo/api-client/browser"; +import type { MemoryEmbeddingDisclosureV2 } from "@nautilo/api-client/browser"; import { runWithCryptoAdmission } from "./crypto-admission-access"; export type WorkbenchProtectedMemoryContent = @@ -151,9 +152,10 @@ function createUnfencedWorkbenchProtectedHumanMemoryController( type: string; content: string; importance: number; + } & ({ requestedProvider: "openai" | "openrouter" | "venice"; requestedModel: string; - }>) { + } | { requestedEmbedding: MemoryEmbeddingDisclosureV2 })>) { let memory: WorkbenchProtectedMemory | undefined; const response = await client.update(input.memoryId, { payload: { @@ -162,8 +164,10 @@ function createUnfencedWorkbenchProtectedHumanMemoryController( content: input.content, }, importance: input.importance, - requestedProvider: input.requestedProvider, - requestedModel: input.requestedModel, + ...("requestedEmbedding" in input + ? { requestedEmbedding: input.requestedEmbedding } + : { requestedProvider: input.requestedProvider, + requestedModel: input.requestedModel }), }, (value) => { memory = protectedOpened(value); }); diff --git a/apps/workbench/src/pages/admin/sections/models-section.tsx b/apps/workbench/src/pages/admin/sections/models-section.tsx index 5f7ab2913..a61f2d5f8 100644 --- a/apps/workbench/src/pages/admin/sections/models-section.tsx +++ b/apps/workbench/src/pages/admin/sections/models-section.tsx @@ -33,6 +33,7 @@ const SUPPORTED_EMBEDDING_MODEL_IDS = new Set([ "venice:text-embedding-3-small", "openrouter:openai/text-embedding-3-small", "openai:text-embedding-3-small", + "venice:text-embedding-3-large", ]); type SaveState = "idle" | "saving" | "saved" | { error: string }; @@ -666,6 +667,7 @@ export function ModelsSection() {

Creates vectors for semantic Memory search and Reflection. Server configuration inherits the operator setting. Automatic selects the first configured provider: Qwen3 Embedding 8B on Venice or OpenRouter, then text-embedding-3-small on OpenAI. + {" "}For Surplus, enable Prefer Surplus and select Venice — text-embedding-3-large explicitly.

- + {draft.embeddingModel !== null && draft.embeddingModel !== "" && !supportedEmbeddingModels.some((model) => model.id === draft.embeddingModel) ? (