diff --git a/apps/web/package.json b/apps/web/package.json index 315ca10396..cca3602e15 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "@corbits/api-query": "workspace:*", "@corbits/approvals": "workspace:*", "@corbits/artifact-ui": "workspace:*", + "@corbits/assistant-workflow": "workspace:*", "@corbits/bench-ui": "workspace:*", "@corbits/chat-ui": "workspace:*", "@corbits/client-log": "workspace:*", diff --git a/apps/web/src/client-bootstrap.test.ts b/apps/web/src/client-bootstrap.test.ts index 6abe950ab3..dd331713a7 100644 --- a/apps/web/src/client-bootstrap.test.ts +++ b/apps/web/src/client-bootstrap.test.ts @@ -43,6 +43,7 @@ test("session bootstrap drives the portable client manifest", async () => { id, name: "Ada", slug: "ada", + domain: "ada.example.com", parentId: null, }); }, diff --git a/apps/web/src/myra-deploy.test.ts b/apps/web/src/myra-deploy.test.ts new file mode 100644 index 0000000000..60e9f7096a --- /dev/null +++ b/apps/web/src/myra-deploy.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; + +import { buildMyraDeployInput, MyraDeployError } from "./myra-deploy"; +import { MYRA_SOURCE_CONFIG } from "./myra-source"; + +describe("buildMyraDeployInput", () => { + test("maps a published asset and the operator's offering pick to a WorkflowDeployInput", () => { + const input = buildMyraDeployInput({ + assetId: "ast_123", + sourceOfferingIds: ["off_1", "off_2"], + defaultSourceOfferingId: "off_2", + }); + + expect(input).toEqual({ + source: { + kind: "asset", + assetId: "ast_123", + package: { format: "tarball" }, + }, + entry: MYRA_SOURCE_CONFIG.entryPath, + sourceOfferingIds: ["off_1", "off_2"], + defaultSourceOfferingId: "off_2", + pin: `${MYRA_SOURCE_CONFIG.packageName}@${MYRA_SOURCE_CONFIG.packageVersion}`, + }); + }); + + test("rejects an empty offering list", () => { + expect(() => + buildMyraDeployInput({ + assetId: "ast_123", + sourceOfferingIds: [], + defaultSourceOfferingId: "off_1", + }), + ).toThrow(MyraDeployError); + }); + + test("rejects a default offering id absent from the offering list", () => { + expect(() => + buildMyraDeployInput({ + assetId: "ast_123", + sourceOfferingIds: ["off_1"], + defaultSourceOfferingId: "off_2", + }), + ).toThrow(MyraDeployError); + }); +}); diff --git a/apps/web/src/myra-deploy.ts b/apps/web/src/myra-deploy.ts new file mode 100644 index 0000000000..88cb83918e --- /dev/null +++ b/apps/web/src/myra-deploy.ts @@ -0,0 +1,338 @@ +// Builds and publishes Myra's deployable definition entirely over stock +// routes (CL-8154). See `myra-source.ts` for why this is the one +// deploy-source variant that installs today. +// +// Three steps, each a stock route: ensure the `package-registry` asset +// exists (`POST /api/tenants/:id/assets`, tolerating the 409 an +// already-provisioned tenant reports); pack the built assistant +// definition as an npm-shaped tarball and PUT it in +// (`PUT /api/tenants/:id/assets/:assetId/tarballs/:filename`); hand back +// the `WorkflowDeployInput` the caller passes to `convergeNeedsList`. +// The pure offering-ids → deploy-input mapping (`buildMyraDeployInput`) +// is split out so it can be unit-tested without a fetch or a real +// gzip/tar round-trip. +import { + ASSISTANT_STEP_ID, + ASSISTANT_SYSTEM_PROMPT, + ASSISTANT_TOOL_PACKAGE_PINS, + ASSISTANT_WORKFLOW_ID, +} from "@corbits/assistant-workflow/prompt"; +import { renderWorkflowSourceTree } from "@corbits/workflows/client"; +import { type } from "arktype"; + +import { MYRA_SOURCE_CONFIG } from "./myra-source"; +import type { WorkflowDeployInput } from "./needs-list"; + +export class MyraDeployError extends Error {} + +const AssetCreatedShape = type({ id: "string" }); +const AssetListShape = type({ id: "string", name: "string" }).array(); +const TarballPutShape = type({ commit: "string", integrity: "string" }); + +async function readErrorBody(response: Response): Promise { + const body: unknown = await response.json().catch(() => undefined); + const envelope = type({ + error: { code: "string", userMessage: "string", refId: "string" }, + })(body); + return envelope instanceof type.errors + ? `HTTP ${response.status}` + : envelope.error.userMessage; +} + +/** Idempotently ensures the `package-registry` asset Myra's tarball is + * published into, mirroring `packages/onboarding`'s `ensureWorkflowAsset` + * 409-then-list pattern. */ +export async function ensureMyraSourceAsset( + tenantId: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const created = await fetchImpl( + `/api/tenants/${encodeURIComponent(tenantId)}/assets`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + kind: MYRA_SOURCE_CONFIG.assetKind, + name: MYRA_SOURCE_CONFIG.assetName, + displayName: MYRA_SOURCE_CONFIG.displayName, + }), + }, + ); + if (created.status === 201) { + const parsed = AssetCreatedShape(await created.json()); + if (parsed instanceof type.errors) { + throw new MyraDeployError( + `Myra's source came back an unexpected shape: ${parsed.summary}`, + ); + } + return parsed.id; + } + if (created.status !== 409) { + throw new MyraDeployError( + `preparing Myra's source failed: ${await readErrorBody(created)}`, + ); + } + const listed = await fetchImpl( + `/api/tenants/${encodeURIComponent(tenantId)}/assets?kind=${MYRA_SOURCE_CONFIG.assetKind}&inherited=false`, + ); + if (!listed.ok) { + throw new MyraDeployError( + `checking this workbench's setup failed: ${await readErrorBody(listed)}`, + ); + } + const parsed = AssetListShape(await listed.json()); + if (parsed instanceof type.errors) { + throw new MyraDeployError( + `this workbench's setup list came back an unexpected shape: ${parsed.summary}`, + ); + } + const existing = parsed.find( + (asset) => asset.name === MYRA_SOURCE_CONFIG.assetName, + ); + if (existing === undefined) { + throw new MyraDeployError( + "Myra's source reported a name conflict but is not listed on this workbench", + ); + } + return existing.id; +} + +// -- tar/gzip: a minimal, dependency-free USTAR + gzip writer for the +// exact two small text files `renderWorkflowSourceTree` emits. No new +// package: the browser's own `CompressionStream("gzip")` does the +// compression; only the tar container needs hand-rolling. -- + +const TAR_BLOCK_SIZE = 512; + +function tarChecksumPlaceholder(): string { + return " "; // 8 spaces, per the USTAR header spec +} + +function padOctal(value: number, width: number): string { + return value.toString(8).padStart(width - 1, "0") + "\0"; +} + +/** One USTAR header block plus its content, padded to a 512-byte + * boundary — everything `tar.Parser`'s auto-detected gzip+ustar read + * needs for a single regular-file entry. */ +function tarEntry(path: string, content: Uint8Array): Uint8Array { + if (path.length >= 100) { + throw new MyraDeployError( + `tar entry path ${JSON.stringify(path)} is too long for a USTAR header`, + ); + } + const header = new Uint8Array(TAR_BLOCK_SIZE); + const encoder = new TextEncoder(); + const writeField = (offset: number, value: string): void => { + header.set(encoder.encode(value), offset); + }; + writeField(0, path); + writeField(100, padOctal(0o644, 8)); + writeField(108, padOctal(0, 8)); + writeField(116, padOctal(0, 8)); + writeField(124, padOctal(content.length, 12)); + writeField(136, padOctal(0, 12)); + writeField(148, tarChecksumPlaceholder()); + writeField(156, "0"); // typeflag: regular file + writeField(257, "ustar\0"); + writeField(263, "00"); + + let checksum = 0; + for (const byte of header) checksum += byte; + writeField(148, padOctal(checksum, 8).slice(0, 7) + "\0 "); + + const paddedLength = + Math.ceil(content.length / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE; + const body = new Uint8Array(paddedLength); + body.set(content); + + const entry = new Uint8Array(header.length + body.length); + entry.set(header, 0); + entry.set(body, header.length); + return entry; +} + +/** Packs a `{ path: contents }` tree (as `renderWorkflowSourceTree` + * returns) into an npm-shaped tar archive rooted at `package/`, then + * gzips it. */ +export async function packTarball( + tree: Readonly>, +): Promise { + const encoder = new TextEncoder(); + const entries: Uint8Array[] = []; + for (const [path, contents] of Object.entries(tree)) { + entries.push(tarEntry(`package/${path}`, encoder.encode(contents))); + } + const endOfArchive = new Uint8Array(TAR_BLOCK_SIZE * 2); + const totalLength = + entries.reduce((sum, entry) => sum + entry.length, 0) + endOfArchive.length; + const tar = new Uint8Array(totalLength); + let offset = 0; + for (const entry of entries) { + tar.set(entry, offset); + offset += entry.length; + } + tar.set(endOfArchive, offset); + + const gzipStream = new Blob([tar]) + .stream() + .pipeThrough(new CompressionStream("gzip")); + return new Uint8Array(await new Response(gzipStream).arrayBuffer()); +} + +function tarballFilename(): string { + const safeName = MYRA_SOURCE_CONFIG.packageName.replace( + /[^A-Za-z0-9_.@+-]/g, + "-", + ); + return `${safeName}-${MYRA_SOURCE_CONFIG.packageVersion}.tgz`; +} + +const ASSISTANT_TURN_TIMEOUT_MS = 2 * 60 * 1000; + +/** + * The exact `WorkflowDefinition` JSON `@corbits/assistant-workflow`'s + * `buildAssistantWorkflow` would produce for a single-step, mail-triggered, + * unbounded-turn assistant — hand-built here rather than calling that + * function, because `buildAssistantWorkflow` goes through `@intx/workflow`'s + * `defineWorkflow`/`step`, which pull in `@intx/agent`'s Node-bound runtime + * (file locking) that a browser bundle cannot resolve (confirmed by a failed + * `apps/web` build importing `@corbits/assistant-workflow` directly). + * `apps/web/src/myra-deploy.test.ts` and `workflows/assistant`'s own + * `validate-push.test.ts`-style round-trip both guard this shape against + * drift from `defineWorkflow`'s own normalization + * (`vendor/intx/workflow/src/definition/workflow.ts`'s `normalize`/ + * `applyDefaultInputStep`, and `primitives.ts`'s `step`): a single step with + * no `after` gets `input: { from: "trigger.payload" }`; `triggers: + * "unbounded"` (not the numeric default) gets `drainBehavior: "wait"`; a + * bare `trigger` becomes a one-element `triggers` array. + */ +export function buildMyraDefinitionJson(triggerAddress: string): unknown { + return { + id: ASSISTANT_WORKFLOW_ID, + triggers: [{ type: "mail", to: triggerAddress }], + steps: { + [ASSISTANT_STEP_ID]: { + kind: "step", + id: ASSISTANT_STEP_ID, + agent: { + id: ASSISTANT_STEP_ID, + description: + "A general-purpose assistant that answers questions, drafts " + + "text, and reasons through problems for the team", + systemPrompt: ASSISTANT_SYSTEM_PROMPT, + toolFactories: [], + capabilities: [], + // Cosmetic label only (see `InferencePreference`'s own doc + // comment in `@intx/agent`): deploy-time inference actually + // resolves from the deploy's `sourceOfferingIds`, never from + // this field. + inference: { sources: [{ provider: "stock", model: "stock" }] }, + toolPackagePins: ASSISTANT_TOOL_PACKAGE_PINS, + }, + drainBehavior: "wait", + timeout: ASSISTANT_TURN_TIMEOUT_MS, + triggers: "unbounded", + input: { from: "trigger.payload" }, + }, + }, + stepOrder: [ASSISTANT_STEP_ID], + }; +} + +/** Renders Myra's built assistant definition, packs it, and PUTs it into + * the given `package-registry` asset. Returns the published filename + * (`pin`'s `name@version` names the package inside it; the filename + * itself never leaves this module). */ +export async function publishMyraTarball( + tenantId: string, + assetId: string, + tenantDomain: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const tree = renderWorkflowSourceTree({ + packageName: MYRA_SOURCE_CONFIG.packageName, + workflowJson: JSON.stringify( + buildMyraDefinitionJson(`assistant@${tenantDomain}`), + ), + }); + const tarball = await packTarball(tree); + const filename = tarballFilename(); + const response = await fetchImpl( + `/api/tenants/${encodeURIComponent(tenantId)}/assets/${encodeURIComponent(assetId)}/tarballs/${encodeURIComponent(filename)}`, + { + method: "PUT", + headers: { "content-type": "application/octet-stream" }, + body: new Blob([tarball as Uint8Array]), + }, + ); + if (!response.ok) { + throw new MyraDeployError( + `publishing Myra's tarball failed: ${await readErrorBody(response)}`, + ); + } + const parsed = TarballPutShape(await response.json()); + if (parsed instanceof type.errors) { + throw new MyraDeployError( + `Myra's tarball upload came back an unexpected shape: ${parsed.summary}`, + ); + } +} + +/** The pure mapping this module exists to get right: the operator's + * offering pick plus the asset this tenant just published into, turned + * into the exact `WorkflowDeployInput` `convergeNeedsList` needs. Split + * out from the fetch/tar plumbing above so it is unit-testable on its + * own. */ +export function buildMyraDeployInput(args: { + assetId: string; + sourceOfferingIds: readonly string[]; + defaultSourceOfferingId: string; +}): WorkflowDeployInput { + if (args.sourceOfferingIds.length === 0) { + throw new MyraDeployError( + "at least one source offering id is required to start Myra", + ); + } + if (!args.sourceOfferingIds.includes(args.defaultSourceOfferingId)) { + throw new MyraDeployError( + "the default source offering id must be one of the supplied source offering ids", + ); + } + return { + source: { + kind: "asset", + assetId: args.assetId, + package: { format: "tarball" }, + }, + entry: MYRA_SOURCE_CONFIG.entryPath, + sourceOfferingIds: [...args.sourceOfferingIds], + defaultSourceOfferingId: args.defaultSourceOfferingId, + pin: `${MYRA_SOURCE_CONFIG.packageName}@${MYRA_SOURCE_CONFIG.packageVersion}`, + }; +} + +/** Orchestrates the three steps above and returns the `myraDeploy` input + * ready to hand to `bootstrapClientSession`/`convergeNeedsList`. */ +export async function deployMyraSource( + args: { + tenantId: string; + tenantDomain: string; + sourceOfferingIds: readonly string[]; + defaultSourceOfferingId: string; + }, + fetchImpl: typeof fetch = fetch, +): Promise { + const assetId = await ensureMyraSourceAsset(args.tenantId, fetchImpl); + await publishMyraTarball( + args.tenantId, + assetId, + args.tenantDomain, + fetchImpl, + ); + return buildMyraDeployInput({ + assetId, + sourceOfferingIds: args.sourceOfferingIds, + defaultSourceOfferingId: args.defaultSourceOfferingId, + }); +} diff --git a/apps/web/src/myra-source.ts b/apps/web/src/myra-source.ts new file mode 100644 index 0000000000..5349016287 --- /dev/null +++ b/apps/web/src/myra-source.ts @@ -0,0 +1,52 @@ +// Myra's deploy source, pinned to what this hub can actually install +// today (CL-8154). +// +// `@corbits/assistant-workflow` (workflows/assistant) is `private: true` +// and unpublished, so `WorkflowDefinitionRegistrySource` (the `registry` +// variant) has no external npm registry to resolve it from yet — that is +// CL-8151's job. The package also exports builder functions +// (`buildAssistantWorkflow`), not a default-exported `WorkflowDefinition`, +// so pointing a git `asset` "source" install at the real workspace +// package would fail the source-tree installer's shape checks too. +// +// The one variant that installs cleanly today, entirely over stock +// routes and with no hub or seed change, is the same shape +// `packages/onboarding`'s `seedTenant` and the e2e harness already push: +// the built definition rendered as the two-file source package +// `@corbits/workflows`' `renderWorkflowSourceTree` emits, published as a +// `tarball` inside a stock `package-registry` asset via `PUT +// /api/tenants/:id/assets/:assetId/tarballs/:filename` — a route that +// takes raw tarball bytes over plain `fetch`, no git protocol, no +// system git binary, so it is the only variant reachable from a browser +// SPA like `apps/web`. `myra-deploy.ts` drives it. +import { type } from "arktype"; + +export const MyraSourceConfig = type({ + assetKind: "'package-registry'", + assetName: "string > 0", + displayName: "string > 0", + packageName: "string > 0", + packageVersion: "string > 0", + entryPath: "string > 0", +}); +export type MyraSourceConfig = typeof MyraSourceConfig.infer; + +const parsed = MyraSourceConfig({ + assetKind: "package-registry", + // Distinct from `SETUP_AGENT_ASSET_NAME` ("assistant", a `workflow`-kind + // asset `seedTenant` owns): this is a `package-registry`-kind asset, + // a different asset namespace, so it never collides with a seeded + // tenant's own asset. + assetName: "myra-deploy-source", + displayName: "Myra", + // The rendered package's own name — never `@corbits/assistant-workflow` + // itself, which names the real, un-deployable workspace package this + // module explicitly does not source from (see the module doc above). + packageName: "@workbench-onboarding/myra", + packageVersion: "0.0.0", + entryPath: "./workflow.js", +}); +if (parsed instanceof type.errors) { + throw new Error(`invalid MYRA_SOURCE_CONFIG literal: ${parsed.summary}`); +} +export const MYRA_SOURCE_CONFIG: MyraSourceConfig = parsed; diff --git a/apps/web/src/needs-converge.test.ts b/apps/web/src/needs-converge.test.ts index bb34189056..aed17db44f 100644 --- a/apps/web/src/needs-converge.test.ts +++ b/apps/web/src/needs-converge.test.ts @@ -18,6 +18,7 @@ const primary = { id: "tnt_primary", name: "Ada", slug: "ada", + domain: "ada.example.com", parentId: null, }; @@ -70,6 +71,7 @@ function snapshotWithChild(childId: string): HubSnapshot { id: childId, name: "Atlas", slug: "ada-atlas", + domain: "ada.example.com", parentId: "tnt_primary", }, ], @@ -115,6 +117,7 @@ describe("group workbench creation sequence", () => { id: "tnt_atlas", name: input.name, slug: input.slug, + domain: "ada.example.com", parentId: input.parentId ?? null, }); }, @@ -304,6 +307,7 @@ describe("group workbench creation sequence", () => { id: "tnt_atlas", name: input.name, slug: input.slug, + domain: "ada.example.com", parentId: input.parentId ?? null, }), inviteMember: () => Promise.resolve(), @@ -412,6 +416,7 @@ describe("writes after gap checks", () => { id: "tnt_atlas", name: input.name, slug: input.slug, + domain: "ada.example.com", parentId: input.parentId ?? null, }); }; @@ -517,6 +522,7 @@ describe("thread-native DM derivation", () => { id: `tnt_${input.slug}`, name: input.name, slug: input.slug, + domain: "ada.example.com", parentId: input.parentId ?? null, }); }, @@ -805,6 +811,7 @@ describe("stock-only fetch hub", () => { id: "tnt_atlas", name: "Atlas", slug: "ada-atlas", + domain: "ada.example.com", parentId: "tnt_primary", }, "POST /api/tenants/tnt_atlas/mailbox/messages": { diff --git a/apps/web/src/needs-converge.ts b/apps/web/src/needs-converge.ts index 04eb59b78f..6c9ca37946 100644 --- a/apps/web/src/needs-converge.ts +++ b/apps/web/src/needs-converge.ts @@ -38,6 +38,7 @@ export type HubTenant = { name: string; slug: string; parentId: string | null; + domain: string; }; export type HubRole = { id: string; name: string }; @@ -468,6 +469,7 @@ const TenantShape = type({ id: "string", name: "string", slug: "string", + domain: "string", "parentId?": "string | null", }); const RoleShape = type({ id: "string", name: "string" }); diff --git a/apps/web/src/onboarding/provider-connect-step.tsx b/apps/web/src/onboarding/provider-connect-step.tsx new file mode 100644 index 0000000000..9d2b9c4533 --- /dev/null +++ b/apps/web/src/onboarding/provider-connect-step.tsx @@ -0,0 +1,182 @@ +// The credential + model-offering step this onboarding page runs before +// installing Myra (CL-8154): connects a provider credential through the +// stock native credentials/providers routes (`@corbits/inference-settings`' +// `shadowOffering`), then derives the exactly-one offering it mints into +// `sourceOfferingIds`/`defaultSourceOfferingId` — no separate offering +// picker, because a freshly connected provider names exactly one model. +// A tenant that already resolves a catalog offering (inherited, or from +// an earlier run of this same step) skips this UI entirely: see +// `resolveExistingOffering` in `onboarding-page.tsx`. +import { Button, Input, RadioGroup, RadioOption } from "@corbits/react-ui"; +import { + getResolvedCatalog, + shadowOffering, +} from "@corbits/inference-settings"; +import { reportError } from "@corbits/error-sink"; +import { useState } from "react"; +import type { FormEvent } from "react"; + +import type { ModelProviderPlugin } from "@intx/types"; + +export type ProviderOption = { + readonly plugin: ModelProviderPlugin; + readonly label: string; + readonly description: string; + readonly canonicalName: string; + readonly modelDisplayName: string; + readonly baseURL: string; + readonly keyHint: string; +}; + +// Deliberately small: `ModelProviderPlugin` names the adapters this hub +// ships (`vendor/intx/types/src/catalog.ts`), and each option below picks +// one canonical model so connecting a provider mints exactly one +// offering — no second "which model" prompt (per the CL-8154 ruling, +// the flow asks only where a human input is genuinely required). +// Widening this list (more models per provider, an `openai-compatible` +// custom-baseURL card) is a follow-up, not a blocker for a working +// install. +export const PROVIDER_OPTIONS: readonly ProviderOption[] = [ + { + plugin: "anthropic", + label: "Anthropic", + description: "Claude models, direct from Anthropic.", + canonicalName: "claude-sonnet-4-5", + modelDisplayName: "Claude Sonnet 4.5", + baseURL: "https://api.anthropic.com", + keyHint: "sk-ant-", + }, + { + plugin: "openai", + label: "OpenAI", + description: "GPT models, direct from OpenAI.", + canonicalName: "gpt-5", + modelDisplayName: "GPT-5", + baseURL: "https://api.openai.com/v1", + keyHint: "sk-", + }, + { + plugin: "google-genai", + label: "Google", + description: "Gemini models, direct from Google.", + canonicalName: "gemini-2.5-pro", + modelDisplayName: "Gemini 2.5 Pro", + baseURL: "https://generativelanguage.googleapis.com", + keyHint: "AIza", + }, +]; + +export type ExistingOffering = { + readonly sourceOfferingIds: readonly string[]; + readonly defaultSourceOfferingId: string; +}; + +/** A tenant that already resolves a catalog offering (inherited from an + * ancestor, or minted by an earlier run of this step) needs no UI at + * all: every visible offering becomes `sourceOfferingIds`, and the + * lowest-priority one is the default — the same rule + * `resolveRealSourceOfferingIds` in `packages/onboarding` applies. */ +export async function resolveExistingOffering( + tenantId: string, +): Promise { + const models = await getResolvedCatalog(tenantId); + const offerings = models + .flatMap((model) => model.offerings) + .sort((a, b) => a.priority - b.priority); + const defaultSourceOfferingId = offerings[0]?.offeringId; + if (defaultSourceOfferingId === undefined) return null; + return { + sourceOfferingIds: offerings.map((offering) => offering.offeringId), + defaultSourceOfferingId, + }; +} + +export function ProviderConnectStep({ + tenantId, + onConnected, + onError, +}: { + readonly tenantId: string; + readonly onConnected: (offering: ExistingOffering) => void; + readonly onError: (message: string) => void; +}) { + const [selected, setSelected] = useState( + PROVIDER_OPTIONS[0]?.plugin ?? "anthropic", + ); + const [apiKey, setApiKey] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const option = + PROVIDER_OPTIONS.find((candidate) => candidate.plugin === selected) ?? + PROVIDER_OPTIONS[0]; + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (option === undefined || apiKey.trim().length === 0 || submitting) { + return; + } + setSubmitting(true); + try { + const created = await shadowOffering(tenantId, { + canonicalName: option.canonicalName, + modelDisplayName: option.modelDisplayName, + providerName: option.label, + plugin: option.plugin, + baseURL: option.baseURL, + apiKey: apiKey.trim(), + priority: 0, + }); + onConnected({ + sourceOfferingIds: [created.id], + defaultSourceOfferingId: created.id, + }); + } catch (cause) { + const refId = reportError(cause, { + operation: "onboarding.connect-provider", + tenantId, + }); + onError( + `${cause instanceof Error ? cause.message : String(cause)} (ref ${refId})`, + ); + } finally { + setSubmitting(false); + } + } + + return ( +
void handleSubmit(event)} + > + setSelected(value as ModelProviderPlugin)} + > + {PROVIDER_OPTIONS.map((candidate) => ( + + ))} + + + +
+ ); +} diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 8f2b364224..8c896af458 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -1,28 +1,51 @@ -// The setup gate (CL-8112/CL-8131): the screen a signed-in session lands -// on when the hub reports setup-required. It reads the hub's native -// setup-status route, and a hub that already has tenants bounces -// straight into the shell (`/`). An empty hub drives the browser -// installer itself: `bootstrapClientSession` mints the account's primary -// tenant over the stock `POST /api/tenants` route and converges Myra -// onto it, all over stock routes — the hub never seeds anything. A -// converged install lands on `/`; a stock capability gap (most commonly -// "no myraDeploy configured yet") renders here so the operator sees -// exactly what stock Interchange is missing, with a retry; a broken -// status read blocks with retry too. +// The setup gate (CL-8112/CL-8131/CL-8154): the screen a signed-in +// session lands on when the hub reports setup-required. It reads the +// hub's native setup-status route, and a hub that already has tenants +// bounces straight into the shell (`/`). An empty hub drives the +// browser installer itself, as one converge loop over stock routes, +// asking the operator only where a human input is genuinely required: +// +// 1. mint the account's primary tenant (stock `POST /api/tenants`) if +// it does not already own one; +// 2. resolve a catalog offering to deploy Myra against — if the +// tenant already resolves one (inherited, or a previous run of +// this step), skip straight past; otherwise ask the operator to +// connect exactly one provider credential (`ProviderConnectStep`); +// 3. publish Myra's tarball-sourced deploy input for that offering +// (`deployMyraSource`) and hand it to `bootstrapClientSession` as +// `myraDeploy`. +// +// A converged install lands on `/`; a stock capability gap this loop did +// not anticipate, or a hard failure at any step, renders here with a +// retry — a gap is not "ready". import { Button, EmptyState } from "@corbits/react-ui"; import { WarningCircle } from "@corbits/icons"; import { WorkbenchLoadingState } from "@corbits/chat-ui"; import { useCallback, useEffect, useState } from "react"; -import { runPortableClientBootstrap } from "../client-bootstrap"; +import { + ensurePrimaryTenant, + runPortableClientBootstrap, +} from "../client-bootstrap"; +import { createFetchStockHub, findOwnedTenants } from "../needs-converge"; +import { deployMyraSource } from "../myra-deploy"; import { useNavigate } from "../navigation"; import { triggerFirstLoginProvisioning } from "../onboarding"; import { OnboardingLayout } from "../onboarding/onboarding-layout"; +import { + ProviderConnectStep, + resolveExistingOffering, + type ExistingOffering, +} from "../onboarding/provider-connect-step"; +import type { WorkflowDeployInput } from "../needs-list"; import type { SessionUser } from "../session"; type GateState = | { readonly phase: "checking" } - | { readonly phase: "installing" } + | { readonly phase: "resolving-tenant" } + | { readonly phase: "provider-setup"; readonly tenantId: string } + | { readonly phase: "publishing-myra" } + | { readonly phase: "installing"; readonly myraDeploy: WorkflowDeployInput } | { readonly phase: "setup-pending"; readonly message: string; @@ -39,8 +62,8 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { const [state, setState] = useState({ phase: "checking" }); // One status read per landing (plus each manual recheck): a hub that - // already has tenants means setup is done; an empty hub drives the - // installer immediately rather than showing a dead-end panel. + // already has tenants means setup is done; an empty hub starts the + // converge loop immediately rather than showing a dead-end panel. const checkStatus = useCallback(() => { setState({ phase: "checking" }); void triggerFirstLoginProvisioning().then((result) => { @@ -57,7 +80,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { } else if (result.kind === "existing-member") { navigate("/"); } else { - setState({ phase: "installing" }); + setState({ phase: "resolving-tenant" }); } }); }, [navigate]); @@ -65,14 +88,107 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { checkStatus(); }, [checkStatus]); - // The installer itself: runs once the status read confirms setup is - // required. A converged install hands off to the shell; a stock - // capability gap or a hard failure surfaces here instead of retrying - // silently forever. + // Step 1: mint the primary tenant if this account does not already + // own one, then move to the offering-resolution step. Stays a + // separate phase from "installing" (which still runs the full + // `bootstrapClientSession` converge for the rest of the needs list) + // because a credential connect needs a tenant id to write against. + useEffect(() => { + if (state.phase !== "resolving-tenant") return; + let cancelled = false; + const hub = createFetchStockHub(); + void (async () => { + await ensurePrimaryTenant(user, hub); + const owned = await findOwnedTenants(hub); + const primary = owned.find((tenant) => tenant.parentId === null); + if (primary === undefined) { + throw new Error( + "Sign-in is expected to leave exactly one owned top-level home behind, but this session shows none.", + ); + } + return primary; + })().then( + (primary) => { + if (cancelled) return; + void resolveExistingOffering(primary.id).then( + (existing) => { + if (cancelled) return; + if (existing !== null) { + setState({ phase: "publishing-myra" }); + void publishAndInstall(primary.id, primary.domain, existing); + return; + } + setState({ phase: "provider-setup", tenantId: primary.id }); + }, + (error: unknown) => { + if (cancelled) return; + setState({ + phase: "error", + message: + error instanceof Error + ? error.message + : "Checking your model access hit a snag.", + }); + }, + ); + }, + (error: unknown) => { + if (cancelled) return; + setState({ + phase: "error", + message: + error instanceof Error + ? error.message + : "Setting up your workbench hit a snag.", + }); + }, + ); + return () => { + cancelled = true; + }; + // `publishAndInstall` is defined below and stable across renders (it + // closes over nothing but `setState`), so it is intentionally left + // out of this effect's dependency list. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.phase, user]); + + // Step 3: publish Myra's deploy input for the resolved offering, then + // hand off to the installing phase. Shared by both the + // already-resolved-offering path (above) and the operator-connected + // path (`ProviderConnectStep`'s `onConnected` below). + function publishAndInstall( + tenantId: string, + tenantDomain: string, + offering: ExistingOffering, + ): Promise { + return deployMyraSource({ + tenantId, + tenantDomain, + sourceOfferingIds: offering.sourceOfferingIds, + defaultSourceOfferingId: offering.defaultSourceOfferingId, + }).then( + (myraDeploy) => setState({ phase: "installing", myraDeploy }), + (error: unknown) => { + setState({ + phase: "error", + message: + error instanceof Error + ? error.message + : "Publishing Myra's source hit a snag.", + }); + }, + ); + } + + // Step 4: the installer itself, run once a `myraDeploy` is in hand. A + // converged install hands off to the shell; a stock capability gap or + // a hard failure surfaces here instead of retrying silently forever. useEffect(() => { if (state.phase !== "installing") return; let cancelled = false; - void runPortableClientBootstrap(user).then( + void runPortableClientBootstrap(user, { + myraDeploy: state.myraDeploy, + }).then( (result) => { if (cancelled) return; if (result.kind === "ready") { @@ -97,9 +213,9 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { return () => { cancelled = true; }; - }, [state.phase, user, navigate]); + }, [state, user, navigate]); - if (state.phase === "checking") { + if (state.phase === "checking" || state.phase === "resolving-tenant") { return (
@@ -116,7 +232,48 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { ); } - if (state.phase === "installing") { + if (state.phase === "provider-setup") { + return ( + +
+

Connect a model provider

+

+ Myra needs one working inference credential before she can start. +

+
+ { + const tenantId = state.tenantId; + setState({ phase: "publishing-myra" }); + // The tenant's domain never changes mid-flow — re-derive + // it fresh rather than threading it through state, since + // `resolving-tenant` already looked the tenant up once. + void findOwnedTenants(createFetchStockHub()).then((owned) => { + const primary = owned.find((t) => t.id === tenantId); + if (primary === undefined) { + setState({ + phase: "error", + message: + "Your primary workbench disappeared mid-setup — please retry.", + }); + return; + } + void publishAndInstall(tenantId, primary.domain, offering); + }); + }} + onError={(message) => setState({ phase: "error", message })} + /> +
+
+
+ ); + } + + if (state.phase === "publishing-myra" || state.phase === "installing") { return (
diff --git a/apps/web/test/onboarding.test.tsx b/apps/web/test/onboarding.test.tsx index 3683d76f66..104bc10e2a 100644 --- a/apps/web/test/onboarding.test.tsx +++ b/apps/web/test/onboarding.test.tsx @@ -320,13 +320,14 @@ describe("the setup gate", () => { ); } - test("an empty hub drives the installer, which mints the primary tenant then surfaces the myra-deploy gap", async () => { - // The installer's own flow (CL-8131): the first pass finds no owned - // primary tenant, mints one over stock `POST /api/tenants`, then - // converges again — landing on the "no myraDeploy configured" gap - // since this test supplies no deploy inputs and the fresh tenant has - // no Myra yet. The gate renders that gap, and never navigates: a gap - // is not "ready". + test("an empty hub drives the installer, which mints the primary tenant then asks the operator to connect a provider (CL-8154)", async () => { + // The installer's own flow (CL-8131, extended by CL-8154): the first + // pass finds no owned primary tenant, mints one over stock + // `POST /api/tenants`, then checks whether it already resolves a + // catalog offering. A fresh tenant has none, so the gate renders the + // credential-connect step rather than the old "no myraDeploy + // configured" gap — the whole point of CL-8154 is that this page + // supplies `myraDeploy` itself instead of stopping there. let ownsPrimary = false; const container = document.createElement("div"); document.body.appendChild(container); @@ -364,6 +365,7 @@ describe("the setup gate", () => { id: "tnt_primary", name: "Ada's Workbench", slug: "ada", + domain: "ada.workbench.localhost", parentId: null, }, 201, @@ -374,12 +376,16 @@ describe("the setup gate", () => { id: "tnt_primary", name: "Ada's Workbench", slug: "ada", + domain: "ada.workbench.localhost", parentId: null, }); } if (path === "/api/tenants/tnt_primary/principals") { return json({ data: [], nextCursor: null }); } + if (path === "/api/tenants/tnt_primary/models") { + return json([]); + } // Anything outside the mocked stock routes is a failure: the gate // must never touch the deleted `/api/onboarding/*` routes. throw new Error(`unexpected fetch: ${url}`); @@ -388,16 +394,10 @@ describe("the setup gate", () => { try { await settle(root, gateElement(navigate)); - expect(container.textContent).toContain("Set up your workbench"); - expect(container.textContent).toContain("Check again"); + expect(container.textContent).toContain("Connect a model provider"); expect(calls).toEqual([]); expect(seen).toContain("/api/setup/status"); - expect( - seen.some( - (url) => - url.startsWith("/api/tenants") && !url.includes("principals"), - ), - ).toBe(true); + expect(seen).toContain("/api/tenants/tnt_primary/models"); } finally { act(() => root.unmount()); container.remove(); diff --git a/bun.lock b/bun.lock index 377c934fa8..add9edbd9a 100644 --- a/bun.lock +++ b/bun.lock @@ -157,6 +157,7 @@ "@corbits/api-query": "workspace:*", "@corbits/approvals": "workspace:*", "@corbits/artifact-ui": "workspace:*", + "@corbits/assistant-workflow": "workspace:*", "@corbits/bench-ui": "workspace:*", "@corbits/chat-ui": "workspace:*", "@corbits/client-log": "workspace:*", diff --git a/workflows/assistant/package.json b/workflows/assistant/package.json index e975da3746..d0f0c23809 100644 --- a/workflows/assistant/package.json +++ b/workflows/assistant/package.json @@ -13,7 +13,8 @@ } }, "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./prompt": "./src/prompt.ts" }, "scripts": { "typecheck": "tsc --noEmit", diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index b60294d2f4..a8527b574c 100644 --- a/workflows/assistant/src/index.ts +++ b/workflows/assistant/src/index.ts @@ -24,230 +24,20 @@ import type { AgentDefinition, InferencePreference } from "@intx/agent"; import { defineWorkflow, step } from "@intx/workflow"; import type { WorkflowDefinition } from "@intx/workflow"; -import type { ToolPackagePin } from "@intx/types/tool-packages"; -export const ASSISTANT_WORKFLOW_ID = "wf_assistant"; -export const ASSISTANT_STEP_ID = "assistant"; +import { + ASSISTANT_STEP_ID, + ASSISTANT_SYSTEM_PROMPT, + ASSISTANT_TOOL_PACKAGE_PINS, + ASSISTANT_WORKFLOW_ID, +} from "./prompt"; -/** - * The tool packages this deployment pins. `@corbits/memory-tools` - * is the original pin; `@corbits/capability-tools` lets Myra self-service - * a missing tool, skill, or model; the manager-tools bundles give Myra - * real workbench-management capability — a specialist agent she can - * create (each gets their own chat), connection visibility, and skill - * capture — each a thin wrapper over an existing platform primitive (see each package's own file-header comment for - * which one). `@corbits/mcp-tools` and `@corbits/interaction-tools` - * expose tenant-connected MCP servers and the ask-user card; - * `@corbits/manus-tools` is pinned so Manus tools exist when the tenant - * has connected Manus (launch folds the binding only then — the pin - * itself does not require a credential). `@corbits/access-tools` gives - * Myra the grant surface (list_principals/list_grants/grant_access/ - * revoke_access) she needs to grant a teammate she just created only - * what it needs. No `@intx/tools-posix`: Myra never gets raw filesystem - * access. - */ -export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ - { name: "@corbits/memory-tools", version: "0.0.4" }, - { name: "@corbits/capability-tools", version: "0.0.6" }, - { name: "@corbits/agent-directory-tools", version: "0.0.6" }, - { name: "@corbits/connections-tools", version: "0.0.9" }, - { name: "@corbits/catalog-tools", version: "0.0.6" }, - { name: "@corbits/skills-tools", version: "0.0.7" }, - { name: "@corbits/mcp-tools", version: "0.0.11" }, - { name: "@corbits/interaction-tools", version: "0.0.8" }, - { name: "@corbits/manus-tools", version: "0.0.11" }, - { name: "@corbits/workflow-authoring-tools", version: "0.0.6" }, - { name: "@corbits/access-tools", version: "0.0.3" }, -]; - -/** - * WELCOME: how Myra introduces herself when asked. Said once, briefly — - * an offer, not a menu. The opening hello itself is canned and posted - * by the chat layer before her first turn (see `@corbits/chat`'s - * `postCannedGreeting`), so this clause covers later introductions only. - */ -const ASSISTANT_WELCOME_CLAUSE = - "When you introduce yourself, say plainly, once, what you can " + - "actually do here: create more agents, set up routines, or open a " + - "shared channel — an offer, not a " + - "checklist to read off, " + - "and never a reason to withhold help until asked whether you're " + - "allowed to."; - -/** - * TRIAGE (CL-6350): how Myra decides, on every message, whether to - * answer directly or delegate — what to do when a job needs a - * connection this workbench doesn't have yet, and the reverse case: she - * answers by default and only steps back when a message @-mentions a - * different teammate and not her. - */ -const ASSISTANT_TRIAGE_CLAUSE = - "On every message, decide first whether to answer directly or " + - "delegate: answer directly when the request is a question, a piece " + - "of drafting, or something you can reason through yourself in this " + - "conversation; delegate — by drafting and creating a new specialist " + - "agent when no existing one fits — when the work is a distinct, " + - "boundable job better run on its own, especially anything that " + - "should recur (draft a routine for it) rather than be asked for " + - "each time. State which you're doing and why in one short line " + - "before you act, and " + - "always summarize a delegated result back to the sender when it " + - "completes rather than leaving it to be found in another channel. " + - "When a job needs a service that isn't connected yet, name the " + - "connection and hand over the link to connect it, then continue " + - "once it's there. In a workbench with other agent teammates, " + - "delegate by @mentioning the specialist in your reply and saying " + - "why in a few words — don't answer for a specialist when handing " + - "off; that @mention opens a thread for the deep-dive, so tell the " + - "specialist, when you brief it, to finish its thread with a " + - "one-line summary addressed back to you and the main conversation " + - "rather than leaving the result buried in the thread. You answer " + - "by default: when an incoming message @-mentions a different " + - "teammate by name and not you, that is their turn — stay out of it " + - "unless you are @-mentioned too or the sender asks you directly."; - -/** - * TEAMMATE (CL-7469 folds in COORDINATOR): how Myra offers help without - * pushing it — folds in the skills-capture nudge rather than a separate - * always-on clause. Myra is the coordinator of this bench's agents, not - * just its resident assistant: she never builds a teammate by - * hand-editing files or answering in its place, and never stands up a - * second instance of an agent that already exists. - * - * The build-arc sentence (CL-5879) is the load-bearing one: it never - * waits for someone to name the mechanism ("make an agent", "set up a - * routine"). An *outcome* — a sales motion, a content pipeline, a repo - * to keep up — is enough on its own for Myra to work out the - * mechanism herself and propose it; asking "should I create an agent - * for that?" is the wizard behavior this clause exists to rule out. - */ -const ASSISTANT_TEAMMATE_CLAUSE = - "Be a teammate, not a wizard: use your judgment about when to " + - "suggest and when to just listen or answer. When you can see a " + - "useful next move — what this workbench could be for, a connection " + - "a job will need, a recurring ask that would be better as a " + - "routine, a job a specialist agent should own, or a way of doing " + - "something here that's worth saving as a skill so every agent in " + - "this workbench can use it — offer it once, plainly, and let the " + - "person decide; if they pass, drop it. Don't narrate a checklist or " + - "push setup on someone who came to talk. Match their pace: someone " + - "building something out gets a proactive partner, someone asking " + - "one question gets a good answer. You are the coordinator of this " + - "bench's agents, not just its resident assistant. When someone " + - "describes an outcome they want this workbench to produce — running " + - "a sales motion, keeping up a content pipeline, maintaining a repo, " + - "anything with a recognizable shape — never wait to be told the " + - "mechanism; work out the team yourself: which specialists it needs, " + - "what each one owns, and which routines keep it running without " + - "being asked each time, then say that plan back in one short " + - "paragraph before doing anything. First check memory for what you " + - "already know about this person's work so you don't ask for it " + - "twice. Then ask only for the handful of facts you genuinely can't " + - "infer — their ICP, a repo URL, a cadence, whichever specifics the " + - "plan actually turns on — never 'should I create an agent for " + - "that?' or any other question that just asks permission to use the " + - "mechanism. On their OK, build the whole thing in one go: for each " + - "specialist, check list_agents first and reuse one that already " + - "fits rather than standing up a second one; otherwise author it, " + - "deploy it, grant it only the access its job needs, and add it to " + - "the bench or a DM (each gets its own chat) — then create the " + - "routines and save the facts they gave you to memory. Every write " + - "already asks for its own approval, so build once you have what you " + - "need rather than checking in again first. Report back what you " + - "built and what it can now do once it's live."; - -/** - * DISCOVERY (CL-6179): the specific interview-then-propose procedure a - * stated outcome triggers, on top of the general "work out the team - * yourself" doctrine in `ASSISTANT_TRIAGE_CLAUSE` above — short and - * concrete rather than open-ended, so discovery never turns into a - * drawn-out intake form. Ordering matters: the skill loads before - * `create_agent` ever fires, matching every other prompt-authoring path - * in this file. - */ -const ASSISTANT_DISCOVERY_CLAUSE = - "When someone states an outcome rather than a mechanism, discover " + - "before proposing: ask one or two sharp questions that resolve what " + - "the team actually needs to know — a tappable card via ask_user when " + - "the options are enumerable, plain prose otherwise — never a long, " + - "open-ended interview. Then propose a small, named specialist team, " + - "one line per member naming its job. Build only on a light " + - "confirmation, never a full spec: load the writing-system-prompts " + - "skill, then create_agent for each specialist in the plan. Once " + - "they're built, hand off in one line: \"Their own chats for focused " + - 'work. Here when you want me to run the hunt and hand things off."'; - -/** - * Sectioned per current cross-lab prompting guidance (Anthropic, - * OpenAI, Google, xAI, Cursor — researched 2026-08, distilled into the - * seeded `writing-system-prompts` skill): one-line identity first, - * named sections one concern each, cross-tool doctrine only (each - * tool's own description says how it works), and runtime facts (who - * sent a message) arriving as data on each mail rather than - * baked-in lore. The behavior clauses above (triage and teammate) are - * load-bearing and eval-anchored — restructure around them, never - * reword them casually. - */ -export const ASSISTANT_SYSTEM_PROMPT = - "You are Myra, the resident teammate agent inside this team's " + - "shared workbench.\n" + - "\n" + - "## Where you are\n" + - "You are already inside the workbench, a member of its " + - "conversation alongside the team — never speak as if you are " + - "somewhere else, and never ask to be pointed at the workbench or " + - "shown around; when you need direction, ask what they are working " + - "on. When a workbench is opened with you in it, a short opener is " + - "posted on the timeline under your name before your first turn — a " + - "hello introducing you and asking what they are working on. Never " + - "greet or introduce yourself again after it: answer their first " + - "message directly, as a teammate mid-conversation would. Never " + - "list your capabilities as a menu, and never mention memory, " + - "lookups, or missing context. Messages arrive as " + - 'mail and may carry a leading "[From: someone]" header line; treat ' + - "that line as metadata about who sent the message, never as part " + - "of the message to act on, and never echo it back in your reply.\n" + - "\n" + - "## How you speak\n" + - "Lead with the answer; two to five sentences unless the sender " + - "asks you to elaborate. " + - ASSISTANT_WELCOME_CLAUSE + - " When interviewing someone with a small set of enumerable options " + - "(2-6), use ask_user to show it as an interactive card instead of " + - "writing the options out as prose.\n" + - "\n" + - "## Deciding what to do\n" + - ASSISTANT_TRIAGE_CLAUSE + - " " + - ASSISTANT_DISCOVERY_CLAUSE + - "\n" + - "\n" + - "## Being a teammate\n" + - ASSISTANT_TEAMMATE_CLAUSE + - "\n" + - "\n" + - "## Tools\n" + - "Each tool's own description says how it works; what spans them: " + - "invoke tools only through tool calls, never by writing a JSON object " + - "with a tool name into your reply. " + - "read-only tools run free, anything that changes state asks for " + - "its own approval — so act once you have what you need instead of " + - "asking permission to use a tool. Use the team's firm memory " + - "(memory_search, memory_add, memory_list) to recall facts and " + - "decisions from earlier conversations and to record ones worth " + - "keeping — never memory_search a bare greeting, and use memory only " + - "when you actually need a fact from earlier; never fabricate a " + - "recollection when a search comes back empty, and if memory isn't " + - "set up on this deployment, proceed without mentioning it. Any MCP " + - "server connected under Plugins is " + - "reachable with mcp_list_servers, mcp_list_tools, mcp_read, and " + - "mcp_call — discover once with mcp_list_tools (pattern search when " + - "unsure which server has the tool you want); use mcp_read for " + - "read-only tools and mcp_call for anything that changes state; " + - "never guess a tool name or its arguments, and never dump a " + - "server's whole catalog into a reply. Before you write or edit any " + - "agent's system prompt or a routine's instructions, load the " + - "writing-system-prompts skill and follow it."; +export { + ASSISTANT_STEP_ID, + ASSISTANT_SYSTEM_PROMPT, + ASSISTANT_TOOL_PACKAGE_PINS, + ASSISTANT_WORKFLOW_ID, +} from "./prompt"; /** * Everything the definition needs that is per-deployment data. The diff --git a/workflows/assistant/src/prompt.ts b/workflows/assistant/src/prompt.ts new file mode 100644 index 0000000000..ebfa1d2cd7 --- /dev/null +++ b/workflows/assistant/src/prompt.ts @@ -0,0 +1,233 @@ +// Myra's pure prompt/pin data — no runtime import of `@intx/workflow` or +// `@intx/agent` (only a type-only import below), so this module is safe +// to bundle into a browser build (CL-8154's `apps/web` needs exactly +// this: the content, not `buildAssistantWorkflow`'s `defineWorkflow` +// call, which pulls in `@intx/agent`'s Node-bound runtime — file +// locking, in particular — that a browser bundle cannot resolve). +// `index.ts`'s `buildAssistantWorkflow` imports these same constants; +// this file is their one source, never duplicated. + +import type { ToolPackagePin } from "@intx/types/tool-packages"; + +export const ASSISTANT_WORKFLOW_ID = "wf_assistant"; +export const ASSISTANT_STEP_ID = "assistant"; + +/** + * The tool packages this deployment pins. `@corbits/memory-tools` + * is the original pin; `@corbits/capability-tools` lets Myra self-service + * a missing tool, skill, or model; the manager-tools bundles give Myra + * real workbench-management capability — a specialist agent she can + * create (each gets their own chat), connection visibility, and skill + * capture — each a thin wrapper over an existing platform primitive (see each package's own file-header comment for + * which one). `@corbits/mcp-tools` and `@corbits/interaction-tools` + * expose tenant-connected MCP servers and the ask-user card; + * `@corbits/manus-tools` is pinned so Manus tools exist when the tenant + * has connected Manus (launch folds the binding only then — the pin + * itself does not require a credential). `@corbits/access-tools` gives + * Myra the grant surface (list_principals/list_grants/grant_access/ + * revoke_access) she needs to grant a teammate she just created only + * what it needs. No `@intx/tools-posix`: Myra never gets raw filesystem + * access. + */ +export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ + { name: "@corbits/memory-tools", version: "0.0.4" }, + { name: "@corbits/capability-tools", version: "0.0.6" }, + { name: "@corbits/agent-directory-tools", version: "0.0.6" }, + { name: "@corbits/connections-tools", version: "0.0.9" }, + { name: "@corbits/catalog-tools", version: "0.0.6" }, + { name: "@corbits/skills-tools", version: "0.0.7" }, + { name: "@corbits/mcp-tools", version: "0.0.11" }, + { name: "@corbits/interaction-tools", version: "0.0.8" }, + { name: "@corbits/manus-tools", version: "0.0.11" }, + { name: "@corbits/workflow-authoring-tools", version: "0.0.6" }, + { name: "@corbits/access-tools", version: "0.0.3" }, +]; + +/** + * WELCOME: how Myra introduces herself when asked. Said once, briefly — + * an offer, not a menu. The opening hello itself is canned and posted + * by the chat layer before her first turn (see `@corbits/chat`'s + * `postCannedGreeting`), so this clause covers later introductions only. + */ +const ASSISTANT_WELCOME_CLAUSE = + "When you introduce yourself, say plainly, once, what you can " + + "actually do here: create more agents, set up routines, or open a " + + "shared channel — an offer, not a " + + "checklist to read off, " + + "and never a reason to withhold help until asked whether you're " + + "allowed to."; + +/** + * TRIAGE (CL-6350): how Myra decides, on every message, whether to + * answer directly or delegate — what to do when a job needs a + * connection this workbench doesn't have yet, and the reverse case: she + * answers by default and only steps back when a message @-mentions a + * different teammate and not her. + */ +const ASSISTANT_TRIAGE_CLAUSE = + "On every message, decide first whether to answer directly or " + + "delegate: answer directly when the request is a question, a piece " + + "of drafting, or something you can reason through yourself in this " + + "conversation; delegate — by drafting and creating a new specialist " + + "agent when no existing one fits — when the work is a distinct, " + + "boundable job better run on its own, especially anything that " + + "should recur (draft a routine for it) rather than be asked for " + + "each time. State which you're doing and why in one short line " + + "before you act, and " + + "always summarize a delegated result back to the sender when it " + + "completes rather than leaving it to be found in another channel. " + + "When a job needs a service that isn't connected yet, name the " + + "connection and hand over the link to connect it, then continue " + + "once it's there. In a workbench with other agent teammates, " + + "delegate by @mentioning the specialist in your reply and saying " + + "why in a few words — don't answer for a specialist when handing " + + "off; that @mention opens a thread for the deep-dive, so tell the " + + "specialist, when you brief it, to finish its thread with a " + + "one-line summary addressed back to you and the main conversation " + + "rather than leaving the result buried in the thread. You answer " + + "by default: when an incoming message @-mentions a different " + + "teammate by name and not you, that is their turn — stay out of it " + + "unless you are @-mentioned too or the sender asks you directly."; + +/** + * TEAMMATE (CL-7469 folds in COORDINATOR): how Myra offers help without + * pushing it — folds in the skills-capture nudge rather than a separate + * always-on clause. Myra is the coordinator of this bench's agents, not + * just its resident assistant: she never builds a teammate by + * hand-editing files or answering in its place, and never stands up a + * second instance of an agent that already exists. + * + * The build-arc sentence (CL-5879) is the load-bearing one: it never + * waits for someone to name the mechanism ("make an agent", "set up a + * routine"). An *outcome* — a sales motion, a content pipeline, a repo + * to keep up — is enough on its own for Myra to work out the + * mechanism herself and propose it; asking "should I create an agent + * for that?" is the wizard behavior this clause exists to rule out. + */ +const ASSISTANT_TEAMMATE_CLAUSE = + "Be a teammate, not a wizard: use your judgment about when to " + + "suggest and when to just listen or answer. When you can see a " + + "useful next move — what this workbench could be for, a connection " + + "a job will need, a recurring ask that would be better as a " + + "routine, a job a specialist agent should own, or a way of doing " + + "something here that's worth saving as a skill so every agent in " + + "this workbench can use it — offer it once, plainly, and let the " + + "person decide; if they pass, drop it. Don't narrate a checklist or " + + "push setup on someone who came to talk. Match their pace: someone " + + "building something out gets a proactive partner, someone asking " + + "one question gets a good answer. You are the coordinator of this " + + "bench's agents, not just its resident assistant. When someone " + + "describes an outcome they want this workbench to produce — running " + + "a sales motion, keeping up a content pipeline, maintaining a repo, " + + "anything with a recognizable shape — never wait to be told the " + + "mechanism; work out the team yourself: which specialists it needs, " + + "what each one owns, and which routines keep it running without " + + "being asked each time, then say that plan back in one short " + + "paragraph before doing anything. First check memory for what you " + + "already know about this person's work so you don't ask for it " + + "twice. Then ask only for the handful of facts you genuinely can't " + + "infer — their ICP, a repo URL, a cadence, whichever specifics the " + + "plan actually turns on — never 'should I create an agent for " + + "that?' or any other question that just asks permission to use the " + + "mechanism. On their OK, build the whole thing in one go: for each " + + "specialist, check list_agents first and reuse one that already " + + "fits rather than standing up a second one; otherwise author it, " + + "deploy it, grant it only the access its job needs, and add it to " + + "the bench or a DM (each gets its own chat) — then create the " + + "routines and save the facts they gave you to memory. Every write " + + "already asks for its own approval, so build once you have what you " + + "need rather than checking in again first. Report back what you " + + "built and what it can now do once it's live."; + +/** + * DISCOVERY (CL-6179): the specific interview-then-propose procedure a + * stated outcome triggers, on top of the general "work out the team + * yourself" doctrine in `ASSISTANT_TRIAGE_CLAUSE` above — short and + * concrete rather than open-ended, so discovery never turns into a + * drawn-out intake form. Ordering matters: the skill loads before + * `create_agent` ever fires, matching every other prompt-authoring path + * in this file. + */ +const ASSISTANT_DISCOVERY_CLAUSE = + "When someone states an outcome rather than a mechanism, discover " + + "before proposing: ask one or two sharp questions that resolve what " + + "the team actually needs to know — a tappable card via ask_user when " + + "the options are enumerable, plain prose otherwise — never a long, " + + "open-ended interview. Then propose a small, named specialist team, " + + "one line per member naming its job. Build only on a light " + + "confirmation, never a full spec: load the writing-system-prompts " + + "skill, then create_agent for each specialist in the plan. Once " + + "they're built, hand off in one line: \"Their own chats for focused " + + 'work. Here when you want me to run the hunt and hand things off."'; + +/** + * Sectioned per current cross-lab prompting guidance (Anthropic, + * OpenAI, Google, xAI, Cursor — researched 2026-08, distilled into the + * seeded `writing-system-prompts` skill): one-line identity first, + * named sections one concern each, cross-tool doctrine only (each + * tool's own description says how it works), and runtime facts (who + * sent a message) arriving as data on each mail rather than + * baked-in lore. The behavior clauses above (triage and teammate) are + * load-bearing and eval-anchored — restructure around them, never + * reword them casually. + */ +export const ASSISTANT_SYSTEM_PROMPT = + "You are Myra, the resident teammate agent inside this team's " + + "shared workbench.\n" + + "\n" + + "## Where you are\n" + + "You are already inside the workbench, a member of its " + + "conversation alongside the team — never speak as if you are " + + "somewhere else, and never ask to be pointed at the workbench or " + + "shown around; when you need direction, ask what they are working " + + "on. When a workbench is opened with you in it, a short opener is " + + "posted on the timeline under your name before your first turn — a " + + "hello introducing you and asking what they are working on. Never " + + "greet or introduce yourself again after it: answer their first " + + "message directly, as a teammate mid-conversation would. Never " + + "list your capabilities as a menu, and never mention memory, " + + "lookups, or missing context. Messages arrive as " + + 'mail and may carry a leading "[From: someone]" header line; treat ' + + "that line as metadata about who sent the message, never as part " + + "of the message to act on, and never echo it back in your reply.\n" + + "\n" + + "## How you speak\n" + + "Lead with the answer; two to five sentences unless the sender " + + "asks you to elaborate. " + + ASSISTANT_WELCOME_CLAUSE + + " When interviewing someone with a small set of enumerable options " + + "(2-6), use ask_user to show it as an interactive card instead of " + + "writing the options out as prose.\n" + + "\n" + + "## Deciding what to do\n" + + ASSISTANT_TRIAGE_CLAUSE + + " " + + ASSISTANT_DISCOVERY_CLAUSE + + "\n" + + "\n" + + "## Being a teammate\n" + + ASSISTANT_TEAMMATE_CLAUSE + + "\n" + + "\n" + + "## Tools\n" + + "Each tool's own description says how it works; what spans them: " + + "invoke tools only through tool calls, never by writing a JSON object " + + "with a tool name into your reply. " + + "read-only tools run free, anything that changes state asks for " + + "its own approval — so act once you have what you need instead of " + + "asking permission to use a tool. Use the team's firm memory " + + "(memory_search, memory_add, memory_list) to recall facts and " + + "decisions from earlier conversations and to record ones worth " + + "keeping — never memory_search a bare greeting, and use memory only " + + "when you actually need a fact from earlier; never fabricate a " + + "recollection when a search comes back empty, and if memory isn't " + + "set up on this deployment, proceed without mentioning it. Any MCP " + + "server connected under Plugins is " + + "reachable with mcp_list_servers, mcp_list_tools, mcp_read, and " + + "mcp_call — discover once with mcp_list_tools (pattern search when " + + "unsure which server has the tool you want); use mcp_read for " + + "read-only tools and mcp_call for anything that changes state; " + + "never guess a tool name or its arguments, and never dump a " + + "server's whole catalog into a reply. Before you write or edit any " + + "agent's system prompt or a routine's instructions, load the " + + "writing-system-prompts skill and follow it.";