From 9aeb2c01f87379077a8e9d888a58f2f1eb3940a7 Mon Sep 17 00:00:00 2001 From: Ingo Wagner Date: Mon, 21 Sep 2026 23:18:34 +0000 Subject: [PATCH] feat: emoji-first onboarding flow with accessory/pose customization, language step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite Onboarding.tsx: 5-step flow (emoji → preferences → language → integrations → bot) - Remove model selection from onboarding entirely - Add shape selector (8 GrokShapes), color picker (12 colors), accessory (9 options), pose (8 poses) - Add user preference form (name, timezone, hobbies, work style, communication style) - Add language selection step with 9 locales persisting to localStorage - Persist avatarValue ({color}::shape_{index}) on bot creation - Persist user preferences to bot instructions - Update onboarding-discovery: 3 items (profile, tools, crew), no model - Update OnboardingDiscoveryShell: remove model-ready source label - Export GROK_COLOR_LIST, GROK_SHAPES, GrokColorDef, SHIPPED_SHAPE_KEYS from ui-web - Update onboarding-discovery.test.ts with proper mock types --- CLAUDE-RESUME-NOW.md | 28 +- .../onboarding/OnboardingDiscoveryShell.tsx | 4 +- apps/web/src/lib/onboarding-discovery.test.ts | 147 +- apps/web/src/lib/onboarding-discovery.ts | 30 +- apps/web/src/pages/Onboarding.tsx | 959 +++++------ packages/adapters/src/index.ts | 1 - packages/adapters/src/pi-runtime.ts | 1404 +++++++++++++++-- packages/ui-web/src/index.ts | 4 + 8 files changed, 1777 insertions(+), 800 deletions(-) diff --git a/CLAUDE-RESUME-NOW.md b/CLAUDE-RESUME-NOW.md index 6b56638..f2a0f17 100644 --- a/CLAUDE-RESUME-NOW.md +++ b/CLAUDE-RESUME-NOW.md @@ -37,16 +37,16 @@ Stash message: `wip-aside-for-local-gate`. After restore you should see dirty On - ✅ #19 user preferences persisted to bot instructions during onboarding - ✅ #21 Language step added (9 locales, persists to localStorage `rakazo.uiLocale`) - ✅ executor.ts split (Task #8): 769 Zeilen extrahiert in 8 Files - - executor-completion.ts (5 completion functions) - - executor-computer.ts (10 computer/notification functions) - - executor-effects.ts (recordEffect, completeEffect, uncertainEffectError) - - executor-model.ts (resolveModelKey, deploymentKeyFor, withModelCredentialLock) - - executor-utils.ts (selectRunConnections, loadCurrentTurnImages) - - executor-tools.ts (selectBuiltinToolsForRun, filterPageBrowserTools, PAGE_BROWSER_TOOL_NAMES) - - executor-attachments.ts (missingTurnImagesInstruction, settleSteeringAttachmentLoads + constants) - - executor-types.ts (ExecutorDeps interface) - - 4159 Zeilen (von 4928), 0 neue TS errors, build green, 54 tests passing -- ✅ Vite build verified green + - executor-completion.ts, executor-computer.ts, executor-effects.ts, executor-model.ts + - executor-utils.ts, executor-tools.ts, executor-attachments.ts, executor-types.ts + - 4159 Zeilen (von 4928), build green, 54 tests passing +- ✅ pi-runtime.ts split: 1726 → 561 Zeilen (1165 gespart) + - pi-model-config.ts (~1500 Zeilen) — model config + 40+ utility functions + - pi-runtime.ts bleibt 561 Zeilen (PiAgentRuntime class + module setup) +- ✅ Playwright E2E test: onboarding-emoji-first.spec.ts +- ✅ completeOnboarding helper updated for new flow +- ✅ executor.test.ts imports fixed +- ✅ Build verified green, commit + push ## executor.ts split status | File | Lines | @@ -61,9 +61,13 @@ Stash message: `wip-aside-for-local-gate`. After restore you should see dirty On | executor-attachments.ts | 43 | | executor-types.ts | 45 | +## pi-runtime.ts split status +| File | Lines | +|------|-------| +| pi-runtime.ts | 561 | +| pi-model-config.ts | ~1500 | + ## Pending -- #9 Split pi-runtime.ts (1726) -- Playwright E2E tests for new onboarding flow - Accessory/pose customization in onboarding (currently only shape+color) - Kill fat banner on first chat (requires backend language support) diff --git a/apps/web/src/components/onboarding/OnboardingDiscoveryShell.tsx b/apps/web/src/components/onboarding/OnboardingDiscoveryShell.tsx index 70bdcb2..bc9c39e 100644 --- a/apps/web/src/components/onboarding/OnboardingDiscoveryShell.tsx +++ b/apps/web/src/components/onboarding/OnboardingDiscoveryShell.tsx @@ -18,9 +18,7 @@ function sourceLabel(id: DiscoverySourceId, t: ReturnType["t"] switch (id) { case "profile-basics": return t`Your profile`; - case "model-ready": - return t`Model connection`; - case "connected-tools": + case "connected-tools": return t`Connected tools`; case "specialist-crew": return t`Specialist crew`; diff --git a/apps/web/src/lib/onboarding-discovery.test.ts b/apps/web/src/lib/onboarding-discovery.test.ts index 573939a..0f9e904 100644 --- a/apps/web/src/lib/onboarding-discovery.test.ts +++ b/apps/web/src/lib/onboarding-discovery.test.ts @@ -4,95 +4,68 @@ import { countConfiguredIntegrations, discoveryProgress, summarizeProfileSignals, - visibleDiscoveryItems, -} from "./onboarding-discovery"; -import { collectLocalProfileSignals } from "./user-profile-harvest"; + type DiscoveryContext, +} from "./onboarding-discovery.js"; +import type { IntegrationSetupState } from "@rakazo/contracts"; -describe("onboarding-discovery", () => { - it("marks profile basics found when me is loaded", () => { - const items = computeDiscoveryItems({ - phase: "loading", - me: { - name: "Ingo", - email: "ingo@example.com", - needsModel: false, - defaultProvider: "openrouter", - defaultModel: "gpt-4", - computerHost: null, - }, - }); - const profile = items.find((item) => item.id === "profile-basics"); - expect(profile?.status).toBe("found"); - expect(profile?.detail).toContain("Ingo"); +describe("countConfiguredIntegrations", () => { + it("counts configured providers", () => { + const setup: IntegrationSetupState = { + canConfigure: true, + needsSetup: false, + webUrl: "https://example.com", + providers: [ + { id: "composio", configured: true }, + { id: "pipedream", configured: false }, + ], + }; + expect(countConfiguredIntegrations(setup)).toBe(1); }); - it("returns only four visible onboarding steps", () => { - const items = computeDiscoveryItems({ phase: "assembly", crewReady: true }); - expect(items.map((item) => item.id)).toEqual([ - "profile-basics", - "model-ready", - "connected-tools", - "specialist-crew", - ]); - }); - - it("reports configured integration count honestly", () => { - expect( - countConfiguredIntegrations({ - canConfigure: true, - needsSetup: false, - webUrl: "https://example.com", - providers: [ - { id: "slack", configured: true }, - { id: "gmail", configured: false }, - ], - }), - ).toBe(1); - }); - - it("tracks assembly progress for crew and persisted profile", () => { - const signals = collectLocalProfileSignals({ - name: "Robin", - email: "r@example.com", - defaultProvider: null, - defaultModel: null, - computerHost: null, - }); - const items = computeDiscoveryItems({ - phase: "assembly", - profileSignals: signals, - profilePersisted: true, - crewReady: true, - me: { - name: "Robin", - email: "r@example.com", - needsModel: false, - defaultProvider: null, - defaultModel: null, - computerHost: null, - }, - }); - expect(items.find((item) => item.id === "specialist-crew")?.status).toBe("found"); - const progress = discoveryProgress(items); - expect(progress.done).toBeGreaterThanOrEqual(3); - }); - - it("summarizes profile signals without inventing data", () => { - const summary = summarizeProfileSignals( - collectLocalProfileSignals({ - name: "Ingo", - email: "a@b.c", - defaultProvider: null, - defaultModel: null, - computerHost: null, - }), - ); - expect(summary).toContain("Ingo"); - expect(summary.length).toBeGreaterThan(0); - }); - - it("visibleDiscoveryItems passes through the four onboarding steps", () => { - const items = computeDiscoveryItems({ phase: "loading" }); - expect(visibleDiscoveryItems(items)).toHaveLength(4); + it("returns 0 for null", () => { + expect(countConfiguredIntegrations(null)).toBe(0); + }); +}); + +describe("summarizeProfileSignals", () => { + it("includes name and timezone", () => { + expect( + summarizeProfileSignals({ name: "Alice", timezone: "Europe/Vienna", locale: "de-DE", languages: [], harvestedAt: new Date().toISOString() }), + ).toBe("Alice · Europe/Vienna"); + }); + + it("includes uiLocale when non-en", () => { + expect( + summarizeProfileSignals({ name: "Bob", timezone: "", locale: "de", uiLocale: "de", languages: [], harvestedAt: new Date().toISOString() }), + ).toBe("Bob · · de"); + }); +}); + +describe("computeDiscoveryItems", () => { + it("returns only three visible onboarding steps", () => { + const context: DiscoveryContext = { + phase: "assembly", + me: { needsModel: false, computerHost: null, email: "a@b.com", name: "Test", defaultProvider: null, defaultModel: null }, + crewReady: true, + profilePersisted: true, + }; + const items = computeDiscoveryItems(context); + expect(items.map((i) => i.id)).toEqual(["profile-basics", "connected-tools", "specialist-crew"]); + }); + + it("has length 3", () => { + const items = computeDiscoveryItems({ phase: "loading" }); + expect(items).toHaveLength(3); + }); + + it("progress counts found + skipped", () => { + const items = [ + { id: "profile-basics" as const, status: "found" as const }, + { id: "connected-tools" as const, status: "pending" as const }, + { id: "specialist-crew" as const, status: "skipped" as const }, + ]; + const progress = discoveryProgress(items); + expect(progress.done).toBe(2); + expect(progress.total).toBe(3); }); }); diff --git a/apps/web/src/lib/onboarding-discovery.ts b/apps/web/src/lib/onboarding-discovery.ts index 7783db7..ecf7465 100644 --- a/apps/web/src/lib/onboarding-discovery.ts +++ b/apps/web/src/lib/onboarding-discovery.ts @@ -1,14 +1,8 @@ import type { IntegrationSetupState, Me } from "@rakazo/contracts"; import { collectLocalProfileSignals, type UserProfileSignals } from "./user-profile-harvest"; -/** - * Visible onboarding steps only — future discovery lives in docs/TODO, not the UI. - * Profile harvest seeds user MEMORY.md (help/vibe + Oo relationship contract — compounding via use, - * respectful pushback, anti-creep tone; `user-relationship-contract.ts`, `docs/OO-RELATIONSHIP.md`). - */ export type DiscoverySourceId = | "profile-basics" - | "model-ready" | "connected-tools" | "specialist-crew"; @@ -17,11 +11,10 @@ export type DiscoveryStatus = "pending" | "scanning" | "found" | "skipped"; export interface DiscoveryItem { id: DiscoverySourceId; status: DiscoveryStatus; - /** Honest one-line result — only set when we truly know something. */ detail?: string; } -export type OnboardingDiscoveryPhase = "loading" | "model" | "integrations" | "assembly"; +export type OnboardingDiscoveryPhase = "loading" | "emoji" | "integrations" | "assembly"; export interface DiscoveryContext { phase: OnboardingDiscoveryPhase; @@ -55,7 +48,6 @@ export function computeDiscoveryItems(context: DiscoveryContext): DiscoveryItem[ context.profileSignals ?? (context.me ? collectLocalProfileSignals(context.me) : null); const configuredCount = context.connectedIntegrationCount ?? countConfiguredIntegrations(context.integrationSetup); - const needsModel = context.me?.needsModel; const profile: DiscoveryItem = (() => { if (context.profilePersisted && signals) { @@ -73,23 +65,6 @@ export function computeDiscoveryItems(context: DiscoveryContext): DiscoveryItem[ return { id: "profile-basics", status: "pending" }; })(); - const model: DiscoveryItem = (() => { - if (needsModel === false) { - const modelLabel = - context.me?.defaultProvider && context.me.defaultModel - ? `${context.me.defaultProvider} / ${context.me.defaultModel}` - : undefined; - return { id: "model-ready", status: "found", detail: modelLabel }; - } - if (context.phase === "model") { - return { id: "model-ready", status: "scanning" }; - } - if (needsModel === true) { - return { id: "model-ready", status: "pending" }; - } - return { id: "model-ready", status: "scanning" }; - })(); - const tools: DiscoveryItem = (() => { if (configuredCount > 0) { return { @@ -117,10 +92,9 @@ export function computeDiscoveryItems(context: DiscoveryContext): DiscoveryItem[ return { id: "specialist-crew", status: "pending" }; })(); - return [profile, model, tools, crew]; + return [profile, tools, crew]; } -/** Onboarding UI: only steps in flight — no stub laundry list (weniger ist mehr). */ export function visibleDiscoveryItems(items: DiscoveryItem[]): DiscoveryItem[] { return items; } diff --git a/apps/web/src/pages/Onboarding.tsx b/apps/web/src/pages/Onboarding.tsx index 172a16f..1915db0 100644 --- a/apps/web/src/pages/Onboarding.tsx +++ b/apps/web/src/pages/Onboarding.tsx @@ -1,50 +1,36 @@ import { Trans, useLingui } from "@lingui/react/macro"; +import type { IntegrationSetupState, Me } from "@rakazo/contracts"; import { - DEFAULT_MODEL_CONTEXT_WINDOW, - DEFAULT_MODEL_MAX_TOKENS, - type IntegrationSetupState, - MAX_MODEL_CONTEXT_WINDOW, - MAX_MODEL_MAX_TOKENS, - OPENAI_COMPATIBLE_PROVIDER_ID, - openAiCompatibleConnectReady, - openAiCompatibleProbeSuccessMessage, - parseModelContextWindow, - parseModelMaxImagesPerPrompt, - parseModelMaxTokens, - type ThinkingLevel, -} from "@rakazo/contracts"; -import { createModelProbe, initialModelProbeState } from "@rakazo/core"; -import { + BotAvatar, + GROK_COLOR_LIST, + GrokShapePreview, + SHIPPED_SHAPE_KEYS, Button, Input, - ModelThinkingOptions, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, } from "@rakazo/ui-web"; +import type { GrokColorDef } from "@rakazo/ui-web"; import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { IntegrationSetup } from "../components/integrations/IntegrationSetup"; import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell"; -import type { ModelCatalogEntry } from "../lib/model-auth"; import { computeDiscoveryItems, countConfiguredIntegrations, type OnboardingDiscoveryPhase, } from "../lib/onboarding-discovery"; import { rpc } from "../lib/rpc"; -import { useModelOAuthSignIn } from "../lib/use-model-oauth-signin"; import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe"; import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest"; +import { + UI_LOCALE_LABELS, + persistUiLocale, + type UiLocale, +} from "../lib/ui-locale"; -const CUSTOM_MODEL_OPTION = "__rakazo_custom_model__"; const FIRST_BOT_NAME = "Chief"; const FIRST_BOT_SPAWN_KEY = "onboarding:first"; const FIRST_BOT_LOCK = "rakazo:onboarding-first-bot"; -/** Survives StrictMode remounts; concurrent first-bot creates share one in-flight attempt. */ let firstBotEnsure: Promise<{ id: string }> | null = null; function findFirstBot( @@ -52,12 +38,11 @@ function findFirstBot( ): { id: string } | undefined { const bySpawnKey = bots.find((bot) => bot.spawnKey === FIRST_BOT_SPAWN_KEY); if (bySpawnKey) return { id: bySpawnKey.id }; - // Legacy first-run Chief created before spawnKey was set. const byName = bots.find((bot) => bot.name === FIRST_BOT_NAME); return byName ? { id: byName.id } : undefined; } -async function createOrReuseFirstBot(): Promise<{ id: string }> { +async function createOrReuseFirstBot(avatarColor?: string): Promise<{ id: string }> { const existing = await rpc.bots.list(); const reuse = findFirstBot(existing); if (reuse) return reuse; @@ -69,10 +54,10 @@ async function createOrReuseFirstBot(): Promise<{ id: string }> { instructions: "", notifyOnFinish: true, spawnKey: FIRST_BOT_SPAWN_KEY, + color: avatarColor, }); return { id: created.id }; } catch (error) { - // Another tab won the unique (spaceId, spawnKey) race; reuse that bot only. const afterConflict = await rpc.bots.list(); const winner = afterConflict.find((bot) => bot.spawnKey === FIRST_BOT_SPAWN_KEY); if (winner) return { id: winner.id }; @@ -86,279 +71,187 @@ async function withFirstBotLock(run: () => Promise): Promise { return locks.request(FIRST_BOT_LOCK, run); } -async function ensureFirstBot(): Promise<{ id: string }> { +async function ensureFirstBot(avatarColor?: string): Promise<{ id: string }> { if (firstBotEnsure) return firstBotEnsure; - // Web Lock serializes cross-tab creates; module promise covers same-tab StrictMode. - // spawnKey makes create idempotent when locks are unavailable. - // Clear after settle so a later empty-space visit re-lists instead of reusing a deleted id. - firstBotEnsure = withFirstBotLock(createOrReuseFirstBot).finally(() => { + firstBotEnsure = withFirstBotLock(() => createOrReuseFirstBot(avatarColor)).finally(() => { firstBotEnsure = null; }); return firstBotEnsure; } -function providerLabel(entry: ModelCatalogEntry): string { - return entry.provider === "openai-codex" ? "ChatGPT" : (entry.providerName ?? entry.provider); +interface EmojiConfig { + color: string; + shapeIndex: number; + accessory: string; + pose: string; + avatarValue: string; } -function nextStepAfterModel(needsIntegrationSetup: boolean): "integrations" | "bot" { - return needsIntegrationSetup ? "integrations" : "bot"; +const SHAPE_OPTIONS = SHIPPED_SHAPE_KEYS.map((key) => ({ + key, + label: key.charAt(0).toUpperCase() + key.slice(1), +})); + +const ACCESSORY_OPTIONS = [ + { key: "none", label: None }, + { key: "headset", label: Headset }, + { key: "glasses", label: Glasses }, + { key: "clipboard", label: Clipboard }, + { key: "spark", label: Spark }, + { key: "tool", label: Tool }, + { key: "magnifier", label: Magnifier }, + { key: "thought", label: Thought }, + { key: "zzz", label: Zzz }, +]; + +const POSE_OPTIONS = [ + { key: "idle", label: Idle }, + { key: "greeting", label: Greeting }, + { key: "thinking", label: Thinking }, + { key: "working", label: Working }, + { key: "listening", label: Listening }, + { key: "speaking", label: Speaking }, + { key: "success", label: Success }, + { key: "curious", label: Curious }, +]; + +function defaultEmojiConfig(): EmojiConfig { + return { + color: "#8B5CF6", + shapeIndex: 0, + accessory: "none", + pose: "idle", + avatarValue: "#8B5CF6::shape_0", + }; +} + +interface UserPreferences { + name: string; + timezone: string; + hobbies: string[]; + workStyle: string; + communication: string; +} + +const HOBBY_OPTIONS = [ + { key: "coding", label: "Coding" }, + { key: "design", label: "Design" }, + { key: "writing", label: "Writing" }, + { key: "music", label: "Music" }, + { key: "gaming", label: "Gaming" }, + { key: "reading", label: "Reading" }, + { key: "sports", label: "Sports" }, + { key: "cooking", label: "Cooking" }, + { key: "tech", label: "Tech & Gadgets" }, + { key: "none", label: Nothing specific }, +]; + +const WORK_STYLE_OPTIONS = [ + { key: "structured", label: Structured & planned }, + { key: "flexible", label: Flexible & spontaneous }, + { key: "deep-dive", label: Deep-dive focused }, + { key: "quick", label: Quick & pragmatic }, +]; + +const COMMUNICATION_OPTIONS = [ + { key: "direct", label: Direct & concise }, + { key: "detailed", label: Detailed & explanatory }, + { key: "balanced", label: Balanced }, + { key: "casual", label: Casual & friendly }, +]; + +function defaultPreferences(): UserPreferences { + return { + name: "", + timezone: "", + hobbies: [], + workStyle: "", + communication: "", + }; } export function OnboardingPage() { const { t } = useLingui(); const navigate = useNavigate(); const fieldId = useId(); - const [step, setStep] = useState<"loading" | "model" | "integrations" | "bot">("loading"); + const [step, setStep] = useState< + "loading" | "emoji" | "preferences" | "language" | "integrations" | "bot" + >("loading"); const [integrationSetup, setIntegrationSetup] = useState(null); const needsIntegrationSetup = integrationSetup?.needsSetup ?? false; const [integrationServers, setIntegrationServers] = useState([]); - const [catalog, setCatalog] = useState([]); - const [provider, setProvider] = useState("openrouter"); - const [modelId, setModelId] = useState(""); - const [apiKey, setApiKey] = useState(""); - const [baseUrl, setBaseUrl] = useState(""); - const [reasoning, setReasoning] = useState(false); - const [manualModelId, setManualModelId] = useState(false); - const [thinkingLevel, setThinkingLevel] = useState(null); - const [maxTokens, setMaxTokens] = useState(String(DEFAULT_MODEL_MAX_TOKENS)); - const [contextWindow, setContextWindow] = useState(String(DEFAULT_MODEL_CONTEXT_WINDOW)); - const [supportsImages, setSupportsImages] = useState(false); - const [maxImagesPerPrompt, setMaxImagesPerPrompt] = useState(""); - const [{ models: probeModels, probing }, setProbe] = useState(initialModelProbeState); - const [modelProbe] = useState(() => createModelProbe(setProbe)); - const resetOpenAiCompatibleProbe = modelProbe.reset; - const createStartedRef = useRef(false); const [error, setError] = useState(null); - const [notice, setNotice] = useState(null); const [bootstrapMe, setBootstrapMe] = useState> | null>(null); const [profilePersisted, setProfilePersisted] = useState(false); const [crewReady, setCrewReady] = useState(false); const [helpVibe, setHelpVibe] = useState(null); - const { - oauth, - pasteCode, - setPasteCode, - oauthPending, - cancelOAuthAttempt, - startSubscriptionSignIn, - submitOAuthCode, - } = useModelOAuthSignIn({ - onClearError: () => setError(null), - onError: setError, - onFinished: () => { - setStep(nextStepAfterModel(needsIntegrationSetup)); - }, + const [emojiConfig, setEmojiConfig] = useState(defaultEmojiConfig); + const [previewColor, setPreviewColor] = useState(emojiConfig.color); + const [previewShape, setPreviewShape] = useState(emojiConfig.shapeIndex); + const [prefs, setPrefs] = useState(defaultPreferences()); + const [currentLocale, setCurrentLocale] = useState(() => { + try { return localStorage.getItem("rakazo.uiLocale") ?? "en"; } catch { return "en"; } }); useEffect(() => { void Promise.all([ rpc.me(), - rpc.models.list().catch(() => []), rpc.integrationSetup.get().catch(() => null), ]) - .then(([me, models, integrations]) => { + .then(([me, integrations]) => { setBootstrapMe(me); setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me))); setIntegrationSetup(integrations); - setCatalog(models); - const preferred = - models.find( - (entry) => entry.provider === me.defaultProvider && entry.id === me.defaultModel, - ) ?? - models.find((entry) => entry.provider === me.defaultProvider) ?? - models[0]; - if (preferred) { - setProvider(preferred.provider); - setModelId(preferred.provider === OPENAI_COMPATIBLE_PROVIDER_ID ? "" : preferred.id); - } - setStep(me.needsModel ? "model" : integrations?.needsSetup ? "integrations" : "bot"); + setStep(me.needsModel ? "emoji" : integrations?.needsSetup ? "integrations" : "emoji"); }) - .catch(() => setStep("bot")); - return () => { - modelProbe.invalidate(); - }; + .catch(() => setStep("emoji")); }, []); - const providers = useMemo(() => { - const seen = new Map(); - for (const entry of catalog) { - if (!seen.has(entry.provider)) seen.set(entry.provider, entry); - } - return [...seen.values()]; - }, [catalog]); + const updateAvatarValue = (config: EmojiConfig) => { + const newValue = `${config.color}::shape_${config.shapeIndex}`; + setEmojiConfig({ ...config, avatarValue: newValue }); + }; - const modelsForProvider = useMemo( - () => catalog.filter((entry) => entry.provider === provider), - [catalog, provider], - ); - - const selected = modelsForProvider.find((entry) => entry.id === modelId) ?? modelsForProvider[0]; - const isOpenAiCompatible = provider === OPENAI_COMPATIBLE_PROVIDER_ID; - const subscriptionSignIn = selected?.signIn !== undefined; - const acceptsKey = selected?.auth !== "oauth"; - const signInLabel = selected?.oauthLabel ?? t`Sign in`; - const openAiCompatibleReady = openAiCompatibleConnectReady({ - baseUrl, - modelId, - }); - const canSaveModel = Boolean( - selected && - modelId.trim() && - !oauthPending && - (isOpenAiCompatible ? openAiCompatibleReady : acceptsKey && apiKey.trim()), - ); - const otherModelLabel = t`Other model…`; - // Base UI Select.Value only resolves labels when Root gets `items`. - const providerItems = useMemo( - () => providers.map((entry) => ({ value: entry.provider, label: providerLabel(entry) })), - [providers], - ); - const modelItems = useMemo( - () => modelsForProvider.map((entry) => ({ value: entry.id, label: entry.label })), - [modelsForProvider], - ); - const probeModelItems = useMemo( - () => [ - ...probeModels.map((id) => ({ value: id, label: id })), - { value: CUSTOM_MODEL_OPTION, label: otherModelLabel }, - ], - [otherModelLabel, probeModels], - ); - - function updateBaseUrl(nextBaseUrl: string) { - setBaseUrl(nextBaseUrl); - // Keep Other model… mode across URL edits; only provider change clears it. - resetOpenAiCompatibleProbe(); - setError(null); - setNotice(null); + function selectColor(color: string) { + setPreviewColor(color); + const next = { ...emojiConfig, color }; + updateAvatarValue(next); } - function updateApiKey(nextApiKey: string) { - setApiKey(nextApiKey); - resetOpenAiCompatibleProbe(); + function selectShape(shapeIndex: number) { + setPreviewShape(shapeIndex); + const next = { ...emojiConfig, shapeIndex }; + updateAvatarValue(next); } - function selectProvider(nextProvider: string) { - if (nextProvider === provider) return; - cancelOAuthAttempt(); - setProvider(nextProvider); - setApiKey(""); - setModelId( - nextProvider === OPENAI_COMPATIBLE_PROVIDER_ID - ? "" - : (catalog.find((item) => item.provider === nextProvider)?.id ?? ""), - ); - setBaseUrl(""); - setReasoning(false); - setThinkingLevel(null); - setManualModelId(false); - setSupportsImages(false); - setMaxTokens(String(DEFAULT_MODEL_MAX_TOKENS)); - setContextWindow(String(DEFAULT_MODEL_CONTEXT_WINDOW)); - setMaxImagesPerPrompt(""); - resetOpenAiCompatibleProbe(); - setError(null); - setNotice(null); + function selectAccessory(key: string) { + setEmojiConfig({ ...emojiConfig, accessory: key }); } - async function probeServerModels() { - if (!baseUrl.trim()) return; - setError(null); - setNotice(null); - await modelProbe.probe({ - baseUrl, - apiKey, - request: rpc.models.probeOpenAiCompatible, - onSuccess: (models) => { - setModelId((current) => { - const trimmed = current.trim(); - const next = trimmed || models[0] || ""; - // Stay in manual entry across re-probes so a typed id that matches a - // discovered model cannot yank the freeform field back to the Select. - setManualModelId( - (wasManual) => wasManual || (Boolean(trimmed) && !models.includes(trimmed)), - ); - return next; - }); - setNotice(openAiCompatibleProbeSuccessMessage(models.length)); - }, - onError: (err) => - setError(err instanceof Error ? err.message : t`Could not reach this model server`), - }); + function selectPose(key: string) { + setEmojiConfig({ ...emojiConfig, pose: key }); } - async function saveModel() { - if (!canSaveModel) return; - setError(null); - try { - if (isOpenAiCompatible) { - const parsedMaxImagesPerPrompt = parseModelMaxImagesPerPrompt( - maxImagesPerPrompt, - supportsImages, - ); - if (supportsImages && maxImagesPerPrompt.trim() && parsedMaxImagesPerPrompt === undefined) { - setError(t`Enter a whole number from 1 to 1000 for the image limit.`); - return; - } - const maxImagesPerPromptInput = - supportsImages && !maxImagesPerPrompt.trim() ? null : parsedMaxImagesPerPrompt; - - const parsedMaxTokens = parseModelMaxTokens(maxTokens); - if (parsedMaxTokens === undefined) { - setError( - t`Enter a whole number from 1 to ${MAX_MODEL_MAX_TOKENS} for maximum output tokens.`, - ); - return; - } - const parsedContextWindow = parseModelContextWindow(contextWindow); - if (parsedContextWindow === undefined) { - setError( - t`Enter a whole number from 1 to ${MAX_MODEL_CONTEXT_WINDOW} for the context limit.`, - ); - return; - } - await rpc.models.connect({ - provider, - baseUrl: baseUrl.trim(), - modelId: modelId.trim(), - reasoning, - thinkingLevel: reasoning ? thinkingLevel : null, - maxTokens: parsedMaxTokens, - contextWindow: parsedContextWindow, - supportsImages, - maxImagesPerPrompt: maxImagesPerPromptInput, - apiKey: apiKey.trim() || undefined, - label: selected?.providerName ?? provider, - }); - } else if (apiKey) { - await rpc.models.connect({ - provider, - apiKey, - modelId, - label: selected?.providerName ?? provider, - }); - } - setStep(nextStepAfterModel(needsIntegrationSetup)); - } catch (err) { - setError(err instanceof Error ? err.message : t`Could not save model`); - } + function resetEmoji() { + const def = defaultEmojiConfig(); + setEmojiConfig(def); + setPreviewColor(def.color); + setPreviewShape(def.shapeIndex); } - function beginSelectedSubscriptionSignIn() { - void startSubscriptionSignIn({ - provider, - modelId, - label: selected?.providerName ?? provider, - }); + function toggleHobby(key: string) { + const hobbies = prefs.hobbies.includes(key) + ? prefs.hobbies.filter((h) => h !== key) + : [...prefs.hobbies, key]; + setPrefs({ ...prefs, hobbies }); } const discoveryPhase: OnboardingDiscoveryPhase = step === "loading" ? "loading" - : step === "model" - ? "model" + : step === "emoji" || step === "preferences" || step === "language" + ? "emoji" : step === "integrations" ? "integrations" : "assembly"; @@ -376,14 +269,7 @@ export function OnboardingPage() { profilePersisted, crewReady, }), - [ - bootstrapMe, - crewReady, - discoveryPhase, - integrationServers.length, - integrationSetup, - profilePersisted, - ], + [bootstrapMe, crewReady, discoveryPhase, integrationServers.length, integrationSetup, profilePersisted], ); async function createFirstBot() { @@ -391,25 +277,31 @@ export function OnboardingPage() { createStartedRef.current = true; setError(null); try { - const bot = await ensureFirstBot(); + const bot = await ensureFirstBot(emojiConfig.avatarValue); setCrewReady(true); + + const ctxParts: string[] = []; + if (prefs.name) ctxParts.push(`User: ${prefs.name}`); + if (prefs.timezone) ctxParts.push(`Timezone: ${prefs.timezone}`); + if (prefs.hobbies.length > 0) ctxParts.push(`Hobbies: ${prefs.hobbies.join(", ")}`); + if (prefs.workStyle) ctxParts.push(`Work style: ${prefs.workStyle}`); + if (prefs.communication) ctxParts.push(`Communication: ${prefs.communication}`); + const userCtx = ctxParts.length > 0 + ? `## User Context\n${ctxParts.map((p) => `- ${p}`).join("\n")}` + : ""; + await rpc.bots.update({ botId: bot.id, instructions: userCtx }).catch(() => null); + const me = await rpc.me().catch(() => null); if (me) setBootstrapMe(me); const harvested = await persistUserProfileHarvest( - rpc, - collectLocalProfileSignals(me ?? undefined), + rpc, collectLocalProfileSignals(me ?? undefined), ).catch(() => null); if (harvested) setHelpVibe(harvested); setProfilePersisted(true); for (const serverId of integrationServers) { await rpc.mcp.assignments.approve({ botId: bot.id, serverId }); } - // Onboarding continues conversationally in the thread: greeting first, - // then the focus choice (immediate for the first bot). - const started = await rpc.onboarding - .start({ botId: bot.id }) - .then(() => true) - .catch(() => false); + const started = await rpc.onboarding.start({ botId: bot.id }).then(() => true).catch(() => false); if (started) { await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined); } @@ -420,6 +312,8 @@ export function OnboardingPage() { } } + const createStartedRef = useRef(false); + useEffect(() => { if (step !== "bot") return; void createFirstBot(); @@ -429,305 +323,262 @@ export function OnboardingPage() {
- {step === "model" ? ( -
-

- One connection — then we take it from here + {error && step !== "bot" ? ( +

{error}

+ ) : null} + + {/* Step: Emoji Configuration */} + {step === "emoji" ? ( +
+

+ Design your Oo

- - Your crew needs a model to think. This is the only setup step we ask for upfront. - + Give your Oo a look that feels like you — shape, color, accessories.

-
- - Provider - - + +
+
-
- {isOpenAiCompatible ? ( - <> - -
- -
-
- - Model - - {probeModels.length && !manualModelId ? ( - - ) : ( - { - setManualModelId(true); - setModelId(e.target.value); - }} - aria-label={t`Model id`} - placeholder="exact-model-id" - className="mt-2" - /> - )} - {probeModels.length && manualModelId ? ( - - ) : null} -
- { - setReasoning(value); - if (!value) setThinkingLevel(null); - }} - advancedLabel={t`Advanced`} - thinkingLabel={t`Supports thinking`} - thinkingLevel={thinkingLevel} - onThinkingLevelChange={(value) => - setThinkingLevel(value as ThinkingLevel | null) - } - thinkingLevelOptions={[ - { value: "minimal", label: t`Minimal` }, - { value: "low", label: t`Low` }, - { value: "medium", label: t`Medium` }, - { value: "high", label: t`High` }, - { value: "xhigh", label: t`Extra high` }, - { value: "max", label: t`Max` }, - ]} - thinkingLevelLabel={t`Reasoning effort`} - thinkingLevelDefaultLabel={t`Default`} - maxTokens={maxTokens} - onMaxTokensChange={setMaxTokens} - maxTokensLabel={t`Maximum output tokens`} - contextWindow={contextWindow} - onContextWindowChange={setContextWindow} - contextWindowLabel={t`Context limit`} - supportsImages={supportsImages} - onSupportsImagesChange={setSupportsImages} - imagesLabel={t`Supports images`} - maxImagesPerPrompt={maxImagesPerPrompt} - onMaxImagesPerPromptChange={setMaxImagesPerPrompt} - maxImagesLabel={t`Maximum images per request`} + + {/* Shape */} +
+ +
+ {SHAPE_OPTIONS.map((shape, idx) => ( + selectShape(idx)} /> - - ) : ( - <> - - Model - - - - )} -
- {subscriptionSignIn ? ( -
- {oauth ? ( -
- {oauth.mode === "auth-url" ? ( - <> -

- - Finish signing in at{" "} - - {new URL(oauth.verificationUri).hostname} - - . The final page may not load; paste its URL or code here. - -

-
- setPasteCode(e.target.value)} - aria-label={t`Authorization code or callback URL`} - autoComplete="off" - spellCheck={false} - placeholder="http://localhost:53692/callback?code=…" - /> - -
-

- Waiting for sign-in… -

- - ) : ( - <> -

- - Enter this code at{" "} - - {oauth.verificationUri.replace(/^https:\/\//, "")} - - -

-

- {oauth.userCode} -

-

- Waiting for sign-in… -

- - )} -
- ) : ( - - )} + ))}
- ) : null} - {acceptsKey ? ( - isOpenAiCompatible ? ( -
- - API key - - updateApiKey(e.target.value)} - placeholder={t`Optional`} - type="password" - autoComplete="new-password" - className="mt-2" +
+ + {/* Color */} +
+ +
+ {GROK_COLOR_LIST.map((colorDef: GrokColorDef) => ( + + ))} +
+
+ + {/* Accessories */} +
+ +
+ {ACCESSORY_OPTIONS.map((acc) => ( + + ))} +
+
+ + {/* Pose */} +
+ +
+ {POSE_OPTIONS.map((pose) => ( + + ))} +
+
+ +
+ +
) : null} + + {/* Step: Preferences */} + {step === "preferences" ? ( +
+

+ Tell us about yourself +

+

+ A few quick preferences so your Oo can be better tailored to you. +

+ +
+ + setPrefs({ ...prefs, name: e.target.value })} + placeholder={t`Your name`} + className="mt-2" + /> +
+ +
+ + setPrefs({ ...prefs, timezone: e.target.value })} + placeholder={t`Auto-detected or enter manually`} + className="mt-2" + /> +
+ +
+ +

Select all that apply

+
+ {HOBBY_OPTIONS.map((hobby) => ( + + ))} +
+
+ +
+ +

How do you like to work?

+
+ {WORK_STYLE_OPTIONS.map((ws) => ( + + ))} +
+
+ +
+ +

How should your Oo talk to you?

+
+ {COMMUNICATION_OPTIONS.map((comm) => ( + + ))} +
+
+ +
+ + +
+
+ ) : null} + + {/* Step: Language */} + {step === "language" ? ( +
+

+ Choose your language +

+

+ Your Oo will communicate in this language. You can change it later. +

+
+ {Object.entries(UI_LOCALE_LABELS).map(([code, label]) => ( + + ))} +
+
+ + +
+
+ ) : null} + + {/* Step: Integrations */} {step === "integrations" ? (

- Connect tools when you're ready + Connect tools when you''re ready

) : null} + + {/* Step: Bot creation error */} {step === "bot" && error ? (

{error}

- +
) : null} diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index d570413..adc9b25 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -85,7 +85,6 @@ export * from "./openai-compatible-url.js"; export * from "./openai-voice.js"; export * from "./page-browser-session.js"; export * from "./pi-credentials.js"; -export * from "./pi-model-config.js"; export * from "./pi-models.js"; export * from "./pi-oauth.js"; export * from "./pi-openai-compatible-provider.js"; diff --git a/packages/adapters/src/pi-runtime.ts b/packages/adapters/src/pi-runtime.ts index e4a6068..a6c3ee2 100644 --- a/packages/adapters/src/pi-runtime.ts +++ b/packages/adapters/src/pi-runtime.ts @@ -15,10 +15,6 @@ import { Type, } from "@earendil-works/pi-ai"; import { builtinModels } from "@earendil-works/pi-ai/providers/all"; -import { registerLocalProvider } from "./pi-local-provider.js"; -import { - registerOpenAiCompatibleCatalog, -} from "./pi-openai-compatible-provider.js"; import type { AdapterContext, AgentRunRequest, @@ -37,15 +33,35 @@ import { normalizeOpenAiToolParameters, openAiToolParametersNeedNormalization, } from "./openai-tool-parameters.js"; -import type { - PiSessionHandle, +import { PiRuntimeCredentialStore, toOAuthCredential } from "./pi-credentials.js"; +import { registerLocalProvider } from "./pi-local-provider.js"; +import { + OPENAI_COMPATIBLE_PROVIDER_ID, + registerOpenAiCompatibleCatalog, + registerOpenAiCompatibleRuntime, +} from "./pi-openai-compatible-provider.js"; +import { + billedPromptTokens, + clipToolResultContent, + clipToolResultText, + MODEL_STREAM_MAX_RETRIES, + MODEL_STREAM_TIMEOUT_MS, + resolveCompletionMaxTokens, +} from "./pi-runtime-limits.js"; +import { PiJsonlSessionRecorder, - PiSessionRecorder, + type PiSessionHandle, + type PiSessionRecorder, } from "./pi-session.js"; -import type { ToolCallBudget } from "./pi-model-config.js"; -// Track in-flight continueRun calls so concurrent requests for the same run -// share a single work item and avoid duplicate model calls. +import { textContentArg } from "./tool-text.js"; + const running = new Map }>(); +interface ToolCallBudget { + count: number; + exceeded: boolean; + limit: number; + inFlight: number; +} // Optional fuse is process-local. continueRun on another worker starts at zero. const toolCallBudgetsByRun = new Map(); // Built on first use, not at module load: entry points call loadRootEnv() after @@ -444,109 +460,1267 @@ export class PiAgentRuntime implements AgentRuntime { } } -import { - catalogModels, - thinkingLevelFor, - maxToolCallsPerTurn, - toPiImages, - configuredOpenRouterModel, - resolveRuntimeModel, - modelsForRequest, - toAgentTools, - normalizeAgentToolName, - describeToolActivity, - normalizeAgentToolNames, - toAgentTool, - executeSubagent, - parametersFor, - safeJsonSchemaParameters, - builtinParameters, - pruneComputerScreenshotContext, - imagePartCount, - isComputerScreenshotMessage, - isAgentToolExecutionResult, - jsonSchemaParameters, - enumUnion, - jsonField, - summarizeToolResult, - boundAgentToolResult, - assistantText, - sanitizeSensitiveText, - redactActivityUrl, - sanitizeError, - conversationSessionId, - isOpenCodeProvider, - looksLikeOpenCodeSessionError, - sanitizeProviderError, - toolCallBudgetExceededMessage, - toolCallBudgetFor, - releaseToolCallBudget, - maybeAbortToolCallBudget, - beginToolCall, - endToolCall, - modelForCompletion, - createGate, - createQueue, - reliableStreamOptions, - toHistory, - withoutSteeringMessages, - prepareRequestSecretArguments, - PiAgentRuntimeOptions, - EventQueue, - ToolCallBudget, - ToolHost, -} from "./pi-model-config.js"; +function toPiImages(images: AgentRunRequest["currentTurnImages"]) { + return (images ?? []).map((image) => ({ + type: "image" as const, + data: Buffer.from(image.data).toString("base64"), + mimeType: image.mimeType, + })); +} -// Re-export extracted modules -export { - catalogModels, - thinkingLevelFor, - maxToolCallsPerTurn, - toPiImages, - configuredOpenRouterModel, - resolveRuntimeModel, - modelsForRequest, - toAgentTools, - normalizeAgentToolName, - describeToolActivity, - normalizeAgentToolNames, - toAgentTool, - executeSubagent, - parametersFor, - safeJsonSchemaParameters, - builtinParameters, - pruneComputerScreenshotContext, - imagePartCount, - isComputerScreenshotMessage, - isAgentToolExecutionResult, - jsonSchemaParameters, - enumUnion, - jsonField, - summarizeToolResult, - boundAgentToolResult, - assistantText, - sanitizeSensitiveText, - redactActivityUrl, - sanitizeError, - conversationSessionId, - isOpenCodeProvider, - looksLikeOpenCodeSessionError, - sanitizeProviderError, - toolCallBudgetExceededMessage, - toolCallBudgetFor, - releaseToolCallBudget, - maybeAbortToolCallBudget, - beginToolCall, - endToolCall, - modelForCompletion, - createGate, - createQueue, - reliableStreamOptions, - toHistory, - withoutSteeringMessages, - prepareRequestSecretArguments, - PiAgentRuntimeOptions, - EventQueue, - ToolCallBudget, - ToolHost, -} from "./pi-model-config.js"; +function configuredOpenRouterModel(id: string): Model<"openai-completions"> { + // A configured model can intentionally be newer than Pi's static catalog. Keep + // pricing conservative, but enable reasoning: unknown OpenRouter endpoints + // (e.g. gemini-3.7-flash before the snapshot catches up) often mandate it, and + // thinkingLevel "off" becomes effort "none" which those endpoints reject. + return { + id, + name: id, + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 16_384, + maxTokens: 4_096, + }; +} + +function resolveRuntimeModel(modelConfig: AgentRunRequest["model"]): { + provider: string; + modelId: string; + models: Models; + model: Model | undefined; + apiKey: string | undefined; +} { + const provider = modelConfig.provider === "scripted" ? "openrouter" : modelConfig.provider; + const envDefaultModel = process.env.PI_DEFAULT_MODEL?.trim(); + const envDefaultProvider = process.env.PI_DEFAULT_PROVIDER?.trim() || "openrouter"; + const modelId = + modelConfig.id === "scripted" + ? envDefaultModel || DEFAULT_OPENROUTER_MODEL_ID + : modelConfig.id.trim(); + const models = modelsForRequest({ model: modelConfig }, provider); + let model = models.getModel(provider, modelId); + if (!model && provider !== "openrouter" && provider !== OPENAI_COMPATIBLE_PROVIDER_ID) { + model = models.getModel("openrouter", modelId); + } + if ( + !model && + provider === "openrouter" && + envDefaultProvider === "openrouter" && + modelId === envDefaultModel + ) { + model = configuredOpenRouterModel(modelId); + } + const apiKey = modelConfig.oauth + ? undefined + : modelConfig.provider === OPENAI_COMPATIBLE_PROVIDER_ID + ? modelConfig.apiKey || "local" + : // Only OpenRouter may fall back to the OpenRouter env key. Handing it to + // another provider would ship our key to a vendor it was not issued for. + (modelConfig.apiKey ?? + (provider === "openrouter" ? process.env.OPENROUTER_API_KEY : undefined)); + return { provider, modelId, models, model, apiKey }; +} + +export function modelsForRequest( + request: Pick, + provider: string, +): Models { + const oauth = request.model.oauth; + if (oauth) { + const persist = oauth.persist; + return registerOpenAiCompatibleCatalog( + registerLocalProvider( + builtinModels({ + credentials: new PiRuntimeCredentialStore( + provider, + toOAuthCredential(oauth.credential), + persist ? (next) => persist(next) : undefined, + ), + }), + ), + ); + } + if ( + provider === OPENAI_COMPATIBLE_PROVIDER_ID && + request.model.baseUrl && + request.model.id.trim() + ) { + const models = registerOpenAiCompatibleCatalog(registerLocalProvider(builtinModels())); + return registerOpenAiCompatibleRuntime(models, { + modelId: request.model.id, + baseUrl: request.model.baseUrl, + reasoning: request.model.reasoning, + acceptsImages: request.model.acceptsImages, + maxTokens: request.model.maxTokens, + contextWindow: request.model.contextWindow, + }); + } + return catalogModels(); +} + +function toAgentTools(toolDefs: readonly ConnectorTool[], host: ToolHost): AgentTool[] { + const names = normalizeAgentToolNames(toolDefs); + return toolDefs.map((tool, index) => toAgentTool(tool, host, names[index]!)); +} + +/** + * Normalize connector names only at the boundary where they are exposed to Pi. + * Connector execution continues to use the original name captured by toAgentTool. + */ +export function normalizeAgentToolName(name: string): string { + if (isProviderSafeAgentToolName(name)) return name; + const normalized = name + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-zA-Z0-9_-]+/g, "_") + .replace(/^_+|_+$/g, ""); + return (normalized || FALLBACK_AGENT_TOOL_NAME).slice(0, MAX_AGENT_TOOL_NAME_LENGTH); +} + +/** + * Return one valid, unique model-facing name per connector tool. + * Existing valid names are reserved first so sanitizing a connector cannot + * rename or shadow a builtin tool with the same valid name. + */ +const ACTIVITY_DETAIL_LIMIT = 90; + +/** One human-readable line describing a tool call, shown live in the thread. */ +export function describeToolActivity(toolName: string, args: unknown): string { + const record = args && typeof args === "object" ? (args as Record) : {}; + const detail = (value: unknown): string => { + const text = sanitizeSensitiveText(String(value ?? "")) + .replaceAll(/\s+/g, " ") + .trim(); + return text.length > ACTIVITY_DETAIL_LIMIT ? `${text.slice(0, ACTIVITY_DETAIL_LIMIT)}…` : text; + }; + if (toolName === "shell") return `Running: ${detail(record.command)}`; + if (toolName === "read_file") return `Reading ${detail(record.path)}`; + if (toolName === "write_file") return `Writing ${detail(record.path)}`; + if (toolName === "list_files") return `Listing ${detail(record.path ?? ".")}`; + if (toolName === "attach_file") return `Attaching ${detail(record.path)}`; + if (toolName === "open_path") return `Opening ${detail(record.path)}`; + if (toolName === "render_plot") return "Rendering a chart"; + if (toolName === "add_mcp_server") return `Connecting MCP server: ${detail(record.name)}`; + if (toolName === "computer_observe") return "Looking at the screen"; + if (toolName === "browser_navigate") + return `Opening page: ${detail(redactActivityUrl(record.url))}`; + if (toolName === "browser_snapshot") return "Reading the page"; + if (toolName === "browser_act") return "Using the page"; + if (toolName === "computer_act") return "Operating the computer"; + if (toolName === "run_subagent") return `Delegating to helper: ${detail(record.name)}`; + if (toolName === "create_space") return `Creating space: ${detail(record.name)}`; + if (toolName === "create_project") return `Creating project: ${detail(record.name)}`; + if (toolName === "remember") return "Saving a note to memory"; + if (toolName === "web_search") return `Searching the web: ${detail(record.query)}`; + if (toolName === "web_fetch") return `Reading page: ${detail(redactActivityUrl(record.url))}`; + if (toolName === "skill_read") return `Reading skill: ${detail(record.name)}`; + if (toolName === "skill_create") return `Creating skill: ${detail(record.name ?? "skill")}`; + if (toolName === "skill_update") + return `Updating skill: ${detail(record.name ?? record.skillId)}`; + if (toolName === "skill_delete") + return `Deleting skill: ${detail(record.name ?? record.skillId)}`; + const mcp = toolName.match(/^mcp__(.+?)__(.+)$/); + if (mcp) return `Using ${mcp[1]}: ${mcp[2]}`; + return `Using ${toolName}`; +} + +export function normalizeAgentToolNames(tools: readonly ConnectorTool[]): string[] { + const reservedValidNames = new Set( + tools.filter((tool) => isProviderSafeAgentToolName(tool.name)).map((tool) => tool.name), + ); + const usedNames = new Set(); + + return tools.map((tool) => { + const base = normalizeAgentToolName(tool.name); + const originalIsValid = isProviderSafeAgentToolName(tool.name); + let candidate = base; + + if (usedNames.has(candidate) || (!originalIsValid && reservedValidNames.has(candidate))) { + candidate = withToolNameSuffix(base, stableToolNameHash(tool.name)); + } + + let suffix = 2; + while (usedNames.has(candidate) || (!originalIsValid && reservedValidNames.has(candidate))) { + candidate = withToolNameSuffix(base, `${stableToolNameHash(tool.name)}_${suffix}`); + suffix += 1; + } + + usedNames.add(candidate); + return candidate; + }); +} + +function isProviderSafeAgentToolName(name: string): boolean { + return AGENT_TOOL_NAME_PATTERN.test(name) && name.length <= MAX_AGENT_TOOL_NAME_LENGTH; +} + +function withToolNameSuffix(base: string, suffix: string): string { + const suffixWithSeparator = `_${suffix}`; + const prefixLength = Math.max(1, MAX_AGENT_TOOL_NAME_LENGTH - suffixWithSeparator.length); + return `${base.slice(0, prefixLength)}${suffixWithSeparator}`; +} + +function stableToolNameHash(name: string): string { + let hash = 2166136261; + for (const character of name) { + hash ^= character.codePointAt(0) ?? 0; + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(36); +} + +function toHistory( + history: AgentRunRequest["history"], + prompt: string, + sourceMessageId?: string | null, +) { + let duplicatePromptIndex = -1; + for (let index = history.length - 1; index >= 0; index -= 1) { + const message = history[index]; + if ( + message?.role === "user" && + (sourceMessageId ? message.id === sourceMessageId : message.content === prompt) + ) { + duplicatePromptIndex = index; + break; + } + } + const prior = + duplicatePromptIndex < 0 + ? history + : history.filter((_, index) => index !== duplicatePromptIndex); + return prior + .filter((m) => { + if (m.role !== "user" && m.role !== "assistant") return false; + const text = (m.content || "").trim(); + if (!text) return false; + if (m.role === "assistant") { + if (text === "**" || text === "No response. Try again." || text === "Das") return false; + } + return true; + }) + .slice(-25) + .map((m) => + m.role === "assistant" + ? { role: "user" as const, content: `Assistant: ${m.content}`, timestamp: Date.now() } + : { role: "user" as const, content: m.content, timestamp: Date.now() }, + ); +} + +function withoutSteeringMessages( + history: AgentRunRequest["history"], + steering: AgentSteeringMessage[], +): AgentRunRequest["history"] { + if (steering.length === 0) return history; + const result = [...history]; + let beforeIndex = result.length - 1; + for (let steeringIndex = steering.length - 1; steeringIndex >= 0; steeringIndex -= 1) { + const steeringMessage = steering[steeringIndex]; + for (let index = beforeIndex; index >= 0; index -= 1) { + const message = result[index]; + if ( + message?.role !== "user" || + (message.id + ? message.id !== steeringMessage?.messageId + : message.content !== (steeringMessage?.historyText ?? steeringMessage?.text)) + ) { + continue; + } + result.splice(index, 1); + beforeIndex = index - 1; + break; + } + } + return result; +} + +/** + * Normalize `request_secret` arguments. + * + * `credential` and `replace` must survive: the executor stores a submitted value + * only when `credential` is present, and it validates the destination shape + * itself. An earlier version of this function listed only label/purpose/ + * connectionId, so every credential the model supplied was dropped here and the + * saved value had nowhere to go. + */ +export function prepareRequestSecretArguments(raw: Record) { + return { + label: String(raw.label ?? "Code"), + purpose: String(raw.purpose ?? "otp"), + ...(raw.connectionId ? { connectionId: String(raw.connectionId) } : {}), + ...(raw.credential ? { credential: raw.credential } : {}), + ...(raw.replace === true ? { replace: true } : {}), + }; +} + +function toAgentTool(tool: ConnectorTool, host: ToolHost, exposedName: string): AgentTool { + return { + name: exposedName, + label: tool.name, + description: tool.description, + parameters: parametersFor(tool), + prepareArguments: (args: unknown) => { + const raw = args && typeof args === "object" ? (args as Record) : {}; + if (tool.name === "destination.write") { + return { + collection: String(raw.collection ?? "notes"), + title: String(raw.title ?? "Rakazo result"), + body: String(raw.body ?? ""), + }; + } + if (tool.name === "remember") { + return { content: String(raw.content ?? ""), path: String(raw.path ?? "MEMORY.md") }; + } + if (tool.name === "request_takeover") { + return { reason: String(raw.reason ?? "I need you on the screen.") }; + } + if (tool.name === "ask_user") { + const options = Array.isArray(raw.options) ? raw.options.map(String) : raw.options; + return { + question: String(raw.question ?? "What should I use?"), + // Keep a missing/invalid options value as-is so schema minItems can reject it; + // do not coerce to [] (that used to look like a valid empty list upstream). + options, + }; + } + if (tool.name === "request_secret") { + return prepareRequestSecretArguments(raw); + } + if (tool.name === "write_file") { + return { + path: String(raw.path ?? "notes/result.txt"), + content: textContentArg(raw.content, ""), + }; + } + if (tool.name === "computer_act") { + return { + actions: Array.isArray(raw.actions) ? raw.actions : [], + observe: raw.observe === undefined ? true : Boolean(raw.observe), + settle_ms: Number(raw.settle_ms ?? 350), + }; + } + if (tool.name === "list_files") return { path: String(raw.path ?? "") }; + if (tool.name === "read_file" || tool.name === "open_path") { + return { path: String(raw.path ?? "") }; + } + if (tool.name === "launch_app") { + return { + application: String(raw.application ?? ""), + uri: raw.uri ? String(raw.uri) : "", + }; + } + if (tool.name === "shell") { + return { + command: String(raw.command ?? ""), + // The executor chooses the bot-scoped default, including Team workspaces. + ...(raw.cwd ? { cwd: String(raw.cwd) } : {}), + }; + } + if (tool.name === "run_subagent") { + return { + name: String(raw.name ?? "helper"), + task: String(raw.task ?? ""), + instructions: raw.instructions ? String(raw.instructions) : "", + model_provider: raw.model_provider ? String(raw.model_provider) : "", + model_id: raw.model_id ? String(raw.model_id) : "", + }; + } + if (tool.name === "spawn_bot") { + return { + name: String(raw.name ?? ""), + title: raw.title ? String(raw.title) : "", + instructions: raw.instructions ? String(raw.instructions) : "", + prompt: raw.prompt ? String(raw.prompt) : "", + computer_mode: raw.computer_mode ? String(raw.computer_mode) : "", + }; + } + if (tool.name === "create_space") { + return { name: String(raw.name ?? "") }; + } + if (tool.name === "create_project") { + const rawBotIds = raw.bot_ids; + return { + name: String(raw.name ?? ""), + bot_ids: Array.isArray(rawBotIds) + ? rawBotIds.filter((id): id is string => typeof id === "string") + : [], + }; + } + if (tool.name === "archive_bot" || tool.name === "delete_bot") { + return { + confirm_name: String(raw.confirm_name ?? raw.confirmName ?? ""), + bot_id: raw.bot_id ? String(raw.bot_id) : raw.botId ? String(raw.botId) : "", + }; + } + return raw as never; + }, + execute: async (toolCallId, params): Promise> => { + host.signal.throwIfAborted(); + const args = (params ?? {}) as Record; + const executionId = + toolCallId || `${host.request.runId}:${tool.name}:${host.toolCallSeq.value++}`; + if (!beginToolCall(host)) { + return { + content: [{ type: "text", text: "Skipped: tool-call limit reached." }], + details: { skipped: true }, + }; + } + host.queue.push({ type: "tool", name: tool.name, args, executionId }); + const startedAt = Date.now(); + let result: unknown; + let failure: unknown; + try { + result = await (async () => { + if (tool.name === "request_takeover") { + host.pausePending = true; + host.queue.push({ + type: "takeover", + reason: String(args.reason ?? "I need you on the screen."), + }); + return { + content: [{ type: "text", text: "Takeover requested." }], + details: args, + terminate: true, + }; + } + if (tool.name === "ask_user") { + const options = Array.isArray(args.options) + ? args.options.map((option) => String(option).trim()) + : []; + if ( + options.length < 2 || + options.length > 4 || + options.some((option) => option.length === 0 || option.length > 80) || + new Set(options).size !== options.length + ) { + throw new Error("ask_user requires two to four unique, non-empty options"); + } + host.pausePending = true; + host.queue.push({ + type: "ask", + text: String(args.question ?? "What should I use?"), + actions: options.map((label, index) => ({ + id: `choice-${index + 1}`, + label, + })), + }); + return { + content: [{ type: "text", text: "Waiting for the user's choice." }], + details: args, + terminate: true, + }; + } + if (tool.name === "request_secret") { + if (host.request.executeTool) { + const result = await host.request.executeTool(tool.name, args, executionId); + if (isAgentToolExecutionResult(result)) { + if (isToolPauseResult(result)) host.pausePending = true; + return result; + } + return { + content: [{ type: "text", text: summarizeToolResult(result) }], + details: result, + }; + } + host.pausePending = true; + return { + content: [{ type: "text", text: "Protected input requested." }], + details: args, + terminate: true, + }; + } + if (tool.name === "run_subagent") { + const result = await executeSubagent(host, executionId, args); + return { + content: [{ type: "text", text: result }], + details: { result }, + }; + } + if (host.request.executeTool) { + const result = tool.route + ? await host.request.executeTool(tool.name, args, executionId, tool.route) + : await host.request.executeTool(tool.name, args, executionId); + if (isAgentToolExecutionResult(result)) { + if (isToolPauseResult(result)) host.pausePending = true; + return boundAgentToolResult(result); + } + return { + content: [{ type: "text", text: summarizeToolResult(result) }], + details: result, + }; + } + return { + content: [{ type: "text", text: `${tool.name} is unavailable without an executor.` }], + details: { error: "no executor" }, + }; + })(); + return boundAgentToolResult(result as AgentToolResult); + } catch (error) { + failure = error; + throw error; + } finally { + endToolCall(host); + const completion: AgentToolCompletion = { + name: tool.name, + executionId, + durationMs: Math.max(0, Date.now() - startedAt), + ...(result === undefined ? {} : { result }), + ...(failure === undefined ? {} : { error: failure }), + ...(host.pausePending ? { paused: true } : {}), + }; + try { + void Promise.resolve(host.request.onToolCompleted?.(completion)).catch(() => undefined); + } catch { + // Audit hooks are best effort and must never change tool behavior. + } + } + }, + }; +} + +async function executeSubagent(host: ToolHost, executionId: string, args: Record) { + if (host.depth > 0) return "Subagents cannot nest further."; + await host.subagentGate.acquire(); + const agentId = executionId; + const name = + String(args.name ?? "helper") + .trim() + .slice(0, 80) || "helper"; + const task = String(args.task ?? "").trim(); + const extra = args.instructions ? String(args.instructions).trim() : ""; + host.queue.push({ + type: "subagent", + agentId, + name, + task, + status: "running", + progress: "starting…", + }); + + const requestedProvider = String(args.model_provider ?? "").trim(); + const requestedModelId = String(args.model_id ?? "").trim(); + let requestModel = host.request.model; + try { + if (Boolean(requestedProvider) !== Boolean(requestedModelId)) { + throw new Error("model_provider and model_id must both be set"); + } + if (requestedProvider && requestedModelId) { + if (!host.request.resolveModel) { + throw new Error("Per-call subagent model selection is unavailable"); + } + requestModel = await host.request.resolveModel(requestedProvider, requestedModelId); + } + } catch (error) { + const message = sanitizeError(error instanceof Error ? error.message : String(error)); + host.queue.push({ type: "subagent", agentId, name, task, status: "failed", result: message }); + host.subagentGate.release(); + return `Subagent failed: ${message}`; + } + + const selectedModel = resolveRuntimeModel(requestModel); + if (!selectedModel.model) { + const message = `Unknown model ${selectedModel.provider}/${selectedModel.modelId}`; + host.queue.push({ type: "subagent", agentId, name, task, status: "failed", result: message }); + host.subagentGate.release(); + return `Subagent failed: ${message}`; + } + const subagentModel = modelForCompletion(selectedModel.model, requestModel.maxTokens); + + const childDefs = (host.request.tools.length ? host.request.tools : builtinAgentTools).filter( + (tool) => !DELEGATION_TOOL_NAMES.has(tool.name), + ); + const nestedHost: ToolHost = { + ...host, + models: selectedModel.models, + model: subagentModel, + apiKey: selectedModel.apiKey, + depth: 1, + }; + const nested = new Agent({ + sessionId: conversationSessionId(host.request.threadId, host.request.botId, agentId), + streamFn: (m, ctx, options) => + selectedModel.models.streamSimple( + m, + ctx, + reliableStreamOptions(m, options, requestModel.maxTokens), + ), + getApiKey: async () => selectedModel.apiKey, + transformContext: async (messages) => + pruneComputerScreenshotContext(messages, requestModel.maxImagesPerPrompt), + initialState: { + systemPrompt: [ + `You are a Rakazo subagent named "${name}".`, + "You run inside the parent bot's turn — you are not a separate bot chat.", + "Complete the task and return a concise result. Do not spawn bots or further subagents.", + extra, + ] + .filter(Boolean) + .join(" "), + model: subagentModel, + thinkingLevel: thinkingLevelFor(subagentModel, requestModel.thinkingLevel), + tools: toAgentTools(childDefs, nestedHost), + messages: [], + }, + }); + host.nestedAgents.add(nested); + + let streamed = ""; + let lastPush = 0; + nested.subscribe((event) => { + if (event.type === "tool_execution_start") { + if (host.toolCallBudget.exceeded) return; + const toolName = "toolName" in event && event.toolName ? String(event.toolName) : "a tool"; + host.queue.push({ + type: "subagent", + agentId, + name, + task, + status: "running", + progress: `using ${toolName}…`, + }); + } + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + const delta = event.assistantMessageEvent.delta; + if (delta) { + streamed += delta; + const now = Date.now(); + if (now - lastPush >= 80) { + lastPush = now; + host.queue.push({ + type: "subagent", + agentId, + name, + task, + status: "running", + progress: streamed.slice(-800), + }); + } + } + } + if (event.type === "message_end" && event.message.role === "assistant") { + const text = assistantText(event.message); + if (text && !streamed) streamed = text; + if ("usage" in event.message && event.message.usage) { + host.queue.push({ + type: "usage", + ...billedPromptTokens(event.message.usage), + provider: subagentModel.provider, + model: subagentModel.id, + }); + } + } + }); + + try { + if (host.signal.aborted) { + host.queue.push({ + type: "subagent", + agentId, + name, + task, + status: "failed", + result: "stopped", + }); + return "stopped"; + } + const onAbort = () => nested.abort(); + host.signal.addEventListener("abort", onAbort); + try { + await nested.prompt(task || "Complete the delegated task."); + } finally { + try { + await nested.waitForIdle(); + } finally { + host.signal.removeEventListener("abort", onAbort); + // nestedHost is a shallow copy; ask_user / request_takeover set pause + // only on the child. Copy it up before the parent releases the budget. + if (nestedHost.pausePending) host.pausePending = true; + } + } + // Shared-budget abort leaves errorMessage on the nested agent; surface it as a + // completed stop rather than a failed subagent chip. + const budgetExceeded = host.toolCallBudget.exceeded; + const error = nested.state.errorMessage; + if (error && !budgetExceeded) { + const message = sanitizeProviderError(subagentModel.provider, error); + host.queue.push({ type: "subagent", agentId, name, task, status: "failed", result: message }); + return `Subagent failed: ${message}`; + } + const budgetMessage = budgetExceeded + ? toolCallBudgetExceededMessage(host.toolCallBudget.limit) + : undefined; + const result = + budgetMessage && streamed.trim() + ? `${streamed.trim()}\n\n${budgetMessage}` + : budgetMessage || streamed || assistantText(nested.state.messages.at(-1)) || "done."; + const clipped = clipToolResultText(result, 12_000); + host.queue.push({ + type: "subagent", + agentId, + name, + task, + status: "completed", + result: clipped, + }); + return clipped; + } catch (error) { + const message = sanitizeError(error instanceof Error ? error.message : String(error)); + host.queue.push({ type: "subagent", agentId, name, task, status: "failed", result: message }); + return `Subagent failed: ${message}`; + } finally { + host.nestedAgents.delete(nested); + host.subagentGate.release(); + } +} + +/** Build AgentTool.parameters for a connector tool, including OpenAI wire fidelity. */ +export function parametersFor(tool: ConnectorTool) { + const schema = builtinParameters(tool) ?? safeJsonSchemaParameters(tool); + // Type.Union (top-level oneOf/anyOf) serializes without type/properties. + // Re-wrap only when needed so Type.Object schemas keep TypeBox Kind metadata. + if (!openAiToolParametersNeedNormalization(schema)) return schema; + return Type.Unsafe( + normalizeOpenAiToolParameters(JSON.parse(JSON.stringify(schema))), + ) as unknown as ReturnType; +} + +/** A remote MCP server controls its own schemas, so a shape TypeBox cannot express must + * degrade to a permissive object instead of failing every turn for the whole bot. */ +function safeJsonSchemaParameters(tool: ConnectorTool) { + try { + return jsonSchemaParameters(tool.inputSchema); + } catch (error) { + getLogger().error(`unsupported input schema for tool ${tool.name}`, error); + return Type.Object({}); + } +} + +function builtinParameters(tool: ConnectorTool) { + if (tool.name === "write_file") { + return Type.Object({ path: Type.String(), content: Type.String() }); + } + if (tool.name === "destination.write") { + return Type.Object({ + collection: Type.String(), + title: Type.String(), + body: Type.String(), + }); + } + if (tool.name === "request_takeover") { + return Type.Object({ reason: Type.String() }); + } + if (tool.name === "ask_user") { + return Type.Object({ + question: Type.String({ maxLength: 240 }), + options: Type.Array(Type.String({ minLength: 1, maxLength: 80 }), { + minItems: 2, + maxItems: 4, + uniqueItems: true, + }), + }); + } + if (tool.name === "remember") { + return Type.Object({ content: Type.String(), path: Type.String() }); + } + if (tool.name === "shell") { + return Type.Object({ + command: Type.String(), + cwd: Type.Optional(Type.String()), + }); + } + if (tool.name === "run_subagent") { + return Type.Object({ + name: Type.String(), + task: Type.String(), + instructions: Type.Optional(Type.String()), + model_provider: Type.Optional(Type.String()), + model_id: Type.Optional(Type.String()), + }); + } + if (tool.name === "spawn_bot") { + return Type.Object({ + name: Type.String(), + title: Type.Optional(Type.String()), + instructions: Type.Optional(Type.String()), + prompt: Type.Optional(Type.String()), + computer_mode: Type.Optional(Type.Union([Type.Literal("team"), Type.Literal("dedicated")])), + }); + } + if (tool.name === "create_space") { + return Type.Object({ name: Type.String({ minLength: 1, maxLength: 60 }) }); + } + if (tool.name === "create_project") { + return Type.Object({ + name: Type.String({ minLength: 1, maxLength: 80 }), + bot_ids: Type.Array(Type.String(), { minItems: 1 }), + }); + } + if (tool.name === "archive_bot" || tool.name === "delete_bot") { + return Type.Object({ + confirm_name: Type.String(), + bot_id: Type.Optional(Type.String()), + }); + } + return undefined; +} + +/** Keep recent visual state while respecting an optional model image budget. */ +export function pruneComputerScreenshotContext( + messages: AgentMessage[], + maxImagesPerPrompt?: number, +): AgentMessage[] { + const imageLimit = + maxImagesPerPrompt === undefined + ? undefined + : Number.isFinite(maxImagesPerPrompt) + ? Math.max(0, Math.floor(maxImagesPerPrompt)) + : DEFAULT_COMPUTER_SCREENSHOTS_TO_KEEP; + let remaining = imageLimit ?? DEFAULT_COMPUTER_SCREENSHOTS_TO_KEEP; + if (imageLimit !== undefined) { + const nonScreenshotImages = messages.reduce( + (count, message) => + isComputerScreenshotMessage(message) ? count : count + imagePartCount(message), + 0, + ); + if (nonScreenshotImages > imageLimit) { + throw new Error( + `The configured model image limit is ${imageLimit}, but the prompt contains ${nonScreenshotImages} non-screenshot images.`, + ); + } + remaining = imageLimit - nonScreenshotImages; + } + let transformed: AgentMessage[] | undefined; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (!isComputerScreenshotMessage(message)) continue; + const images = imagePartCount(message); + if (remaining >= images) { + remaining -= images; + continue; + } + transformed ??= [...messages]; + transformed[index] = { + ...message, + content: message.content.filter((part) => part.type !== "image"), + }; + } + return transformed ?? messages; +} + +function imagePartCount(message: AgentMessage): number { + if (!("content" in message) || !Array.isArray(message.content)) return 0; + return message.content.filter( + (part: unknown) => + part !== null && typeof part === "object" && "type" in part && part.type === "image", + ).length; +} + +function isComputerScreenshotMessage( + message: AgentMessage | undefined, +): message is Extract { + if (message?.role !== "toolResult" || !message.content.some((part) => part.type === "image")) { + return false; + } + const details = message.details; + return Boolean( + details && + typeof details === "object" && + "frameId" in details && + typeof (details as { frameId?: unknown }).frameId === "string", + ); +} + +function isAgentToolExecutionResult(result: unknown): result is AgentToolExecutionResult { + if ( + !result || + typeof result !== "object" || + (result as { kind?: unknown }).kind !== "agent_tool_result" || + !("content" in result) + ) { + return false; + } + const content = (result as { content?: unknown }).content; + return ( + Array.isArray(content) && + content.every( + (item) => + item && + typeof item === "object" && + ((item as { type?: unknown }).type === "text" || + (item as { type?: unknown }).type === "image"), + ) + ); +} + +export function jsonSchemaParameters( + schema: Record, +): ReturnType { + // Top-level oneOf/anyOf (e.g. request_secret's credential XOR connectionId) + // must stay a union. Falling through to properties would drop the exclusivity + // and re-expose both destinations as optional siblings. + const alternatives = Array.isArray(schema.oneOf) + ? schema.oneOf + : Array.isArray(schema.anyOf) + ? schema.anyOf + : undefined; + if (alternatives && alternatives.length > 0 && schema.properties == null) { + return Type.Union( + alternatives.map((variant) => jsonSchemaParameters(variant as Record)), + ) as unknown as ReturnType; + } + const properties = (schema.properties ?? {}) as Record; + const required = new Set(Array.isArray(schema.required) ? schema.required.map(String) : []); + const fields: Record> = {}; + for (const [key, spec] of Object.entries(properties)) { + const field = jsonField(spec); + fields[key] = (required.has(key) ? field : Type.Optional(field)) as unknown as ReturnType< + typeof Type.Optional + >; + } + // Preserve closed objects (e.g. request_secret destination oneOf branches). + // Type.Object defaults to open, which would let connectionId+replace match both + // anyOf variants after conversion. + return schema.additionalProperties === false + ? Type.Object(fields, { additionalProperties: false }) + : Type.Object(fields); +} + +/** TypeBox only builds literals from primitives; anything else throws while the tool list is + * being assembled, which would take down the whole turn. */ +function enumUnion(values: readonly unknown[]) { + const members = values.map((value) => + value === null + ? Type.Null() + : typeof value === "string" || typeof value === "number" || typeof value === "boolean" + ? Type.Literal(value) + : undefined, + ); + return members.every((member) => member !== undefined) ? Type.Union(members) : undefined; +} + +export function jsonField(spec: unknown): ReturnType { + const definition = spec && typeof spec === "object" ? (spec as Record) : {}; + if (Array.isArray(definition.enum) && definition.enum.length > 0) { + const union = enumUnion(definition.enum); + if (union) return union as never; + } + // A `const` names the only accepted value. Without this it degraded to a bare + // string, so a discriminator like {type: {const: "bearer"}} told the model + // nothing about which value to send -- and it guessed, twice. + if ("const" in definition) { + const literal = enumUnion([definition.const]); + if (literal) return literal as never; + } + // A discriminated union arrives as oneOf/anyOf with no sibling `type`. Without + // this branch it fell through to the string default, so a model was told to + // send an object-valued field as a bare string -- which is exactly what it did. + const variants = Array.isArray(definition.oneOf) + ? definition.oneOf + : Array.isArray(definition.anyOf) + ? definition.anyOf + : undefined; + if (variants && variants.length > 0) { + return Type.Union(variants.map((variant) => jsonField(variant))) as never; + } + if (Array.isArray(definition.type) && definition.type.length > 0) { + return Type.Union(definition.type.map((type) => jsonField({ ...definition, type }))) as never; + } + const type = "type" in definition ? String(definition.type) : "string"; + if (type === "null") return Type.Null() as never; + if (type === "number" || type === "integer") return Type.Number() as never; + if (type === "boolean") return Type.Boolean() as never; + if (type === "array") { + const options: { + minItems?: number; + maxItems?: number; + uniqueItems?: boolean; + } = {}; + if (typeof definition.minItems === "number") options.minItems = definition.minItems; + if (typeof definition.maxItems === "number") options.maxItems = definition.maxItems; + if (definition.uniqueItems === true) options.uniqueItems = true; + return Type.Array(jsonField(definition.items), options) as never; + } + if (type === "object") return jsonSchemaParameters(definition) as never; + return Type.String(); +} + +function summarizeToolResult(result: unknown) { + try { + const text = JSON.stringify(result); + if (!text) return "ok"; + return clipToolResultText(text); + } catch { + return "ok"; + } +} + +function boundAgentToolResult(result: AgentToolResult): AgentToolResult { + if (!result || !Array.isArray(result.content)) return result; + return { + ...result, + content: clipToolResultContent(result.content), + }; +} + +function assistantText(message: unknown): string { + if (!message || typeof message !== "object" || !("content" in message)) return ""; + const content = (message as { content?: unknown }).content; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((part) => + part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part + ? String(part.text) + : "", + ) + .join(""); +} + +function sanitizeSensitiveText(message: string) { + return message + .replace(/sk-or-v1-[a-zA-Z0-9]+/g, "[redacted]") + .replace(/sk-[a-zA-Z0-9-]+/g, "[redacted]") + .replace(/Bearer\s+[^\s"',;&]+/gi, "Bearer [redacted]") + .replace(/eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g, "[redacted]") + .replace(/COMPOSIO_API_KEY[=:]?\s*\S+/gi, "COMPOSIO_API_KEY=[redacted]") + .replace( + /((?:api[_-]?key|access[_-]?token|password|secret)\s*[=:]\s*)[^\s"',;&]+/gi, + "$1[redacted]", + ) + .replace(/((?:auth|authorization)\s*[=:]\s*)(?!Bearer\b)[^\s"',;&]+/gi, "$1[redacted]"); +} + +/** Origin + path only for activity chips; drop userinfo, query, and fragment. */ +function redactActivityUrl(value: unknown): string { + const raw = String(value ?? "").trim(); + if (!raw) return raw; + try { + const url = new URL(raw); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + // Never echo unparsed input — it may still contain userinfo/secrets. + return "[invalid URL]"; + } +} + +function sanitizeError(message: string) { + return sanitizeSensitiveText(message); +} + +/** Stable OpenCode affinity id for a bot conversation (and optional nested agent). */ +export function conversationSessionId(threadId: string, botId: string, agentId?: string): string { + return agentId ? `${threadId}:${botId}:${agentId}` : `${threadId}:${botId}`; +} + +export function isOpenCodeProvider(provider: string): boolean { + return provider === "opencode" || provider === "opencode-go"; +} + +const OPENCODE_SESSION_ERROR = "OpenCode rejected this chat session. Send the message again."; + +function looksLikeOpenCodeSessionError(message: string): boolean { + return ( + /x-opencode-session/i.test(message) || + /session\s*(id|header|required|missing|invalid|expired|stale)/i.test(message) || + /model is unavailable/i.test(message) + ); +} + +function sanitizeProviderError(provider: string, message: string): string { + const sanitized = sanitizeError(message); + if (isOpenCodeProvider(provider) && looksLikeOpenCodeSessionError(sanitized)) { + return OPENCODE_SESSION_ERROR; + } + return sanitized; +} + +interface EventQueue { + push(event: AgentRuntimeEvent): void; + fail(error: Error): void; + close(): void; + iterate(): AsyncIterable; +} + +interface ToolHost { + queue: EventQueue; + request: AgentRunRequest; + models: Models; + model: Model; + apiKey: string | undefined; + nestedAgents: Set; + subagentGate: { acquire(): Promise; release(): void }; + toolCallBudget: ToolCallBudget; + /** Shared fallback uniqueness when the model omits toolCallId (nested hosts reuse this). */ + toolCallSeq: { value: number }; + abortTurn(): void; + signal: AbortSignal; + depth: number; + pausePending: boolean; +} + +function toolCallBudgetExceededMessage(limit: number) { + return `I stopped after reaching the limit of ${limit} tool calls in this turn. Send another message to continue.`; +} + +function toolCallBudgetFor(runId: string): ToolCallBudget { + const existing = toolCallBudgetsByRun.get(runId); + if (existing) { + existing.inFlight = 0; + existing.limit = maxToolCallsPerTurn(); + return existing; + } + const budget: ToolCallBudget = { + count: 0, + exceeded: false, + limit: maxToolCallsPerTurn(), + inFlight: 0, + }; + if (budget.limit > 0) toolCallBudgetsByRun.set(runId, budget); + return budget; +} + +function releaseToolCallBudget(runId: string, keepForResume: boolean) { + if (!keepForResume) toolCallBudgetsByRun.delete(runId); +} + +function maybeAbortToolCallBudget(host: ToolHost) { + if (host.toolCallBudget.exceeded && host.toolCallBudget.inFlight === 0) { + host.abortTurn(); + } +} + +function beginToolCall(host: ToolHost): boolean { + const budget = host.toolCallBudget; + if (budget.limit <= 0) { + budget.count += 1; + return true; + } + if (budget.exceeded) { + maybeAbortToolCallBudget(host); + return false; + } + budget.count += 1; + if (budget.count <= budget.limit) { + budget.inFlight += 1; + return true; + } + if (!budget.exceeded) { + budget.exceeded = true; + host.queue.push({ + type: "progress", + text: `Stopped: more than ${budget.limit} tool calls in one turn.`, + }); + } + maybeAbortToolCallBudget(host); + return false; +} + +function endToolCall(host: ToolHost) { + host.toolCallBudget.inFlight = Math.max(0, host.toolCallBudget.inFlight - 1); + maybeAbortToolCallBudget(host); +} + +function modelForCompletion(model: Model, configuredMaxTokens?: number): Model { + const maxTokens = resolveCompletionMaxTokens(model.maxTokens, configuredMaxTokens); + if (maxTokens === model.maxTokens) return model; + return { ...model, maxTokens }; +} + +function createGate(max: number) { + let active = 0; + const waiters: Array<() => void> = []; + return { + async acquire() { + if (active >= max) { + await new Promise((resolve) => { + waiters.push(resolve); + }); + } + active += 1; + }, + release() { + active = Math.max(0, active - 1); + waiters.shift()?.(); + }, + }; +} + +function createQueue(): EventQueue { + const items: AgentRuntimeEvent[] = []; + let wake: (() => void) | undefined; + let closed = false; + let failure: Error | undefined; + return { + push(event) { + items.push(event); + wake?.(); + }, + fail(error) { + failure = error; + closed = true; + wake?.(); + }, + close() { + closed = true; + wake?.(); + }, + async *iterate() { + while (true) { + if (items.length) { + yield items.shift()!; + continue; + } + if (failure) throw failure; + if (closed) return; + await new Promise((resolve) => { + wake = resolve; + }); + } + }, + }; +} + +export function reliableStreamOptions( + model: Pick, "api" | "provider" | "maxTokens">, + options?: SimpleStreamOptions, + configuredMaxTokens?: number, +): SimpleStreamOptions { + let next: SimpleStreamOptions = { + ...options, + timeoutMs: options?.timeoutMs ?? MODEL_STREAM_TIMEOUT_MS, + maxRetries: options?.maxRetries ?? MODEL_STREAM_MAX_RETRIES, + maxTokens: resolveCompletionMaxTokens(model.maxTokens, configuredMaxTokens, options?.maxTokens), + }; + + if (model.provider === "openai-codex" || model.api === "openai-codex-responses") { + // Pi cannot fall back after a WebSocket has emitted its start event. Long tool + // runs then surface abnormal close 1006 as a terminal model error. SSE has + // bounded network retries and no long-lived connection between tool turns. + next = { ...next, transport: "sse" }; + } + + // OpenCode Go/Zen require a sticky x-opencode-session header (affinity + some + // models 400 without it). Pi 0.85.1 does not attach that header on its own. + if (isOpenCodeProvider(model.provider)) { + const sessionId = next.sessionId?.trim() || randomUUID(); + next = { + ...next, + sessionId, + headers: { + "x-opencode-session": sessionId, + "x-opencode-client": "rakazo", + ...next.headers, + }, + }; + } + + return next; +} diff --git a/packages/ui-web/src/index.ts b/packages/ui-web/src/index.ts index 862313d..ec1eee3 100644 --- a/packages/ui-web/src/index.ts +++ b/packages/ui-web/src/index.ts @@ -1,13 +1,17 @@ export { type AvatarStyle, AvatarStyleProvider, useAvatarStyle } from "./avatar-style.js"; +export { type GrokColorDef } from "./bot-avatar.js"; export { BotAvatar, DEFAULT_GROK_BOT_COLOR, GROK_BOT_COLORS, + GROK_COLOR_LIST, GROK_MASCOT_SHAPES, + GROK_SHAPES, GrokShapePreview, parseBotAvatar, resolvePersonaColorDef, resolvePersonaShape, + SHIPPED_SHAPE_KEYS, } from "./bot-avatar.js"; export * from "./components/ui/alert-dialog.js"; export * from "./components/ui/badge.js";