Setup profile is minted by the backend and found by role, not by name (#119456)
* feat: setup profile is minted by the backend and found by role, not by name The guided onboarding runs in a profile the desktop used to create itself (profiles.create with a soul, "already exists" treated as success) and recognise by the literal "hermes-setup". The upcoming setup toolset grants catalog installs to that profile, so the marker that grants it must be written only by the backend. - profile.yaml carries `role: setup`; read_profile_meta / write_profile_meta / ProfileInfo know it; profiles.list and GET /api/profiles report it. - hermes_cli/setup_profile.py: ensure (find by role, adopt a pre-role hermes-setup dir, else clone default + soul + role) and reset (soul, memories, skills back to the created state, in place). The soul text moves here from the renderer. - tui_gateway/methods_onboarding.py: onboarding.ensure_setup_profile and onboarding.reset_setup_profile. Neither takes a name; profiles.create and profiles.configure already reject `role` (unknown key, 4000). - Copies never inherit the role: --clone-all, profile import, and a distribution that ships profile.yaml drop it. - setup.status for a named profile reports `ready` once the boot bootstrap settled. Since one host backend serves every profile (#118246) the desktop's setup-profile probe lands on this branch, which never set `ready`, and the kickoff waited forever. - Desktop: SETUP_PROFILE, ensureSetupProfile(profiles.create) and composeSetupSoul are gone. store/setup-profile.ts holds the name the backend returned (or the roster's role row after a relaunch); kickoff, handoff and the build card use it. The dev reset calls the reset RPC. * fix: write the setup soul as bytes so Windows keeps \n line endings * refactor(desktop): drop the renderer's setup-profile store; the backend is the only owner Kickoff reads the name straight from onboarding.ensure_setup_profile and records it on $setupSession, which every later step already carries. The handoff recovery check reads the roster row's role. No renderer module holds a setup-profile name or a fallback lookup.
This commit is contained in:
@@ -21,7 +21,7 @@ export function ChatSwapOverlay({ profile }: { profile: string | null }) {
|
||||
|
||||
// The first run swaps profiles twice — into the setup profile, then into the
|
||||
// task profile — and neither is a thing the user asked for or has a name for.
|
||||
// "Waking up hermes-setup…" over a greeting that is already on screen reads
|
||||
// "Waking up <setup profile>…" over a greeting that is already on screen reads
|
||||
// as a stall in the one moment that has to feel instant. The flow narrates
|
||||
// its own handoff (the handoff card) and the greeting is banked, so there
|
||||
// is nothing here to cover.
|
||||
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
firstTaskTitle,
|
||||
guideSourceConnectionId,
|
||||
readGuideHandoffReceipt,
|
||||
retrySetupHandoff,
|
||||
SETUP_PROFILE
|
||||
retrySetupHandoff
|
||||
} from '@/components/onboarding-chat/setup-profile'
|
||||
import { showHandoffTour } from '@/components/onboarding-chat/signpost'
|
||||
import { findGroupOfPane } from '@/components/pane-shell/tree/model'
|
||||
@@ -28,7 +27,7 @@ import { requestGatewayForAgent } from '@/store/gateway'
|
||||
import { dismissNotification, notify } from '@/store/notifications'
|
||||
import { $onboardingAnswers } from '@/store/onboarding-answers'
|
||||
import { beginOnboardingHandoff, completeOnboardingFlow } from '@/store/onboarding-gate'
|
||||
import { $activeGatewayProfile, $newChatProfile, $newChatRoute, ensureGatewayAgent } from '@/store/profile'
|
||||
import { $activeGatewayProfile, $newChatProfile, $newChatRoute, $profiles, ensureGatewayAgent } from '@/store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$selectedStoredSessionId,
|
||||
@@ -75,7 +74,7 @@ export function useOnboardingHandoff({
|
||||
!isOnboardingEnabled() ||
|
||||
$setupHandoff.get() ||
|
||||
!selectedStoredId ||
|
||||
$activeGatewayProfile.get() !== SETUP_PROFILE
|
||||
$profiles.get().find(p => p.name === $activeGatewayProfile.get())?.role !== 'setup'
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -104,7 +103,7 @@ export function useOnboardingHandoff({
|
||||
|
||||
$setupSession.set({
|
||||
connectionId,
|
||||
profile: SETUP_PROFILE,
|
||||
profile: $activeGatewayProfile.get(),
|
||||
runtimeId: $activeSessionId.get() ?? '',
|
||||
storedId: selectedStoredId
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { OnboardingEnsureSetupProfileResult } from '@hermes/shared'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import type { useSessionActions } from '@/app/session/hooks/use-session-actions'
|
||||
@@ -8,13 +9,7 @@ import {
|
||||
pickOnboardingGreeting,
|
||||
takeGuideShape
|
||||
} from '@/components/onboarding-chat/assembly'
|
||||
import {
|
||||
$setupSession,
|
||||
ensureSetupProfile,
|
||||
guideSourceConnectionId,
|
||||
SETUP_CHAT_TITLE,
|
||||
SETUP_PROFILE
|
||||
} from '@/components/onboarding-chat/setup-profile'
|
||||
import { $setupSession, guideSourceConnectionId, SETUP_CHAT_TITLE } from '@/components/onboarding-chat/setup-profile'
|
||||
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
|
||||
import { activeGatewayConnectionId, requestGatewayForProfile } from '@/store/gateway'
|
||||
import { loadMachineProfile } from '@/store/machine'
|
||||
@@ -54,6 +49,7 @@ interface GuideSession {
|
||||
}
|
||||
|
||||
async function adoptGuideSession(
|
||||
setupProfile: string,
|
||||
canonical: GuideSession,
|
||||
freeTier: SetupStatus['free_tier'],
|
||||
resumeSession: OnboardingKickoffOptions['resumeSession'],
|
||||
@@ -64,7 +60,7 @@ async function adoptGuideSession(
|
||||
$chatOnboardingThreadIds.set(adoptedRuntimeId ? [canonical.id, adoptedRuntimeId] : [canonical.id])
|
||||
$setupSession.set({
|
||||
connectionId: guideSourceConnectionId(canonical.id),
|
||||
profile: SETUP_PROFILE,
|
||||
profile: setupProfile,
|
||||
runtimeId: adoptedRuntimeId ?? canonical.id,
|
||||
storedId: canonical.id
|
||||
})
|
||||
@@ -78,7 +74,7 @@ async function adoptGuideSession(
|
||||
}
|
||||
}
|
||||
|
||||
/** Seeds the runbook and a pre-written greeting on hermes-setup before the phase advances.
|
||||
/** Seeds the runbook and a pre-written greeting on the setup profile before the phase advances.
|
||||
* The seeded assistant row shows the chat's first message without a model turn. */
|
||||
export function useOnboardingKickoff({
|
||||
createBackendSessionForSend,
|
||||
@@ -100,11 +96,14 @@ export function useOnboardingKickoff({
|
||||
let swapped = false
|
||||
|
||||
try {
|
||||
await ensureSetupProfile(requestGateway)
|
||||
const { name: setupProfile } = await requestGateway<OnboardingEnsureSetupProfileResult>(
|
||||
'onboarding.ensure_setup_profile',
|
||||
{}
|
||||
)
|
||||
|
||||
// Probe the guide's own socket before switching profiles so a refusal
|
||||
// leaves classic onboarding on the user's current backend.
|
||||
const record = await requestGatewayForProfile<SetupStatus>(SETUP_PROFILE, 'setup.status', {})
|
||||
const record = await requestGatewayForProfile<SetupStatus>(setupProfile, 'setup.status', {})
|
||||
|
||||
if (record.ready !== true || record.provider_configured !== true) {
|
||||
return false
|
||||
@@ -112,8 +111,8 @@ export function useOnboardingKickoff({
|
||||
|
||||
swapped = true
|
||||
$newChatRoute.set(null)
|
||||
$newChatProfile.set(SETUP_PROFILE)
|
||||
await ensureGatewayProfile(SETUP_PROFILE)
|
||||
$newChatProfile.set(setupProfile)
|
||||
await ensureGatewayProfile(setupProfile)
|
||||
|
||||
// Idempotent: the gate already took the shape on the tick the guide was
|
||||
// owed, so no full-size shell painted during the profile round trips.
|
||||
@@ -121,7 +120,7 @@ export function useOnboardingKickoff({
|
||||
await loadMachineProfile()
|
||||
|
||||
const guideRequest: AmbientGatewayRequest = (method, params, timeout) =>
|
||||
requestGatewayForProfile(SETUP_PROFILE, method, params, timeout)
|
||||
requestGatewayForProfile(setupProfile, method, params, timeout)
|
||||
|
||||
// Look the guide up by its exact title: a relaunch adopts the existing guide session before creating
|
||||
// one, so the backend's UNIQUE(title) constraint cannot leave an untitled duplicate behind.
|
||||
@@ -133,7 +132,7 @@ export function useOnboardingKickoff({
|
||||
const canonical = registryHit?.sessions?.[0]
|
||||
|
||||
if (canonical?.id) {
|
||||
await adoptGuideSession(canonical, record.free_tier, resumeSession, guideRequest)
|
||||
await adoptGuideSession(setupProfile, canonical, record.free_tier, resumeSession, guideRequest)
|
||||
|
||||
// runGuideKickoff records the guided phase only after adoption.
|
||||
return true
|
||||
@@ -141,7 +140,7 @@ export function useOnboardingKickoff({
|
||||
|
||||
const capabilities = await readOnboardingCapabilities({
|
||||
connectionId: previousConnectionId,
|
||||
profile: SETUP_PROFILE
|
||||
profile: setupProfile
|
||||
})
|
||||
|
||||
const seedMessages = buildChatOnboardingSeedMessages(
|
||||
@@ -156,7 +155,7 @@ export function useOnboardingKickoff({
|
||||
createOverrides.reasoningEffort = 'minimal'
|
||||
}
|
||||
|
||||
const runtimeId = await runCreatePinnedTo(SETUP_PROFILE, () =>
|
||||
const runtimeId = await runCreatePinnedTo(setupProfile, () =>
|
||||
createBackendSessionForSend(null, seedMessages, createOverrides)
|
||||
)
|
||||
|
||||
@@ -168,7 +167,7 @@ export function useOnboardingKickoff({
|
||||
$chatOnboardingThreadIds.set(storedId ? [storedId, runtimeId] : [runtimeId])
|
||||
$setupSession.set({
|
||||
connectionId: guideSourceConnectionId(storedId),
|
||||
profile: SETUP_PROFILE,
|
||||
profile: setupProfile,
|
||||
runtimeId,
|
||||
storedId
|
||||
})
|
||||
|
||||
@@ -374,7 +374,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
const { connectionRef, gateway, gatewayRef, requestGateway: ambientRequestGateway } = useGatewayRequest()
|
||||
|
||||
// The guide remains selected while handoff creates on another profile.
|
||||
// Without this pin, the owner ladder sends session.create to hermes-setup
|
||||
// Without this pin, the owner ladder sends session.create to the setup profile
|
||||
// despite the gateway switch (#89206). Scope it to the create leg so
|
||||
// concurrent session traffic keeps its recorded owner.
|
||||
const handoffCreateProfileRef = useRef<null | string>(null)
|
||||
|
||||
@@ -17,19 +17,20 @@ import { Chip } from '@/components/onboarding-chat/chip'
|
||||
import {
|
||||
$handoffError,
|
||||
$setupHandoff,
|
||||
$setupSession,
|
||||
firstTaskTitle,
|
||||
guideHandoffReceiptKey,
|
||||
parseHandoffPlan,
|
||||
readGuideHandoffReceipt,
|
||||
requestSetupHandoff,
|
||||
retrySetupHandoff,
|
||||
SETUP_PROFILE
|
||||
retrySetupHandoff
|
||||
} from '@/components/onboarding-chat/setup-profile'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { answeredAfter } from '@/lib/chat-messages/parts'
|
||||
import { segmentTranscriptDirectives } from '@/lib/transcript-directives'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $onboardingAnswers, markStepCommitted } from '@/store/onboarding-answers'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import { assertSessionOwnerResolved } from '@/store/session-owner-resolution'
|
||||
import { isSessionOwnerRoute } from '@/store/session-request-router'
|
||||
|
||||
@@ -152,7 +153,7 @@ export function HandoffCard({ attrs, locked }: CardProps) {
|
||||
storedId,
|
||||
runtimeId,
|
||||
connectionId: isSessionOwnerRoute(owner) ? owner.connectionId : null,
|
||||
profile: isSessionOwnerRoute(owner) ? owner.profile : owner || SETUP_PROFILE
|
||||
profile: isSessionOwnerRoute(owner) ? owner.profile : owner || $setupSession.get()?.profile || $activeGatewayProfile.get()
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -195,7 +196,7 @@ export function HandoffCard({ attrs, locked }: CardProps) {
|
||||
storedId,
|
||||
runtimeId,
|
||||
connectionId: isSessionOwnerRoute(owner) ? owner.connectionId : null,
|
||||
profile: isSessionOwnerRoute(owner) ? owner.profile : owner || SETUP_PROFILE
|
||||
profile: isSessionOwnerRoute(owner) ? owner.profile : owner || $setupSession.get()?.profile || $activeGatewayProfile.get()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const CHECK_IN_NOTE =
|
||||
|
||||
interface FirstBuild {
|
||||
/** Profile of the build session. The note must be routed to this profile explicitly: the user can return to
|
||||
* Setup's chat while the build runs, which makes hermes-setup the active gateway. */
|
||||
* Setup's chat while the build runs, which makes the setup profile the active gateway. */
|
||||
profile: string
|
||||
sessionId: string
|
||||
tools: number
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* The welcome chat that guided onboarding runs in, and the seed prompts for the first build session.
|
||||
*
|
||||
* The chat belongs to a persistent `hermes-setup` profile, so it survives onboarding and can be found again. `setup`
|
||||
* is the internal name throughout this module (the profile key, the atoms, the hidden `[setup]` notes); the user sees
|
||||
* only Hermes and the title `Welcome to Hermes`.
|
||||
* The chat belongs to the setup profile, which the backend creates and marks (`onboarding.ensure_setup_profile`), so it
|
||||
* survives onboarding and can be found again. `setup` is the internal name throughout this module (the atoms, the hidden `[setup]` notes); the user
|
||||
* sees only Hermes and the title `Welcome to Hermes`.
|
||||
*
|
||||
* This module holds the pure pieces: names, souls, seed prompts, and the handoff request atom. The side effects
|
||||
* (profiles.create, session.create, the chat switch) run in the wiring's kickoff and handoff effects, which hold the
|
||||
* (session.create, the chat switch) run in the wiring's kickoff and handoff effects, which hold the
|
||||
* gateway and session hooks.
|
||||
*/
|
||||
|
||||
@@ -15,7 +15,6 @@ import { atom } from 'nanostores'
|
||||
import type { ProfileScope } from '@/api/client'
|
||||
import type { HandoffReceipt } from '@/app/contrib/handoff-leg'
|
||||
import { handoffReceiptKey, readHandoffReceipt } from '@/app/contrib/handoff-receipt'
|
||||
import type { GatewayRequest } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||
import { CONNECTOR_LEAD_ORDER } from '@/components/onboarding-chat/options'
|
||||
import { connectorTitle } from '@/lib/connector-tools'
|
||||
import { activeGatewayConnectionId } from '@/store/gateway'
|
||||
@@ -25,9 +24,6 @@ import { readOnboardingCapabilities } from '@/store/onboarding-capabilities'
|
||||
import { FIRST_USE_GUIDANCE, PLAIN_SPEECH } from '@/store/onboarding-script'
|
||||
import { getSessionOwnerHint } from '@/store/session'
|
||||
|
||||
/** Profile name of the onboarding guide. Prefixed so it cannot collide with a profile the user named "setup". */
|
||||
export const SETUP_PROFILE = 'hermes-setup'
|
||||
|
||||
/** Title of the welcome chat, and the row the user sees in the sessions list. Kickoff re-finds the chat by exact
|
||||
* title after a relaunch, so this string is also a lookup key. */
|
||||
export const SETUP_CHAT_TITLE = 'Welcome to Hermes'
|
||||
@@ -125,23 +121,6 @@ export function firstTaskTitle(task: string): string {
|
||||
return trimmed.length > 28 ? `${trimmed.slice(0, 27).trimEnd()}…` : trimmed || 'First build'
|
||||
}
|
||||
|
||||
/** SOUL.md for the welcome profile. It applies to the welcome chat and to every later check-in. */
|
||||
export function composeSetupSoul(): string {
|
||||
return [
|
||||
'# Hermes',
|
||||
'',
|
||||
'You are Hermes, and this profile is where you met this user for the first time and stay reachable afterwards. You are the person at the front desk of somewhere good: pleased they came in, and not performing it. Quick, unhurried, never flustered, never in the way. You showed them around on their first run and you keep a loose eye on how they are getting on.',
|
||||
'',
|
||||
'- Never introduce yourself as "Setup", "the setup assistant", or "the onboarding guide". You are Hermes.',
|
||||
'- Warmth is in paying attention, not in adjectives. Remember what they told you and use it. Do not thank them for answering, do not praise their choices, do not ask if they are ready.',
|
||||
'- Offer an opinion lightly when you have one. "Most people wire that one up first" is worth more than a neutral menu.',
|
||||
'- You are training wheels: useful early, ignorable later. Never guilt-trip, never nag. If the user asks you to stop checking in, stop.',
|
||||
'- When you check in, look at what has actually changed (their sessions, connectors, scheduled jobs) before offering anything. One concrete suggestion beats a menu.',
|
||||
'- Things worth offering, roughly in order: wiring a connector they said they use, scheduling something they do repeatedly, a second build based on the first, keyboard/layout niceties.',
|
||||
'- Write like a person talking to another person. Short sentences, plain words, no headers, no bullet walls, no emoji.'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildFirstTaskRunbook(
|
||||
task: string,
|
||||
answers: OnboardingAnswers,
|
||||
@@ -283,22 +262,3 @@ export async function buildFirstTaskSeedMessages(
|
||||
export function buildHandoffCompleteNote(task: string): string {
|
||||
return `[setup] handoff complete — "${task.trim()}" is now building in its own session on the default profile, and the user is watching it there. The app is showing them a short tour of the profile rail and the sessions list right now, so do not describe either. Say ONE short line and then stop: you're around if they want a hand, and this chat stays where it is. Do not ask a question, do not offer a list, do not schedule anything.`
|
||||
}
|
||||
|
||||
/** Creates the guide profile. The catch treats an already-existing profile as success, so kickoff can call this on
|
||||
* every run. */
|
||||
export async function ensureSetupProfile(request: GatewayRequest): Promise<void> {
|
||||
try {
|
||||
await request('profiles.create', {
|
||||
description: 'Where Hermes met you — walks your first run, then checks in as you find your feet.',
|
||||
name: SETUP_PROFILE,
|
||||
clone_from: 'default',
|
||||
share_auth: true,
|
||||
no_alias: true,
|
||||
soul: composeSetupSoul()
|
||||
})
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && /exist/i.test(error.message))) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { atom } from 'nanostores'
|
||||
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
|
||||
import { readKey, writeKey } from '@/lib/storage'
|
||||
|
||||
import { $gateway } from './gateway'
|
||||
import { hasSeenIntroReveal, markIntroRevealSeen } from './intro-reveal'
|
||||
import { DEFAULT_ANSWERS, setOnboardingAnswers } from './onboarding-answers'
|
||||
|
||||
@@ -159,11 +160,13 @@ export function skipGuide(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function devResetOnboardingFlow(): void {
|
||||
/** Resets the backend's setup profile in place, then the local flow state. */
|
||||
export async function devResetOnboardingFlow(): Promise<void> {
|
||||
if (!import.meta.env.DEV) {
|
||||
return
|
||||
}
|
||||
|
||||
await $gateway.get()?.request('onboarding.reset_setup_profile', {})
|
||||
guideKickoff = { status: 'idle' }
|
||||
setPhase('idle')
|
||||
setOnboardingAnswers({ ...DEFAULT_ANSWERS, connectors: [...DEFAULT_ANSWERS.connectors] })
|
||||
|
||||
@@ -607,7 +607,7 @@ export async function ensureGatewayProfile(
|
||||
// renderer-side $activeGatewayProfile mirror is not proof of the socket:
|
||||
// applyActive can decline an epoch-losing publication while call sites
|
||||
// publish the atom anyway, leaving "atom says X, socket serves Y" (the
|
||||
// #89206 split-brain — observed live as atom 'default' over a hermes-setup
|
||||
// #89206 split-brain — observed live as atom 'default' over a setup-profile
|
||||
// socket during the guided-onboarding handoff). Verify the leg we're about
|
||||
// to rely on; on disagreement fall through to the full ensure path, which
|
||||
// re-activates the socket and leaves the atom and route agreeing. The one
|
||||
|
||||
@@ -1035,6 +1035,8 @@ export interface ProfileInfo {
|
||||
name: string
|
||||
path: string
|
||||
provider: null | string
|
||||
/** Backend-assigned role from profile.yaml; `setup` marks the onboarding guide's profile. */
|
||||
role?: 'setup' | null
|
||||
skill_count: number
|
||||
}
|
||||
|
||||
|
||||
@@ -1624,6 +1624,7 @@ export interface ProfileRow {
|
||||
display_name?: string
|
||||
skill_count?: number
|
||||
previous_names?: string[]
|
||||
role?: 'setup' | null
|
||||
last_session?: ProfileSessionPreview | null
|
||||
worker_session?: ProfileWorkerSession | null
|
||||
canonical_session?: ProfileCanonicalSession | null
|
||||
@@ -1808,6 +1809,20 @@ export interface ProfilesRememberOnboardingResult {
|
||||
profile?: string
|
||||
target?: string
|
||||
}
|
||||
/** Client→server method params / server→client request params. Unknown keys are rejected. */
|
||||
export type Params = Record<string, never>
|
||||
/** ``created`` is false when an existing setup profile was found (and returned untouched). */
|
||||
export interface OnboardingEnsureSetupProfileResult {
|
||||
name: string
|
||||
path: string
|
||||
created: boolean
|
||||
role?: 'setup'
|
||||
}
|
||||
export interface OnboardingResetSetupProfileResult {
|
||||
name: string
|
||||
path: string
|
||||
reset?: boolean
|
||||
}
|
||||
export interface VaultListResult {
|
||||
items?: VaultItem[]
|
||||
}
|
||||
@@ -4622,6 +4637,10 @@ export interface RpcMethods {
|
||||
'model.options': { params: ModelOptionsParams; result: ModelOptionsResult }
|
||||
/** Save an API key for a provider and return its refreshed inventory row. */
|
||||
'model.save_key': { params: ModelSaveKeyParams; result: ModelSaveKeyResult }
|
||||
/** Create-or-read the backend-owned setup profile; the backend picks the name and finds it by role. */
|
||||
'onboarding.ensure_setup_profile': { params: Params; result: OnboardingEnsureSetupProfileResult }
|
||||
/** Restore the setup profile to its created state in place (soul, memories, skills, sessions). */
|
||||
'onboarding.reset_setup_profile': { params: Params; result: OnboardingResetSetupProfileResult }
|
||||
/** Spill a large paste to a file and hand back the inline placeholder. */
|
||||
'paste.collapse': { params: PasteCollapseParams; result: PasteCollapseResult }
|
||||
/** Render a PDF's pages to PNG and queue them as images for the next turn. */
|
||||
@@ -4978,6 +4997,8 @@ export const RPC_METHODS = [
|
||||
'model.disconnect',
|
||||
'model.options',
|
||||
'model.save_key',
|
||||
'onboarding.ensure_setup_profile',
|
||||
'onboarding.reset_setup_profile',
|
||||
'paste.collapse',
|
||||
'pdf.attach',
|
||||
'pet.cancel',
|
||||
|
||||
@@ -1752,6 +1752,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "onboarding.ensure_setup_profile",
|
||||
"summary": "Create-or-read the backend-owned setup profile; the backend picks the name and finds it by role.",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Params"
|
||||
}
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OnboardingEnsureSetupProfileResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "onboarding.reset_setup_profile",
|
||||
"summary": "Restore the setup profile to its created state in place (soul, memories, skills, sessions).",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Params"
|
||||
}
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OnboardingResetSetupProfileResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "paste.collapse",
|
||||
"summary": "Spill a large paste to a file and hand back the inline placeholder.",
|
||||
@@ -17347,6 +17383,61 @@
|
||||
"title": "OnboardingAnswers",
|
||||
"type": "object"
|
||||
},
|
||||
"OnboardingEnsureSetupProfileResult": {
|
||||
"additionalProperties": false,
|
||||
"description": "``created`` is false when an existing setup profile was found (and returned untouched).",
|
||||
"properties": {
|
||||
"name": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"title": "Path",
|
||||
"type": "string"
|
||||
},
|
||||
"created": {
|
||||
"title": "Created",
|
||||
"type": "boolean"
|
||||
},
|
||||
"role": {
|
||||
"const": "setup",
|
||||
"default": "setup",
|
||||
"title": "Role",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"path",
|
||||
"created"
|
||||
],
|
||||
"title": "OnboardingEnsureSetupProfileResult",
|
||||
"type": "object"
|
||||
},
|
||||
"OnboardingResetSetupProfileResult": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"title": "Path",
|
||||
"type": "string"
|
||||
},
|
||||
"reset": {
|
||||
"default": true,
|
||||
"title": "Reset",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"path"
|
||||
],
|
||||
"title": "OnboardingResetSetupProfileResult",
|
||||
"type": "object"
|
||||
},
|
||||
"OpenRequestEntry": {
|
||||
"additionalProperties": false,
|
||||
"description": "One unanswered server\u2192client request (``server_requests.Request.snapshot``); the reconnecting\nclient re-delivers it to its request handlers.",
|
||||
@@ -17406,6 +17497,13 @@
|
||||
"title": "PaneRevealPayload",
|
||||
"type": "object"
|
||||
},
|
||||
"Params": {
|
||||
"additionalProperties": false,
|
||||
"description": "Client\u2192server method params / server\u2192client request params. Unknown keys are rejected.",
|
||||
"properties": {},
|
||||
"title": "Params",
|
||||
"type": "object"
|
||||
},
|
||||
"PasteCollapseParams": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -20947,6 +21045,19 @@
|
||||
"title": "Previous Names",
|
||||
"type": "array"
|
||||
},
|
||||
"role": {
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "setup",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Role"
|
||||
},
|
||||
"last_session": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -478,6 +478,10 @@ def _copy_dist_payload(staged: Path, target: Path, manifest: DistributionManifes
|
||||
|
||||
# Make sure the manifest on disk reflects resolved name + source
|
||||
write_manifest(target, manifest)
|
||||
# A shipped profile.yaml must not carry a backend-assigned role.
|
||||
if any(rel_parts == ("profile.yaml",) for _, rel_parts in entries):
|
||||
from hermes_cli.profiles import drop_profile_role
|
||||
drop_profile_role(target)
|
||||
|
||||
|
||||
def _bootstrap_user_dirs(target: Path) -> None:
|
||||
|
||||
@@ -71,6 +71,11 @@ _CLONE_ALL_HISTORY_EXCLUDE_ROOT: frozenset[str] = frozenset({
|
||||
# dashboard) skip bundled-skill seeding. Delete the file to opt back in.
|
||||
NO_BUNDLED_SKILLS_MARKER = ".no-bundled-skills"
|
||||
|
||||
# ``profile.yaml`` ``role`` values. A role grants backend capabilities (the setup toolset), so
|
||||
# only the backend writes one, and a copy of a profile (clone-all, import) never inherits it.
|
||||
SETUP_ROLE = "setup"
|
||||
PROFILE_ROLES = frozenset({SETUP_ROLE})
|
||||
|
||||
# Header seeded into a profile's empty .env so it owns a credentials file from day one.
|
||||
_PLACEHOLDER_ENV = (
|
||||
"# Per-profile secrets for this Hermes profile.\n"
|
||||
@@ -552,6 +557,8 @@ class ProfileInfo:
|
||||
# appends here). Lets Bot Mode group chats re-link persisted member
|
||||
# descriptors to the renamed live profile (#110200).
|
||||
previous_names: List[str] = field(default_factory=list)
|
||||
# Backend-assigned role (``SETUP_ROLE`` or None). Only ``hermes_cli.setup_profile`` writes it.
|
||||
role: Optional[str] = None
|
||||
|
||||
|
||||
def _load_yaml_dict(path: Path) -> Optional[dict]:
|
||||
@@ -797,6 +804,7 @@ def read_profile_meta(profile_dir: Path) -> dict:
|
||||
"display_name": str(data.get("display_name") or "").strip(),
|
||||
"bot_title": bot_title,
|
||||
"previous_names": _clean_previous_names(data.get("previous_names")),
|
||||
"role": data.get("role") if data.get("role") in PROFILE_ROLES else None,
|
||||
}
|
||||
|
||||
# A copy per caller (list included): the cached value is shared, and a caller that mutates
|
||||
@@ -824,13 +832,19 @@ def _clean_previous_names(raw) -> List[str]:
|
||||
def write_profile_meta(
|
||||
profile_dir: Path, *, description: Optional[str] = None, description_auto: Optional[bool] = None,
|
||||
display_name: Optional[str] = None, previous_names: Optional[List[str]] = None,
|
||||
role: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Update ``profile.yaml`` in place: only passed fields are overwritten; the file is
|
||||
created if missing. The profile directory itself must exist."""
|
||||
created if missing. The profile directory itself must exist. ``role`` grants backend
|
||||
capabilities, so no client-facing writer passes it through."""
|
||||
if not profile_dir.is_dir():
|
||||
raise FileNotFoundError(f"profile directory does not exist: {profile_dir}")
|
||||
if role is not None and role not in PROFILE_ROLES:
|
||||
raise ValueError(f"unknown profile role: {role!r}")
|
||||
path = profile_dir / "profile.yaml"
|
||||
existing: dict = _load_yaml_dict(path) or {}
|
||||
if role is not None:
|
||||
existing["role"] = role
|
||||
if description is not None:
|
||||
existing["description"] = description.strip()
|
||||
if description_auto is not None:
|
||||
@@ -856,6 +870,17 @@ def write_profile_meta(
|
||||
atomic_yaml_write(path, existing, sort_keys=False)
|
||||
|
||||
|
||||
def drop_profile_role(profile_dir: Path) -> None:
|
||||
"""Remove ``role`` from a copied ``profile.yaml``: a copy is an ordinary profile."""
|
||||
path = profile_dir / "profile.yaml"
|
||||
existing = _load_yaml_dict(path)
|
||||
if not existing or "role" not in existing:
|
||||
return
|
||||
existing.pop("role")
|
||||
from utils import atomic_yaml_write
|
||||
atomic_yaml_write(path, existing, sort_keys=False)
|
||||
|
||||
|
||||
def format_profile_label(name: str, display_name: Optional[str]) -> str:
|
||||
"""``display_name (canonical_id)``, or the bare id when no display name is set (or it
|
||||
equals the id) — byte-for-byte the pre-feature rendering."""
|
||||
@@ -1038,9 +1063,10 @@ def _copytree_keep_junctions(src: Path, dst: Path, ignore, dirs_exist_ok: bool =
|
||||
|
||||
|
||||
def _clone_all_into(source_dir: Path, profile_dir: Path, canon: str) -> None:
|
||||
"""--clone-all: full copytree minus infrastructure/history, then strip runtime files
|
||||
and cloned single-use OAuth grants."""
|
||||
"""--clone-all: full copytree minus infrastructure/history, then strip runtime files,
|
||||
the backend-assigned role, and cloned single-use OAuth grants."""
|
||||
_copytree_keep_junctions(source_dir, profile_dir, _clone_all_copytree_ignore(source_dir))
|
||||
drop_profile_role(profile_dir)
|
||||
materialized = _materialize_symlinked_files(profile_dir)
|
||||
if materialized:
|
||||
logger.info("profile %s: materialized symlinked %s so the clone never writes through to %s",
|
||||
@@ -1982,6 +2008,7 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
|
||||
if archive_root != canon:
|
||||
final_source = staging_root / canon
|
||||
extracted.rename(final_source)
|
||||
drop_profile_role(final_source)
|
||||
shutil.move(str(final_source), str(profile_dir))
|
||||
return profile_dir
|
||||
|
||||
|
||||
114
hermes_cli/setup_profile.py
Normal file
114
hermes_cli/setup_profile.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""The setup profile: where guided onboarding runs and the guide keeps checking in afterwards.
|
||||
|
||||
The backend owns it. One per home, found by ``role: setup`` in ``profile.yaml`` (the name is an
|
||||
implementation detail). ``ensure_setup_profile`` creates it once and afterwards returns it as-is;
|
||||
``reset_setup_profile`` restores the created state in place, keeping the directory, name and role.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple, Optional
|
||||
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SETUP_PROFILE_NAME = "hermes-setup"
|
||||
SETUP_PROFILE_DESCRIPTION = "Where Hermes met you — walks your first run, then checks in as you find your feet."
|
||||
|
||||
SETUP_SOUL = "\n".join([
|
||||
"# Hermes",
|
||||
"",
|
||||
"You are Hermes, and this profile is where you met this user for the first time and stay reachable afterwards. "
|
||||
"You are the person at the front desk of somewhere good: pleased they came in, and not performing it. Quick, "
|
||||
"unhurried, never flustered, never in the way. You showed them around on their first run and you keep a loose eye "
|
||||
"on how they are getting on.",
|
||||
"",
|
||||
'- Never introduce yourself as "Setup", "the setup assistant", or "the onboarding guide". You are Hermes.',
|
||||
"- Warmth is in paying attention, not in adjectives. Remember what they told you and use it. Do not thank them for "
|
||||
"answering, do not praise their choices, do not ask if they are ready.",
|
||||
'- Offer an opinion lightly when you have one. "Most people wire that one up first" is worth more than a neutral '
|
||||
"menu.",
|
||||
"- You are training wheels: useful early, ignorable later. Never guilt-trip, never nag. If the user asks you to "
|
||||
"stop checking in, stop.",
|
||||
"- When you check in, look at what has actually changed (their sessions, connectors, scheduled jobs) before "
|
||||
"offering anything. One concrete suggestion beats a menu.",
|
||||
"- Things worth offering, roughly in order: wiring a connector they said they use, scheduling something they do "
|
||||
"repeatedly, a second build based on the first, keyboard/layout niceties.",
|
||||
"- Write like a person talking to another person. Short sentences, plain words, no headers, no bullet walls, no "
|
||||
"emoji.",
|
||||
])
|
||||
|
||||
|
||||
class SetupProfile(NamedTuple):
|
||||
name: str
|
||||
path: Path
|
||||
created: bool
|
||||
|
||||
|
||||
def find_setup_profile() -> Optional[tuple[str, Path]]:
|
||||
"""``(name, path)`` of the profile carrying ``role: setup``, first by name; None when absent."""
|
||||
found = [(p.name, Path(p.path)) for p in profiles_mod.list_profiles(lazy_skill_count=True)
|
||||
if p.role == profiles_mod.SETUP_ROLE]
|
||||
if len(found) > 1:
|
||||
logger.warning("several profiles carry role: setup (%s); using %s",
|
||||
", ".join(name for name, _ in found), found[0][0])
|
||||
return found[0] if found else None
|
||||
|
||||
|
||||
def ensure_setup_profile() -> SetupProfile:
|
||||
"""Create-or-read. A found profile is returned untouched (soul, memories, skills, config).
|
||||
|
||||
A ``hermes-setup`` profile from before the role existed is adopted: it gets the role and
|
||||
nothing else, so existing installs keep their guide chat."""
|
||||
found = find_setup_profile()
|
||||
if found is not None:
|
||||
return SetupProfile(found[0], found[1], created=False)
|
||||
if profiles_mod.profile_exists(SETUP_PROFILE_NAME):
|
||||
path = profiles_mod.get_profile_dir(SETUP_PROFILE_NAME)
|
||||
profiles_mod.write_profile_meta(path, role=profiles_mod.SETUP_ROLE)
|
||||
return SetupProfile(SETUP_PROFILE_NAME, path, created=False)
|
||||
path = profiles_mod.create_profile(SETUP_PROFILE_NAME, clone_from="default", clone_config=True, no_alias=True,
|
||||
description=SETUP_PROFILE_DESCRIPTION)
|
||||
_write_soul(path)
|
||||
profiles_mod.write_profile_meta(path, role=profiles_mod.SETUP_ROLE)
|
||||
return SetupProfile(SETUP_PROFILE_NAME, path, created=True)
|
||||
|
||||
|
||||
def reset_setup_profile() -> SetupProfile:
|
||||
"""Restore the created state in place: soul from the template, memories and skills re-copied
|
||||
from ``default`` as create copies them. Session history is cleared by the caller, which owns
|
||||
the live sessions and the session store. Raises LookupError when no setup profile exists."""
|
||||
found = find_setup_profile()
|
||||
if found is None:
|
||||
raise LookupError("no setup profile to reset")
|
||||
name, path = found
|
||||
source = profiles_mod.get_profile_dir("default")
|
||||
_write_soul(path)
|
||||
_replace_dir(path / "memories")
|
||||
for relpath in profiles_mod._CLONE_SUBDIR_FILES:
|
||||
profiles_mod._clone_file(source, path, relpath)
|
||||
_replace_dir(path / "skills")
|
||||
if (source / "skills").is_dir():
|
||||
profiles_mod._copytree_keep_junctions(source / "skills", path / "skills",
|
||||
profiles_mod._non_exportable_entries, dirs_exist_ok=True)
|
||||
return SetupProfile(name, path, created=False)
|
||||
|
||||
|
||||
def _write_soul(path: Path) -> None:
|
||||
# Bytes, so Windows text mode cannot turn the template's \n into \r\n.
|
||||
from utils import atomic_write_bytes
|
||||
atomic_write_bytes(path / "SOUL.md", SETUP_SOUL.encode("utf-8"))
|
||||
|
||||
|
||||
def _replace_dir(directory: Path) -> None:
|
||||
"""Empty *directory*. A link (symlink or NTFS junction) is removed, never followed: its
|
||||
target belongs to another profile or an external skills root."""
|
||||
if directory.is_symlink() or profiles_mod._junction_target(str(directory)) is not None:
|
||||
directory.unlink() if directory.is_symlink() else directory.rmdir()
|
||||
elif directory.exists():
|
||||
shutil.rmtree(directory)
|
||||
directory.mkdir(parents=True)
|
||||
@@ -92,7 +92,7 @@ def _profile_to_dict(info) -> Dict[str, Any]:
|
||||
"distribution_name": attr("distribution_name", None),
|
||||
"distribution_version": attr("distribution_version", None),
|
||||
"distribution_source": attr("distribution_source", None),
|
||||
"has_alias": attr("alias_path", None) is not None}
|
||||
"has_alias": attr("alias_path", None) is not None, "role": attr("role", None)}
|
||||
|
||||
|
||||
def _profile_setup_command(name: str) -> str:
|
||||
|
||||
@@ -156,6 +156,7 @@ class ProfileRow(Result):
|
||||
display_name: str = ""
|
||||
skill_count: int = 0
|
||||
previous_names: list[str] = Field(default_factory=list)
|
||||
role: Literal["setup"] | None = None
|
||||
last_session: ProfileSessionPreview | None = None
|
||||
worker_session: ProfileWorkerSession | None = None
|
||||
canonical_session: ProfileCanonicalSession | None = None
|
||||
@@ -375,6 +376,32 @@ method("profiles.remember_onboarding", params=ProfilesRememberOnboardingParams,
|
||||
doc="Write the onboarding facts into the default profile's user memory and confirm they landed.")
|
||||
|
||||
|
||||
# ── onboarding (methods_onboarding) ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class OnboardingEnsureSetupProfileResult(Result):
|
||||
"""``created`` is false when an existing setup profile was found (and returned untouched)."""
|
||||
|
||||
name: str
|
||||
path: str
|
||||
created: bool
|
||||
role: Literal["setup"] = "setup"
|
||||
|
||||
|
||||
method("onboarding.ensure_setup_profile", params=Params, result=OnboardingEnsureSetupProfileResult,
|
||||
doc="Create-or-read the backend-owned setup profile; the backend picks the name and finds it by role.")
|
||||
|
||||
|
||||
class OnboardingResetSetupProfileResult(Result):
|
||||
name: str
|
||||
path: str
|
||||
reset: bool = True
|
||||
|
||||
|
||||
method("onboarding.reset_setup_profile", params=Params, result=OnboardingResetSetupProfileResult,
|
||||
doc="Restore the setup profile to its created state in place (soul, memories, skills, sessions).")
|
||||
|
||||
|
||||
# ── vault (methods_vault) ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -288,7 +288,13 @@ def _(rid, params: dict) -> dict:
|
||||
def probe(profile, scoped):
|
||||
record = None if profile else wait_for_record()
|
||||
if record is None:
|
||||
# ``ready`` = this process's boot bootstrap has settled (a named profile has no
|
||||
# record of its own; the launch record says whether the free tier is minted).
|
||||
# Since one host backend serves every profile (#118246), the desktop's
|
||||
# setup-profile probe lands here, and its kickoff requires ``ready``.
|
||||
launch = wait_for_record() if profile else None
|
||||
return {"provider_configured": bool(_has_any_provider_configured(strict_profile_scope=bool(profile))),
|
||||
**({"ready": True, "free_tier": launch.free_tier} if launch is not None else {}),
|
||||
**scoped}
|
||||
# ``failure_fields`` rides along only when the free-tier mint did not happen: the code,
|
||||
# the sentence, and whether / when a retry can succeed (``free_tier.provision``).
|
||||
|
||||
59
tui_gateway/methods_onboarding.py
Normal file
59
tui_gateway/methods_onboarding.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Onboarding JSON-RPC handlers: the backend owns the setup profile (``hermes_cli.setup_profile``).
|
||||
Bodies are rebound onto server.py's globals (method_ctx.bind_module) and reference them bare.
|
||||
"""
|
||||
|
||||
from .method_ctx import HandlerRegistry, bind_module
|
||||
|
||||
_registry = HandlerRegistry()
|
||||
method = _registry.method
|
||||
|
||||
|
||||
@method("onboarding.ensure_setup_profile")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Create-or-read the setup profile. Takes no name: the backend picks it and finds it by role."""
|
||||
from hermes_cli.setup_profile import ensure_setup_profile
|
||||
try:
|
||||
setup = ensure_setup_profile()
|
||||
if setup.created:
|
||||
# Same credential mirroring profiles.create gives the desktop's clones; auth.json stays
|
||||
# shared with the root so a token refresh never forks.
|
||||
_mirror_launch_credentials(setup.path, {"share_auth": True})
|
||||
except Exception as e:
|
||||
return _err(rid, 5073, str(e))
|
||||
return _ok(rid, {"name": setup.name, "path": str(setup.path), "created": setup.created, "role": "setup"})
|
||||
|
||||
|
||||
@method("onboarding.reset_setup_profile")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Restore the setup profile to its created state in place; clears its session history."""
|
||||
from hermes_cli.setup_profile import find_setup_profile, reset_setup_profile
|
||||
found = find_setup_profile()
|
||||
if found is None:
|
||||
return _err(rid, 4072, "no setup profile to reset")
|
||||
_clear_setup_sessions(found[1])
|
||||
try:
|
||||
setup = reset_setup_profile()
|
||||
except Exception as e:
|
||||
return _err(rid, 5074, str(e))
|
||||
return _ok(rid, {"name": setup.name, "path": str(setup.path), "reset": True})
|
||||
|
||||
|
||||
def _clear_setup_sessions(profile_dir) -> None:
|
||||
"""Close this process's live sessions in the setup profile, then delete its stored sessions."""
|
||||
target = Path(profile_dir).resolve()
|
||||
with _sessions_lock:
|
||||
live = [sid for sid, sess in _sessions.items()
|
||||
if Path(sess.get("profile_home") or _hermes_home).resolve() == target]
|
||||
for sid in live:
|
||||
_close_session_by_id(sid, end_reason="setup_reset")
|
||||
from hermes_state_registry import acquire, release_or_close
|
||||
db = acquire(target / "state.db")
|
||||
try:
|
||||
ids = [row[0] for row in db._read_all("SELECT id FROM sessions")]
|
||||
db.delete_sessions(ids, sessions_dir=target / "sessions")
|
||||
finally:
|
||||
release_or_close(db)
|
||||
|
||||
|
||||
def register(server) -> None:
|
||||
bind_module(globals(), server, skip=("_",))
|
||||
@@ -275,7 +275,7 @@ def _(rid, params: dict) -> dict:
|
||||
row = {"name": p.name, "path": str(p.path), "is_default": bool(p.is_default), "model": p.model,
|
||||
"provider": p.provider, "description": p.description or "",
|
||||
"display_name": p.display_name or "", "skill_count": p.skill_count or 0,
|
||||
"previous_names": list(p.previous_names or [])}
|
||||
"previous_names": list(p.previous_names or []), "role": p.role}
|
||||
if include_sessions:
|
||||
_profile_session_fields(row, p.path)
|
||||
_profile_ui_meta_fields(row, Path(str(p.path)))
|
||||
|
||||
@@ -3329,7 +3329,8 @@ from . import ( # noqa: E402
|
||||
methods_projects as _methods_projects, methods_session_foreign as _methods_session_foreign,
|
||||
methods_session_control as _methods_session_control, methods_subagents as _methods_subagents,
|
||||
methods_vault as _methods_vault, methods_free_tier as _methods_free_tier,
|
||||
methods_connectors as _methods_connectors, methods_connectors_account as _methods_connectors_account)
|
||||
methods_connectors as _methods_connectors, methods_connectors_account as _methods_connectors_account,
|
||||
methods_onboarding as _methods_onboarding)
|
||||
|
||||
for _m in (
|
||||
_session_transports, _session_reaper, _session_lifecycle, _session_workdir, _compute_host_bridge, _model_switch,
|
||||
@@ -3340,6 +3341,6 @@ for _m in (
|
||||
_methods_config_set, _methods_complete, _methods_tools, _methods_profiles, _methods_images,
|
||||
_methods_bot_relay, _prompt_turn, _billing_view, _methods_projects, _methods_session_foreign,
|
||||
_methods_session_control, _methods_subagents, _methods_vault, _methods_free_tier, _methods_connectors,
|
||||
_methods_connectors_account):
|
||||
_methods_connectors_account, _methods_onboarding):
|
||||
_m.register(sys.modules[__name__])
|
||||
del _m
|
||||
|
||||
Reference in New Issue
Block a user