diff --git a/.changeset/perstack-error-system.md b/.changeset/perstack-error-system.md new file mode 100644 index 00000000..ec06070d --- /dev/null +++ b/.changeset/perstack-error-system.md @@ -0,0 +1,19 @@ +--- +"@perstack/core": patch +"@perstack/installer": patch +"@perstack/log": patch +"@perstack/perstack-toml": patch +"@perstack/runtime": patch +"@perstack/tui": patch +"@perstack/tui-components": patch +"@perstack/anthropic-provider": patch +"@perstack/azure-openai-provider": patch +"@perstack/google-provider": patch +"@perstack/openai-provider": patch +"perstack": patch +"create-expert": patch +--- + +fix: add PerstackError class for user-facing error handling + +Add `PerstackError` to distinguish user-facing errors from internal bugs. User-facing errors (missing config, invalid input, missing env vars) now show a clean message and exit(1), while unexpected errors crash with a stack trace for debugging. diff --git a/apps/create-expert/bin/cli.ts b/apps/create-expert/bin/cli.ts index cd7460f5..09c7a666 100644 --- a/apps/create-expert/bin/cli.ts +++ b/apps/create-expert/bin/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { readFileSync } from "node:fs" +import { PerstackError } from "@perstack/core" import { findLockfile, loadLockfile, parsePerstackConfig } from "@perstack/perstack-toml" import { startHandler } from "@perstack/tui" import { PROVIDER_ENV_MAP } from "@perstack/tui/provider-config" @@ -31,4 +32,11 @@ new Command() }, }) }) - .parse() + .parseAsync() + .catch((error) => { + if (error instanceof PerstackError) { + console.error(error.message) + process.exit(1) + } + throw error + }) diff --git a/apps/create-expert/package.json b/apps/create-expert/package.json index 22f5c473..a3635298 100644 --- a/apps/create-expert/package.json +++ b/apps/create-expert/package.json @@ -23,6 +23,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@perstack/core": "workspace:*", "@perstack/perstack-toml": "workspace:*", "@perstack/runtime": "workspace:*", "commander": "^14.0.3" diff --git a/apps/perstack/bin/cli.ts b/apps/perstack/bin/cli.ts index 568b0c36..25377e20 100755 --- a/apps/perstack/bin/cli.ts +++ b/apps/perstack/bin/cli.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { PerstackError } from "@perstack/core" import { installHandler } from "@perstack/installer" import { logHandler, parsePositiveInt } from "@perstack/log" import { @@ -154,4 +155,10 @@ program await installHandler({ configPath, perstackConfig, envPath: options.envPath }) }) -program.parse() +program.parseAsync().catch((error) => { + if (error instanceof PerstackError) { + console.error(error.message) + process.exit(1) + } + throw error +}) diff --git a/apps/perstack/package.json b/apps/perstack/package.json index 7ac6b8c3..ab5c304a 100644 --- a/apps/perstack/package.json +++ b/apps/perstack/package.json @@ -23,6 +23,7 @@ "commander": "^14.0.3" }, "devDependencies": { + "@perstack/core": "workspace:*", "@perstack/installer": "workspace:*", "@perstack/log": "workspace:*", "@perstack/perstack-toml": "workspace:*", diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 00000000..1e69e898 --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,6 @@ +export class PerstackError extends Error { + constructor(message: string) { + super(message) + this.name = "PerstackError" + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 83f7d66a..95001d0a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,6 @@ export * from "./adapters/index.js" export * from "./constants/constants.js" +export * from "./errors.js" export * from "./known-models/index.js" export * from "./schemas/activity.js" export * from "./schemas/checkpoint.js" diff --git a/packages/core/src/schemas/runtime.ts b/packages/core/src/schemas/runtime.ts index e2b78f07..901bd11e 100644 --- a/packages/core/src/schemas/runtime.ts +++ b/packages/core/src/schemas/runtime.ts @@ -7,6 +7,7 @@ import { defaultTimeout, expertKeyRegex, } from "../constants/constants.js" +import { PerstackError } from "../errors.js" import type { Checkpoint } from "./checkpoint.js" import { checkpointSchema } from "./checkpoint.js" import type { Expert } from "./expert.js" @@ -36,11 +37,11 @@ export function parseExpertKey(expertKey: string): { } { const match = expertKey.match(expertKeyRegex) if (!match) { - throw new Error(`Invalid expert key format: ${expertKey}`) + throw new PerstackError(`Invalid expert key format: ${expertKey}`) } const [key, name, version, tag] = match if (!name) { - throw new Error(`Invalid expert key format: ${expertKey}`) + throw new PerstackError(`Invalid expert key format: ${expertKey}`) } return { key, name, version, tag } } diff --git a/packages/core/src/utils/event-filter.ts b/packages/core/src/utils/event-filter.ts index de418f17..2beacb83 100644 --- a/packages/core/src/utils/event-filter.ts +++ b/packages/core/src/utils/event-filter.ts @@ -1,3 +1,4 @@ +import { PerstackError } from "../errors.js" import type { PerstackEvent } from "../schemas/runtime.js" import { isValidEventType, isValidRuntimeEventType } from "../schemas/runtime.js" @@ -11,7 +12,7 @@ export function validateEventFilter(filter: string[]): string[] { const invalid = filter.filter((type) => !isValidEventType(type) && !isValidRuntimeEventType(type)) if (invalid.length > 0) { - throw new Error( + throw new PerstackError( `Invalid event type(s): ${invalid.join(", ")}. ` + "Valid event types are: startRun, completeRun, stopRunByError, callTools, etc. " + "See documentation for full list.", diff --git a/packages/core/src/utils/zod-error.ts b/packages/core/src/utils/zod-error.ts index f6b77d0c..ee9ab925 100644 --- a/packages/core/src/utils/zod-error.ts +++ b/packages/core/src/utils/zod-error.ts @@ -1,4 +1,5 @@ import type { ZodError, ZodSchema } from "zod" +import { PerstackError } from "../errors.js" export function formatZodError(error: ZodError): string { const issues = error.issues.map((issue) => { @@ -18,5 +19,5 @@ export function parseWithFriendlyError( return result.data } const prefix = context ? `${context}: ` : "" - throw new Error(`${prefix}${formatZodError(result.error)}`) + throw new PerstackError(`${prefix}${formatZodError(result.error)}`) } diff --git a/packages/installer/src/expert-resolver.ts b/packages/installer/src/expert-resolver.ts index 88adb126..ea2c47c2 100644 --- a/packages/installer/src/expert-resolver.ts +++ b/packages/installer/src/expert-resolver.ts @@ -4,6 +4,7 @@ import { type Expert, expertSchema, type PerstackConfig, + PerstackError, type RuntimeVersion, type Skill, } from "@perstack/core" @@ -103,7 +104,7 @@ export function toRuntimeExpert(key: string, expert: PublishedExpertData): Exper }, ] default: { - throw new Error(`Unknown skill type: ${(skill as { type: string }).type}`) + throw new PerstackError(`Unknown skill type: ${(skill as { type: string }).type}`) } } }), @@ -161,7 +162,7 @@ export async function resolveAllExperts( } const apiKey = env.PERSTACK_API_KEY if (!apiKey) { - throw new Error("PERSTACK_API_KEY is required to resolve remote delegates") + throw new PerstackError("PERSTACK_API_KEY is required to resolve remote delegates") } const client = createApiClient({ baseUrl: config.perstackApiBaseUrl ?? defaultPerstackApiBaseUrl, @@ -174,11 +175,13 @@ export async function resolveAllExperts( if (experts[delegateKey]) continue const result = await client.experts.get(delegateKey) if (!result.ok) { - throw new Error(`Failed to resolve delegate "${delegateKey}": ${result.error.message}`) + throw new PerstackError( + `Failed to resolve delegate "${delegateKey}": ${result.error.message}`, + ) } const publishedExpert = result.data.data.definition.experts[delegateKey] if (!publishedExpert) { - throw new Error(`Expert "${delegateKey}" not found in API response`) + throw new PerstackError(`Expert "${delegateKey}" not found in API response`) } experts[delegateKey] = toRuntimeExpert(delegateKey, publishedExpert) for (const nestedDelegate of publishedExpert.delegates ?? []) { diff --git a/packages/installer/src/handler.ts b/packages/installer/src/handler.ts index 3112fd9f..771db6ab 100644 --- a/packages/installer/src/handler.ts +++ b/packages/installer/src/handler.ts @@ -12,43 +12,34 @@ export async function installHandler(options: { perstackConfig: PerstackConfig envPath?: string[] }): Promise { - try { - const configPath = options.configPath - const config = options.perstackConfig - const envPath = - options.envPath && options.envPath.length > 0 - ? options.envPath - : (config.envPath ?? [".env", ".env.local"]) - const env = getEnv(envPath) - console.log("Resolving experts...") - const experts = await resolveAllExperts(config, env) - console.log(`Found ${Object.keys(experts).length} expert(s)`) - const lockfileExperts: Record = {} - for (const [key, expert] of Object.entries(experts)) { - console.log(`Collecting tool definitions for ${key}...`) - const toolDefinitions = await collectToolDefinitionsForExpert(expert, { - env, - perstackBaseSkillCommand: config.perstackBaseSkillCommand, - }) - console.log(` Found ${toolDefinitions.length} tool(s)`) - lockfileExperts[key] = expertToLockfileExpert(expert, toolDefinitions) - } - const lockfile: Lockfile = { - version: "1", - generatedAt: Date.now(), - configPath: path.basename(configPath), - experts: lockfileExperts, - } - const lockfilePath = path.join(path.dirname(configPath), "perstack.lock") - const lockfileContent = generateLockfileToml(lockfile) - await writeFile(lockfilePath, lockfileContent, "utf-8") - console.log(`Generated ${lockfilePath}`) - } catch (error) { - if (error instanceof Error) { - console.error(`Error: ${error.message}`) - } else { - console.error(error) - } - process.exit(1) + const configPath = options.configPath + const config = options.perstackConfig + const envPath = + options.envPath && options.envPath.length > 0 + ? options.envPath + : (config.envPath ?? [".env", ".env.local"]) + const env = getEnv(envPath) + console.log("Resolving experts...") + const experts = await resolveAllExperts(config, env) + console.log(`Found ${Object.keys(experts).length} expert(s)`) + const lockfileExperts: Record = {} + for (const [key, expert] of Object.entries(experts)) { + console.log(`Collecting tool definitions for ${key}...`) + const toolDefinitions = await collectToolDefinitionsForExpert(expert, { + env, + perstackBaseSkillCommand: config.perstackBaseSkillCommand, + }) + console.log(` Found ${toolDefinitions.length} tool(s)`) + lockfileExperts[key] = expertToLockfileExpert(expert, toolDefinitions) } + const lockfile: Lockfile = { + version: "1", + generatedAt: Date.now(), + configPath: path.basename(configPath), + experts: lockfileExperts, + } + const lockfilePath = path.join(path.dirname(configPath), "perstack.lock") + const lockfileContent = generateLockfileToml(lockfile) + await writeFile(lockfilePath, lockfileContent, "utf-8") + console.log(`Generated ${lockfilePath}`) } diff --git a/packages/log/src/filter.ts b/packages/log/src/filter.ts index a05e9bde..a7399280 100644 --- a/packages/log/src/filter.ts +++ b/packages/log/src/filter.ts @@ -1,4 +1,4 @@ -import type { RunEvent } from "@perstack/core" +import { PerstackError, type RunEvent } from "@perstack/core" import type { FilterCondition, FilterOptions, StepFilter } from "./types.js" export const ERROR_EVENT_TYPES = new Set(["stopRunByError", "retry"]) @@ -16,7 +16,7 @@ export function parseStepFilter(step: string): StepFilter { const min = Number(rangeMatch[1]) const max = Number(rangeMatch[2]) if (min > max) { - throw new Error(`Invalid step range: ${step} (min ${min} > max ${max})`) + throw new PerstackError(`Invalid step range: ${step} (min ${min} > max ${max})`) } return { type: "range", min, max } } @@ -40,19 +40,19 @@ export function parseStepFilter(step: string): StepFilter { if (exactMatch) { return { type: "exact", value: Number(exactMatch[1]) } } - throw new Error(`Invalid step filter: ${step}`) + throw new PerstackError(`Invalid step filter: ${step}`) } export function parseFilterExpression(expression: string): FilterCondition { const trimmed = expression.trim() const operatorMatch = trimmed.match(/^\.([a-zA-Z_][\w.[\]]*)\s*(==|!=|>=|<=|>|<)\s*(.+)$/) if (!operatorMatch) { - throw new Error(`Invalid filter expression: ${expression}`) + throw new PerstackError(`Invalid filter expression: ${expression}`) } const [, fieldPath, operator, rawValue] = operatorMatch const trimmedValue = rawValue.trim() if (trimmedValue === "") { - throw new Error(`Missing value in filter expression: ${expression}`) + throw new PerstackError(`Missing value in filter expression: ${expression}`) } const field = parseFieldPath(fieldPath) const value = parseValue(trimmedValue) diff --git a/packages/log/src/handler.ts b/packages/log/src/handler.ts index b0b536c8..dce9a8b6 100644 --- a/packages/log/src/handler.ts +++ b/packages/log/src/handler.ts @@ -1,3 +1,4 @@ +import { PerstackError } from "@perstack/core" import { applyFilters, createLogDataFetcher, @@ -19,38 +20,29 @@ const DEFAULT_OFFSET = 0 export function parsePositiveInt(val: string, optionName: string): number { const parsed = parseInt(val, 10) if (Number.isNaN(parsed)) { - throw new Error(`Invalid value for ${optionName}: "${val}" is not a valid number`) + throw new PerstackError(`Invalid value for ${optionName}: "${val}" is not a valid number`) } if (parsed < 0) { - throw new Error(`Invalid value for ${optionName}: "${val}" must be non-negative`) + throw new PerstackError(`Invalid value for ${optionName}: "${val}" must be non-negative`) } return parsed } export async function logHandler(options: LogCommandOptions): Promise { - try { - const storagePath = process.env.PERSTACK_STORAGE_PATH ?? `${process.cwd()}/perstack` - const adapter = createStorageAdapter(storagePath) - const fetcher = createLogDataFetcher(adapter) - const filterOptions = buildFilterOptions(options) - const formatterOptions = buildFormatterOptions(options) - const output = await buildOutput(fetcher, options, filterOptions, storagePath) - if (!output) { - console.log("No data found") - return - } - const formatted = formatterOptions.json - ? formatJson(output, formatterOptions) - : formatTerminal(output, formatterOptions) - console.log(formatted) - } catch (error) { - if (error instanceof Error) { - console.error(`Error: ${error.message}`) - } else { - console.error("An unexpected error occurred") - } - process.exit(1) - } + const storagePath = process.env.PERSTACK_STORAGE_PATH ?? `${process.cwd()}/perstack` + const adapter = createStorageAdapter(storagePath) + const fetcher = createLogDataFetcher(adapter) + const filterOptions = buildFilterOptions(options) + const formatterOptions = buildFormatterOptions(options) + const output = await buildOutput(fetcher, options, filterOptions, storagePath) + if (!output) { + console.log("No data found") + return + } + const formatted = formatterOptions.json + ? formatJson(output, formatterOptions) + : formatTerminal(output, formatterOptions) + console.log(formatted) } function buildFilterOptions(options: LogCommandOptions): FilterOptions { diff --git a/packages/perstack-toml/src/config.ts b/packages/perstack-toml/src/config.ts index 7e89e911..50fa2782 100644 --- a/packages/perstack-toml/src/config.ts +++ b/packages/perstack-toml/src/config.ts @@ -1,6 +1,11 @@ import { readFile } from "node:fs/promises" import path from "node:path" -import { type PerstackConfig, parseWithFriendlyError, perstackConfigSchema } from "@perstack/core" +import { + type PerstackConfig, + PerstackError, + parseWithFriendlyError, + perstackConfigSchema, +} from "@perstack/core" import TOML from "smol-toml" import { isRemoteUrl } from "./utils.js" @@ -9,7 +14,7 @@ const ALLOWED_CONFIG_HOSTS = ["raw.githubusercontent.com"] export async function getPerstackConfig(configPath?: string): Promise { const configString = await findPerstackConfigString(configPath) if (configString === null) { - throw new Error("perstack.toml not found. Create one or specify --config path.") + throw new PerstackError("perstack.toml not found. Create one or specify --config path.") } return parsePerstackConfig(configString) } @@ -24,13 +29,13 @@ async function fetchRemoteConfig(url: string): Promise { try { parsed = new URL(url) } catch { - throw new Error(`Invalid remote config URL: ${url}`) + throw new PerstackError(`Invalid remote config URL: ${url}`) } if (parsed.protocol !== "https:") { - throw new Error("Remote config requires HTTPS") + throw new PerstackError("Remote config requires HTTPS") } if (!ALLOWED_CONFIG_HOSTS.includes(parsed.hostname)) { - throw new Error(`Remote config only allowed from: ${ALLOWED_CONFIG_HOSTS.join(", ")}`) + throw new PerstackError(`Remote config only allowed from: ${ALLOWED_CONFIG_HOSTS.join(", ")}`) } try { const response = await fetch(url, { redirect: "error" }) @@ -40,7 +45,7 @@ async function fetchRemoteConfig(url: string): Promise { return await response.text() } catch (error) { const message = error instanceof Error ? error.message : String(error) - throw new Error(`Failed to fetch remote config: ${message}`) + throw new PerstackError(`Failed to fetch remote config: ${message}`) } } @@ -53,7 +58,7 @@ async function findPerstackConfigString(configPath?: string): Promise { return configPath } catch { if (cwd === path.parse(cwd).root) { - throw new Error("perstack.toml not found. Create one or specify --config path.") + throw new PerstackError("perstack.toml not found. Create one or specify --config path.") } return await findConfigPathRecursively(path.dirname(cwd)) } diff --git a/packages/providers/anthropic/src/skills.ts b/packages/providers/anthropic/src/skills.ts index e753651e..57623ce7 100644 --- a/packages/providers/anthropic/src/skills.ts +++ b/packages/providers/anthropic/src/skills.ts @@ -1,3 +1,4 @@ +import { PerstackError } from "@perstack/core" import type { AnthropicProviderSkill, ProviderOptions } from "@perstack/provider-core" import type { JSONValue } from "ai" @@ -34,7 +35,7 @@ function convertSkill(skill: AnthropicProviderSkill): AnthropicSkillConfig { mcp_config: parsed, } } catch (error) { - throw new Error( + throw new PerstackError( `Invalid JSON in custom skill definition for "${skill.name}": ${error instanceof Error ? error.message : String(error)}`, ) } diff --git a/packages/providers/azure-openai/src/tools.ts b/packages/providers/azure-openai/src/tools.ts index d01b3d5a..a8c52ac0 100644 --- a/packages/providers/azure-openai/src/tools.ts +++ b/packages/providers/azure-openai/src/tools.ts @@ -1,4 +1,5 @@ import type { createAzure } from "@ai-sdk/azure" +import { PerstackError } from "@perstack/core" import type { ProviderToolOptions } from "@perstack/provider-core" import type { ToolSet } from "ai" @@ -20,7 +21,7 @@ export function buildAzureOpenAITools( case "fileSearch": { const vectorStoreIds = options?.fileSearch?.vectorStoreIds if (!vectorStoreIds || vectorStoreIds.length === 0) { - throw new Error( + throw new PerstackError( "Azure OpenAI fileSearch tool requires vectorStoreIds. " + "Set providerToolOptions.fileSearch.vectorStoreIds to use this tool.", ) diff --git a/packages/providers/google/src/tools.ts b/packages/providers/google/src/tools.ts index f5ed67ed..57a00573 100644 --- a/packages/providers/google/src/tools.ts +++ b/packages/providers/google/src/tools.ts @@ -1,4 +1,5 @@ import type { createGoogleGenerativeAI } from "@ai-sdk/google" +import { PerstackError } from "@perstack/core" import type { ProviderToolOptions } from "@perstack/provider-core" import type { ToolSet } from "ai" @@ -30,7 +31,7 @@ export function buildGoogleTools( case "fileSearch": { const storeNames = options?.fileSearch?.vectorStoreIds if (!storeNames || storeNames.length === 0) { - throw new Error( + throw new PerstackError( "Google fileSearch tool requires fileSearchStoreNames. " + "Set providerToolOptions.fileSearch.vectorStoreIds to use this tool.", ) diff --git a/packages/providers/openai/src/tools.ts b/packages/providers/openai/src/tools.ts index 919e04c1..ea2d4d85 100644 --- a/packages/providers/openai/src/tools.ts +++ b/packages/providers/openai/src/tools.ts @@ -1,4 +1,5 @@ import type { createOpenAI } from "@ai-sdk/openai" +import { PerstackError } from "@perstack/core" import type { ProviderToolOptions } from "@perstack/provider-core" import type { ToolSet } from "ai" @@ -20,7 +21,7 @@ export function buildOpenAITools( case "fileSearch": { const vectorStoreIds = options?.fileSearch?.vectorStoreIds if (!vectorStoreIds || vectorStoreIds.length === 0) { - throw new Error( + throw new PerstackError( "OpenAI fileSearch tool requires vectorStoreIds. " + "Set providerToolOptions.fileSearch.vectorStoreIds to use this tool.", ) diff --git a/packages/runtime/src/helpers/provider-adapter-factory.test.ts b/packages/runtime/src/helpers/provider-adapter-factory.test.ts index fdb0fec9..600ea410 100644 --- a/packages/runtime/src/helpers/provider-adapter-factory.test.ts +++ b/packages/runtime/src/helpers/provider-adapter-factory.test.ts @@ -1,51 +1,24 @@ -import type { AnthropicProviderConfig } from "@perstack/core" +import { type AnthropicProviderConfig, PerstackError } from "@perstack/core" import { beforeEach, describe, expect, it } from "vitest" -import { - clearProviderAdapterCache, - createProviderAdapter, - ProviderNotInstalledError, -} from "./provider-adapter-factory.js" +import { clearProviderAdapterCache, createProviderAdapter } from "./provider-adapter-factory.js" describe("Provider Adapter Factory", () => { beforeEach(() => { clearProviderAdapterCache() }) - describe("ProviderNotInstalledError", () => { - it("creates error with correct message for standard provider", () => { - const error = new ProviderNotInstalledError("anthropic") - expect(error.message).toBe( - 'Provider "anthropic" is not installed. Run: npm install @perstack/anthropic-provider', - ) - expect(error.name).toBe("ProviderNotInstalledError") - }) - - it("creates error with correct message for amazon-bedrock", () => { - const error = new ProviderNotInstalledError("amazon-bedrock") - expect(error.message).toBe( - 'Provider "amazon-bedrock" is not installed. Run: npm install @perstack/bedrock-provider', - ) - }) - - it("creates error with correct message for google-vertex", () => { - const error = new ProviderNotInstalledError("google-vertex") - expect(error.message).toBe( - 'Provider "google-vertex" is not installed. Run: npm install @perstack/vertex-provider', - ) - }) - }) - describe("createProviderAdapter", () => { - it("throws ProviderNotInstalledError for unregistered provider", async () => { + it("throws PerstackError for unregistered provider", async () => { clearProviderAdapterCache() const config: AnthropicProviderConfig = { providerName: "anthropic", apiKey: "test-key", } - // Clear all registrations by creating fresh state - // Note: This test verifies error handling for unregistered providers - await expect(createProviderAdapter(config)).rejects.toThrow(ProviderNotInstalledError) + await expect(createProviderAdapter(config)).rejects.toThrow(PerstackError) + await expect(createProviderAdapter(config)).rejects.toThrow( + 'Provider "anthropic" is not installed. Run: npm install @perstack/anthropic-provider', + ) }) }) diff --git a/packages/runtime/src/helpers/provider-adapter-factory.ts b/packages/runtime/src/helpers/provider-adapter-factory.ts index 97da12f3..9a33948d 100644 --- a/packages/runtime/src/helpers/provider-adapter-factory.ts +++ b/packages/runtime/src/helpers/provider-adapter-factory.ts @@ -1,4 +1,4 @@ -import type { ProviderConfig, ProviderName } from "@perstack/core" +import { PerstackError, type ProviderConfig, type ProviderName } from "@perstack/core" import type { ProviderAdapter, ProviderAdapterOptions } from "@perstack/provider-core" interface AdapterConstructor { @@ -18,16 +18,6 @@ const PROVIDER_PACKAGE_NAMES: Record = { deepseek: "deepseek-provider", } -export class ProviderNotInstalledError extends Error { - constructor(providerName: ProviderName) { - const packageName = PROVIDER_PACKAGE_NAMES[providerName] - super( - `Provider "${providerName}" is not installed. ` + `Run: npm install @perstack/${packageName}`, - ) - this.name = "ProviderNotInstalledError" - } -} - // Module-level state for provider adapter factory const adapterRegistry = new Map() const adapterInstances = new Map() @@ -62,7 +52,10 @@ export async function createProviderAdapter( const loader = adapterRegistry.get(config.providerName) if (!loader) { - throw new ProviderNotInstalledError(config.providerName) + const packageName = PROVIDER_PACKAGE_NAMES[config.providerName] + throw new PerstackError( + `Provider "${config.providerName}" is not installed. Run: npm install @perstack/${packageName}`, + ) } // Create adapter and track the pending promise diff --git a/packages/runtime/src/helpers/resolve-expert.ts b/packages/runtime/src/helpers/resolve-expert.ts index 394823c8..b9560f82 100644 --- a/packages/runtime/src/helpers/resolve-expert.ts +++ b/packages/runtime/src/helpers/resolve-expert.ts @@ -1,5 +1,5 @@ import { createApiClient } from "@perstack/api-client" -import type { Expert, RuntimeVersion, Skill } from "@perstack/core" +import { type Expert, PerstackError, type RuntimeVersion, type Skill } from "@perstack/core" export async function resolveExpertToRun( expertKey: string, @@ -13,7 +13,9 @@ export async function resolveExpertToRun( return experts[expertKey] } if (!clientOptions.perstackApiKey) { - throw new Error(`PERSTACK_API_KEY is required to resolve published expert "${expertKey}"`) + throw new PerstackError( + `PERSTACK_API_KEY is required to resolve published expert "${expertKey}"`, + ) } const client = createApiClient({ baseUrl: clientOptions.perstackApiBaseUrl, @@ -21,11 +23,11 @@ export async function resolveExpertToRun( }) const result = await client.experts.get(expertKey) if (!result.ok) { - throw new Error(`Failed to resolve expert "${expertKey}": ${result.error.message}`) + throw new PerstackError(`Failed to resolve expert "${expertKey}": ${result.error.message}`) } const publishedExpert = result.data.data.definition.experts[expertKey] if (!publishedExpert) { - throw new Error(`Expert "${expertKey}" not found in API response`) + throw new PerstackError(`Expert "${expertKey}" not found in API response`) } return toRuntimeExpert(expertKey, publishedExpert) } @@ -126,7 +128,7 @@ function toRuntimeExpert( }, ] default: { - throw new Error(`Unknown skill type: ${(skill as { type: string }).type}`) + throw new PerstackError(`Unknown skill type: ${(skill as { type: string }).type}`) } } }), diff --git a/packages/runtime/src/helpers/runtime-version.ts b/packages/runtime/src/helpers/runtime-version.ts index a33ef30d..a37f3993 100644 --- a/packages/runtime/src/helpers/runtime-version.ts +++ b/packages/runtime/src/helpers/runtime-version.ts @@ -1,4 +1,4 @@ -import type { Expert, RuntimeVersion } from "@perstack/core" +import { type Expert, PerstackError, type RuntimeVersion } from "@perstack/core" import pkg from "../../package.json" with { type: "json" } export function parseRuntimeVersion( @@ -44,7 +44,7 @@ export function validateRuntimeVersion(experts: Record): void { const maxMinVersion = getMaxMinRuntimeVersion(experts) if (!maxMinVersion) return if (compareRuntimeVersions(maxMinVersion, currentVersion) > 0) { - throw new Error( + throw new PerstackError( `Runtime version ${currentVersion} does not meet minimum requirement ${maxMinVersion}`, ) } diff --git a/packages/runtime/src/helpers/setup-experts.ts b/packages/runtime/src/helpers/setup-experts.ts index 6ae29032..cf56220e 100644 --- a/packages/runtime/src/helpers/setup-experts.ts +++ b/packages/runtime/src/helpers/setup-experts.ts @@ -1,4 +1,4 @@ -import type { Expert, RunSetting } from "@perstack/core" +import { type Expert, PerstackError, type RunSetting } from "@perstack/core" export type ResolveExpertToRunFn = ( expertKey: string, @@ -28,7 +28,7 @@ export async function setupExperts( visited.add(key) const expert = await resolveFn(key, experts, clientOptions) if (!expert) { - throw new Error(`Delegate ${key} not found`) + throw new PerstackError(`Delegate ${key} not found`) } experts[key] = expert for (const delegateKey of expert.delegates) { diff --git a/packages/runtime/src/skill-manager/command-args.ts b/packages/runtime/src/skill-manager/command-args.ts index d5180193..79143778 100644 --- a/packages/runtime/src/skill-manager/command-args.ts +++ b/packages/runtime/src/skill-manager/command-args.ts @@ -1,4 +1,4 @@ -import type { McpStdioSkill } from "@perstack/core" +import { type McpStdioSkill, PerstackError } from "@perstack/core" export interface CommandArgs { command: string @@ -14,11 +14,13 @@ export function getCommandArgs(skill: McpStdioSkill): CommandArgs { const { name, command, packageName, args } = skill if (!packageName && (!args || args.length === 0)) { - throw new Error(`Skill ${name} has no packageName or args. Please provide one of them.`) + throw new PerstackError(`Skill ${name} has no packageName or args. Please provide one of them.`) } if (packageName && args && args.length > 0) { - throw new Error(`Skill ${name} has both packageName and args. Please provide only one of them.`) + throw new PerstackError( + `Skill ${name} has both packageName and args. Please provide only one of them.`, + ) } let newArgs = args && args.length > 0 ? args : [packageName!] diff --git a/packages/runtime/src/skill-manager/mcp.ts b/packages/runtime/src/skill-manager/mcp.ts index 8dadb3b8..1a451f0c 100644 --- a/packages/runtime/src/skill-manager/mcp.ts +++ b/packages/runtime/src/skill-manager/mcp.ts @@ -8,6 +8,7 @@ import { type ImageInlinePart, type McpSseSkill, type McpStdioSkill, + PerstackError, type RunEvent, type RuntimeEvent, type SkillType, @@ -95,12 +96,12 @@ export class McpSkillManager extends BaseSkillManager { private async _initStdio(skill: McpStdioSkill): Promise { if (!skill.command) { - throw new Error(`Skill ${skill.name} has no command`) + throw new PerstackError(`Skill ${skill.name} has no command`) } const requiredEnv: Record = {} for (const envName of skill.requiredEnv) { if (!this._env[envName]) { - throw new Error(`Skill ${skill.name} requires environment variable ${envName}`) + throw new PerstackError(`Skill ${skill.name} requires environment variable ${envName}`) } requiredEnv[envName] = this._env[envName] } @@ -144,14 +145,14 @@ export class McpSkillManager extends BaseSkillManager { private async _initSse(skill: McpSseSkill): Promise { if (!skill.endpoint) { - throw new Error(`Skill ${skill.name} has no endpoint`) + throw new PerstackError(`Skill ${skill.name} has no endpoint`) } const url = new URL(skill.endpoint) if (url.protocol !== "https:") { - throw new Error(`Skill ${skill.name} SSE endpoint must use HTTPS: ${skill.endpoint}`) + throw new PerstackError(`Skill ${skill.name} SSE endpoint must use HTTPS: ${skill.endpoint}`) } if (isPrivateOrLocalIP(url.hostname)) { - throw new Error( + throw new PerstackError( `Skill ${skill.name} SSE endpoint cannot use private/local IP: ${skill.endpoint}`, ) } diff --git a/packages/tui-components/src/execution/render.tsx b/packages/tui-components/src/execution/render.tsx index 6a4c5f14..cba49713 100644 --- a/packages/tui-components/src/execution/render.tsx +++ b/packages/tui-components/src/execution/render.tsx @@ -1,4 +1,4 @@ -import type { PerstackEvent } from "@perstack/core" +import { PerstackError, type PerstackEvent } from "@perstack/core" import { render } from "ink" import { EventQueue } from "../utils/event-queue.js" import { ExecutionApp } from "./app.js" @@ -36,7 +36,7 @@ export function renderExecution(params: ExecutionParams): RenderExecutionResult waitUntilExit() .then(() => { if (!resolved) { - reject(new Error("Execution cancelled")) + reject(new PerstackError("Execution cancelled")) } }) .catch(reject) diff --git a/packages/tui-components/src/selection/render.tsx b/packages/tui-components/src/selection/render.tsx index ec95c9a9..f9f7cb9f 100644 --- a/packages/tui-components/src/selection/render.tsx +++ b/packages/tui-components/src/selection/render.tsx @@ -1,3 +1,4 @@ +import { PerstackError } from "@perstack/core" import { render } from "ink" import { SelectionApp } from "./app.js" import type { SelectionParams, SelectionResult } from "./types.js" @@ -23,7 +24,7 @@ export async function renderSelection(params: SelectionParams): Promise { if (!resolved) { - reject(new Error("Selection cancelled")) + reject(new PerstackError("Selection cancelled")) } }) .catch(reject) diff --git a/packages/tui/src/lib/context.ts b/packages/tui/src/lib/context.ts index 8e1c21e3..37161d0d 100644 --- a/packages/tui/src/lib/context.ts +++ b/packages/tui/src/lib/context.ts @@ -1,4 +1,10 @@ -import type { Checkpoint, PerstackConfig, ProviderConfig, ProviderName } from "@perstack/core" +import { + type Checkpoint, + type PerstackConfig, + PerstackError, + type ProviderConfig, + type ProviderName, +} from "@perstack/core" import { getEnv } from "./get-env.js" import { getProviderConfig } from "./provider-config.js" import { getCheckpointById, getMostRecentCheckpoint } from "./run-manager.js" @@ -33,7 +39,7 @@ export async function resolveRunContext(input: ResolveRunContextInput): Promise< let checkpoint: Checkpoint | undefined if (input.resumeFrom) { if (!input.continueJob) { - throw new Error("--resume-from requires --continue-job") + throw new PerstackError("--resume-from requires --continue-job") } checkpoint = getCheckpointById(input.continueJob, input.resumeFrom) } else if (input.continueJob) { @@ -42,11 +48,11 @@ export async function resolveRunContext(input: ResolveRunContextInput): Promise< checkpoint = getMostRecentCheckpoint() } if ((input.continue || input.continueJob || input.resumeFrom) && !checkpoint) { - throw new Error("No checkpoint found") + throw new PerstackError("No checkpoint found") } if (checkpoint && input.expertKey && checkpoint.expert.key !== input.expertKey) { - throw new Error( + throw new PerstackError( `Checkpoint expert key ${checkpoint.expert.key} does not match input expert key ${input.expertKey}`, ) } diff --git a/packages/tui/src/lib/provider-config.ts b/packages/tui/src/lib/provider-config.ts index a9edd541..2a3a3c98 100644 --- a/packages/tui/src/lib/provider-config.ts +++ b/packages/tui/src/lib/provider-config.ts @@ -1,4 +1,9 @@ -import type { ProviderConfig, ProviderName, ProviderTable } from "@perstack/core" +import { + PerstackError, + type ProviderConfig, + type ProviderName, + type ProviderTable, +} from "@perstack/core" export const PROVIDER_ENV_MAP: Record = { anthropic: "ANTHROPIC_API_KEY", @@ -22,7 +27,7 @@ export function getProviderConfig( switch (provider) { case "anthropic": { const apiKey = env.ANTHROPIC_API_KEY - if (!apiKey) throw new Error("ANTHROPIC_API_KEY is not set") + if (!apiKey) throw new PerstackError("ANTHROPIC_API_KEY is not set") return { providerName: "anthropic", apiKey, @@ -32,7 +37,7 @@ export function getProviderConfig( } case "google": { const apiKey = env.GOOGLE_GENERATIVE_AI_API_KEY - if (!apiKey) throw new Error("GOOGLE_GENERATIVE_AI_API_KEY is not set") + if (!apiKey) throw new PerstackError("GOOGLE_GENERATIVE_AI_API_KEY is not set") return { providerName: "google", apiKey, @@ -42,7 +47,7 @@ export function getProviderConfig( } case "openai": { const apiKey = env.OPENAI_API_KEY - if (!apiKey) throw new Error("OPENAI_API_KEY is not set") + if (!apiKey) throw new PerstackError("OPENAI_API_KEY is not set") return { providerName: "openai", apiKey, @@ -62,10 +67,11 @@ export function getProviderConfig( } case "azure-openai": { const apiKey = env.AZURE_API_KEY - if (!apiKey) throw new Error("AZURE_API_KEY is not set") + if (!apiKey) throw new PerstackError("AZURE_API_KEY is not set") const resourceName = (setting.resourceName as string | undefined) ?? env.AZURE_RESOURCE_NAME const baseUrl = (setting.baseUrl as string | undefined) ?? env.AZURE_BASE_URL - if (!resourceName && !baseUrl) throw new Error("AZURE_RESOURCE_NAME or baseUrl is not set") + if (!resourceName && !baseUrl) + throw new PerstackError("AZURE_RESOURCE_NAME or baseUrl is not set") return { providerName: "azure-openai", apiKey, @@ -80,10 +86,10 @@ export function getProviderConfig( const accessKeyId = env.AWS_ACCESS_KEY_ID const secretAccessKey = env.AWS_SECRET_ACCESS_KEY const sessionToken = env.AWS_SESSION_TOKEN - if (!accessKeyId) throw new Error("AWS_ACCESS_KEY_ID is not set") - if (!secretAccessKey) throw new Error("AWS_SECRET_ACCESS_KEY is not set") + if (!accessKeyId) throw new PerstackError("AWS_ACCESS_KEY_ID is not set") + if (!secretAccessKey) throw new PerstackError("AWS_SECRET_ACCESS_KEY is not set") const region = (setting.region as string | undefined) ?? env.AWS_REGION - if (!region) throw new Error("AWS_REGION is not set") + if (!region) throw new PerstackError("AWS_REGION is not set") return { providerName: "amazon-bedrock", accessKeyId, @@ -103,7 +109,7 @@ export function getProviderConfig( } case "deepseek": { const apiKey = env.DEEPSEEK_API_KEY - if (!apiKey) throw new Error("DEEPSEEK_API_KEY is not set") + if (!apiKey) throw new PerstackError("DEEPSEEK_API_KEY is not set") return { providerName: "deepseek", apiKey, diff --git a/packages/tui/src/lib/run-manager.ts b/packages/tui/src/lib/run-manager.ts index b2a14252..3d420493 100644 --- a/packages/tui/src/lib/run-manager.ts +++ b/packages/tui/src/lib/run-manager.ts @@ -1,6 +1,12 @@ import { existsSync, readFileSync } from "node:fs" -import type { Checkpoint, Job, RunEvent, RunSetting } from "@perstack/core" -import { checkpointSchema } from "@perstack/core" +import { + type Checkpoint, + checkpointSchema, + type Job, + PerstackError, + type RunEvent, + type RunSetting, +} from "@perstack/core" import { getCheckpointPath, getRunIdsByJobId, @@ -21,7 +27,7 @@ export function getAllRuns(): RunSetting[] { export function getMostRecentRun(): RunSetting { const runs = getAllRuns() if (runs.length === 0) { - throw new Error("No runs found") + throw new PerstackError("No runs found") } return runs[0] } @@ -34,7 +40,7 @@ export function getMostRecentCheckpoint(jobId?: string): Checkpoint { const targetJobId = jobId ?? getMostRecentRun().jobId const checkpoints = getCheckpointsByJobId(targetJobId) if (checkpoints.length === 0) { - throw new Error(`No checkpoints found for job ${targetJobId}`) + throw new PerstackError(`No checkpoints found for job ${targetJobId}`) } return checkpoints[checkpoints.length - 1] } @@ -62,7 +68,7 @@ export function getRecentExperts( export function getCheckpointById(jobId: string, checkpointId: string): Checkpoint { const checkpointPath = getCheckpointPath(jobId, checkpointId) if (!existsSync(checkpointPath)) { - throw new Error(`Checkpoint ${checkpointId} not found in job ${jobId}`) + throw new PerstackError(`Checkpoint ${checkpointId} not found in job ${jobId}`) } const checkpoint = readFileSync(checkpointPath, "utf-8") return checkpointSchema.parse(JSON.parse(checkpoint)) diff --git a/packages/tui/src/run-handler.ts b/packages/tui/src/run-handler.ts index e61c3cc0..de8d57ca 100644 --- a/packages/tui/src/run-handler.ts +++ b/packages/tui/src/run-handler.ts @@ -39,84 +39,66 @@ export async function runHandler( // Validate and apply event filter if specified let eventListener = defaultEventListener if (input.options.filter && input.options.filter.length > 0) { - try { - const validatedTypes = validateEventFilter(input.options.filter) - const allowedTypes = new Set(validatedTypes) - eventListener = createFilteredEventListener(defaultEventListener, allowedTypes) - } catch (error) { - if (error instanceof Error) { - console.error(error.message) - } else { - console.error(error) - } - process.exit(1) - } + const validatedTypes = validateEventFilter(input.options.filter) + const allowedTypes = new Set(validatedTypes) + eventListener = createFilteredEventListener(defaultEventListener, allowedTypes) } - try { - const { perstackConfig, checkpoint, env, providerConfig, model, experts } = - await resolveRunContext({ - perstackConfig: handlerOptions.perstackConfig, - provider: input.options.provider, - model: input.options.model, - envPath: input.options.envPath, - continue: input.options.continue, - continueJob: input.options.continueJob, - resumeFrom: input.options.resumeFrom, - expertKey: input.expertKey, - }) + const { perstackConfig, checkpoint, env, providerConfig, model, experts } = + await resolveRunContext({ + perstackConfig: handlerOptions.perstackConfig, + provider: input.options.provider, + model: input.options.model, + envPath: input.options.envPath, + continue: input.options.continue, + continueJob: input.options.continueJob, + resumeFrom: input.options.resumeFrom, + expertKey: input.expertKey, + }) - const lockfile = handlerOptions.lockfile + const lockfile = handlerOptions.lockfile - // Generate job and run IDs - const jobId = checkpoint?.jobId ?? input.options.jobId ?? createId() - const runId = createId() + // Generate job and run IDs + const jobId = checkpoint?.jobId ?? input.options.jobId ?? createId() + const runId = createId() - await perstackRun( - { - setting: { - jobId, - runId, - expertKey: input.expertKey, - input: input.options.interactiveToolCallResult - ? (parseInteractiveToolCallResultJson(input.query) ?? - (checkpoint - ? parseInteractiveToolCallResult(input.query, checkpoint) - : { text: input.query })) - : { text: input.query }, - experts, - model, - providerConfig, - reasoningBudget: input.options.reasoningBudget ?? perstackConfig.reasoningBudget, - maxSteps: input.options.maxSteps ?? perstackConfig.maxSteps, - maxRetries: input.options.maxRetries ?? perstackConfig.maxRetries, - timeout: input.options.timeout ?? perstackConfig.timeout, - perstackApiBaseUrl: perstackConfig.perstackApiBaseUrl, - perstackApiKey: env.PERSTACK_API_KEY, - perstackBaseSkillCommand: perstackConfig.perstackBaseSkillCommand, - env, - proxyUrl: process.env.PERSTACK_PROXY_URL, - verbose: input.options.verbose, - }, - checkpoint, - }, - { - eventListener, - storeCheckpoint: defaultStoreCheckpoint, - storeEvent: defaultStoreEvent, - retrieveCheckpoint: defaultRetrieveCheckpoint, - storeJob, - retrieveJob, - createJob: createInitialJob, - lockfile, + await perstackRun( + { + setting: { + jobId, + runId, + expertKey: input.expertKey, + input: input.options.interactiveToolCallResult + ? (parseInteractiveToolCallResultJson(input.query) ?? + (checkpoint + ? parseInteractiveToolCallResult(input.query, checkpoint) + : { text: input.query })) + : { text: input.query }, + experts, + model, + providerConfig, + reasoningBudget: input.options.reasoningBudget ?? perstackConfig.reasoningBudget, + maxSteps: input.options.maxSteps ?? perstackConfig.maxSteps, + maxRetries: input.options.maxRetries ?? perstackConfig.maxRetries, + timeout: input.options.timeout ?? perstackConfig.timeout, + perstackApiBaseUrl: perstackConfig.perstackApiBaseUrl, + perstackApiKey: env.PERSTACK_API_KEY, + perstackBaseSkillCommand: perstackConfig.perstackBaseSkillCommand, + env, + proxyUrl: process.env.PERSTACK_PROXY_URL, + verbose: input.options.verbose, }, - ) - } catch (error) { - if (error instanceof Error) { - console.error(error.message) - } else { - console.error(error) - } - process.exit(1) - } + checkpoint, + }, + { + eventListener, + storeCheckpoint: defaultStoreCheckpoint, + storeEvent: defaultStoreEvent, + retrieveCheckpoint: defaultRetrieveCheckpoint, + storeJob, + retrieveJob, + createJob: createInitialJob, + lockfile, + }, + ) } diff --git a/packages/tui/src/start-handler.ts b/packages/tui/src/start-handler.ts index 3d14dc18..6be45ea1 100644 --- a/packages/tui/src/start-handler.ts +++ b/packages/tui/src/start-handler.ts @@ -49,201 +49,192 @@ export async function startHandler( ): Promise { const input = parseWithFriendlyError(startCommandInputSchema, { expertKey, query, options }) - try { - // Phase 1: Initialize context - const { perstackConfig, checkpoint, env, providerConfig, model, experts } = - await resolveRunContext({ - perstackConfig: handlerOptions.perstackConfig, - provider: input.options.provider, - model: input.options.model, - envPath: input.options.envPath, - continue: input.options.continue, - continueJob: input.options.continueJob, - resumeFrom: input.options.resumeFrom, - expertKey: input.expertKey, - }) - - if (handlerOptions?.additionalEnv) { - Object.assign(env, handlerOptions.additionalEnv(env)) - } - - const maxSteps = input.options.maxSteps ?? perstackConfig.maxSteps - const maxRetries = input.options.maxRetries ?? perstackConfig.maxRetries ?? defaultMaxRetries - const timeout = input.options.timeout ?? perstackConfig.timeout ?? defaultTimeout - - // Prepare expert lists - const configuredExperts = Object.keys(perstackConfig.experts ?? {}).map((key) => ({ - key, - name: key, - })) - const recentExperts = getRecentExperts(10) - - // Prepare history jobs (only if browsing is needed) - const showHistory = !input.expertKey && !input.query && !checkpoint - const historyJobs: JobHistoryItem[] = showHistory - ? getAllJobs().map((j) => ({ - jobId: j.id, - status: j.status, - expertKey: j.coordinatorExpertKey, - totalSteps: j.totalSteps, - startedAt: j.startedAt, - finishedAt: j.finishedAt, - })) - : [] - - // Phase 2: Selection - get expert, query, and optional checkpoint - const selection = await renderSelection({ - showHistory, - initialExpertKey: input.expertKey, - initialQuery: input.query, - initialCheckpoint: checkpoint - ? { - id: checkpoint.id, - jobId: checkpoint.jobId, - runId: checkpoint.runId, - stepNumber: checkpoint.stepNumber, - contextWindowUsage: checkpoint.contextWindowUsage ?? 0, - } - : undefined, - configuredExperts, - recentExperts, - historyJobs, - onLoadCheckpoints: async (j: JobHistoryItem): Promise => { - const checkpoints = getCheckpointsWithDetails(j.jobId) - return checkpoints.map((cp) => ({ ...cp, jobId: j.jobId })) - }, + // Phase 1: Initialize context + const { perstackConfig, checkpoint, env, providerConfig, model, experts } = + await resolveRunContext({ + perstackConfig: handlerOptions.perstackConfig, + provider: input.options.provider, + model: input.options.model, + envPath: input.options.envPath, + continue: input.options.continue, + continueJob: input.options.continueJob, + resumeFrom: input.options.resumeFrom, + expertKey: input.expertKey, }) - // Validate selection - if (!selection.expertKey) { - console.error("Expert key is required") - return - } - if (!selection.query && !selection.checkpoint) { - console.error("Query is required") - return - } + if (handlerOptions?.additionalEnv) { + Object.assign(env, handlerOptions.additionalEnv(env)) + } - // Resolve checkpoint if selected from TUI - let currentCheckpoint = selection.checkpoint - ? getCheckpointById(selection.checkpoint.jobId, selection.checkpoint.id) - : checkpoint + const maxSteps = input.options.maxSteps ?? perstackConfig.maxSteps + const maxRetries = input.options.maxRetries ?? perstackConfig.maxRetries ?? defaultMaxRetries + const timeout = input.options.timeout ?? perstackConfig.timeout ?? defaultTimeout + + // Prepare expert lists + const configuredExperts = Object.keys(perstackConfig.experts ?? {}).map((key) => ({ + key, + name: key, + })) + const recentExperts = getRecentExperts(10) + + // Prepare history jobs (only if browsing is needed) + const showHistory = !input.expertKey && !input.query && !checkpoint + const historyJobs: JobHistoryItem[] = showHistory + ? getAllJobs().map((j) => ({ + jobId: j.id, + status: j.status, + expertKey: j.coordinatorExpertKey, + totalSteps: j.totalSteps, + startedAt: j.startedAt, + finishedAt: j.finishedAt, + })) + : [] + + // Phase 2: Selection - get expert, query, and optional checkpoint + const selection = await renderSelection({ + showHistory, + initialExpertKey: input.expertKey, + initialQuery: input.query, + initialCheckpoint: checkpoint + ? { + id: checkpoint.id, + jobId: checkpoint.jobId, + runId: checkpoint.runId, + stepNumber: checkpoint.stepNumber, + contextWindowUsage: checkpoint.contextWindowUsage ?? 0, + } + : undefined, + configuredExperts, + recentExperts, + historyJobs, + onLoadCheckpoints: async (j: JobHistoryItem): Promise => { + const checkpoints = getCheckpointsWithDetails(j.jobId) + return checkpoints.map((cp) => ({ ...cp, jobId: j.jobId })) + }, + }) + + // Validate selection + if (!selection.expertKey) { + console.error("Expert key is required") + return + } + if (!selection.query && !selection.checkpoint) { + console.error("Query is required") + return + } - if (currentCheckpoint && currentCheckpoint.expert.key !== selection.expertKey) { - console.error( - `Checkpoint expert key ${currentCheckpoint.expert.key} does not match input expert key ${selection.expertKey}`, - ) - return - } + // Resolve checkpoint if selected from TUI + let currentCheckpoint = selection.checkpoint + ? getCheckpointById(selection.checkpoint.jobId, selection.checkpoint.id) + : checkpoint + + if (currentCheckpoint && currentCheckpoint.expert.key !== selection.expertKey) { + console.error( + `Checkpoint expert key ${currentCheckpoint.expert.key} does not match input expert key ${selection.expertKey}`, + ) + return + } + + const lockfile = handlerOptions.lockfile + + // Phase 3: Execution loop + let currentQuery: string | null = selection.query + let currentJobId = currentCheckpoint?.jobId ?? input.options.jobId ?? createId() + // Track if the next query should be treated as an interactive tool result + let isNextQueryInteractiveToolResult = input.options.interactiveToolCallResult ?? false + + // Track whether this is the first iteration (for historical events display) + // On first iteration, load all events for the job up to the checkpoint + // On subsequent iterations, skip historical events (previous TUI already displayed them) + let isFirstIteration = true + const initialHistoricalEvents: ReturnType | undefined = currentCheckpoint + ? getAllEventContentsForJob(currentCheckpoint.jobId, currentCheckpoint.stepNumber) + : undefined + + while (currentQuery !== null) { + // Only pass historical events on first iteration + // Subsequent iterations: previous TUI output remains on screen + const historicalEvents = isFirstIteration ? initialHistoricalEvents : undefined + + // Generate a new runId for each iteration + const runId = createId() + + // Start execution TUI + const { result: executionResult, eventListener } = renderExecution({ + expertKey: selection.expertKey, + query: currentQuery, + config: { + runtimeVersion, + model, + maxSteps, + maxRetries, + timeout, + contextWindowUsage: currentCheckpoint?.contextWindowUsage ?? 0, + }, + continueTimeoutMs: CONTINUE_TIMEOUT_MS, + historicalEvents, + }) - const lockfile = handlerOptions.lockfile - - // Phase 3: Execution loop - let currentQuery: string | null = selection.query - let currentJobId = currentCheckpoint?.jobId ?? input.options.jobId ?? createId() - // Track if the next query should be treated as an interactive tool result - let isNextQueryInteractiveToolResult = input.options.interactiveToolCallResult ?? false - - // Track whether this is the first iteration (for historical events display) - // On first iteration, load all events for the job up to the checkpoint - // On subsequent iterations, skip historical events (previous TUI already displayed them) - let isFirstIteration = true - const initialHistoricalEvents: ReturnType | undefined = - currentCheckpoint - ? getAllEventContentsForJob(currentCheckpoint.jobId, currentCheckpoint.stepNumber) - : undefined - - while (currentQuery !== null) { - // Only pass historical events on first iteration - // Subsequent iterations: previous TUI output remains on screen - const historicalEvents = isFirstIteration ? initialHistoricalEvents : undefined - - // Generate a new runId for each iteration - const runId = createId() - - // Start execution TUI - const { result: executionResult, eventListener } = renderExecution({ - expertKey: selection.expertKey, - query: currentQuery, - config: { - runtimeVersion, + // Run the expert + const runResult = await perstackRun( + { + setting: { + jobId: currentJobId, + runId, + expertKey: selection.expertKey, + input: + isNextQueryInteractiveToolResult && currentCheckpoint + ? parseInteractiveToolCallResult(currentQuery, currentCheckpoint) + : { text: currentQuery }, + experts, model, - maxSteps, - maxRetries, - timeout, - contextWindowUsage: currentCheckpoint?.contextWindowUsage ?? 0, - }, - continueTimeoutMs: CONTINUE_TIMEOUT_MS, - historicalEvents, - }) - - // Run the expert - const runResult = await perstackRun( - { - setting: { - jobId: currentJobId, - runId, - expertKey: selection.expertKey, - input: - isNextQueryInteractiveToolResult && currentCheckpoint - ? parseInteractiveToolCallResult(currentQuery, currentCheckpoint) - : { text: currentQuery }, - experts, - model, - providerConfig, - reasoningBudget: input.options.reasoningBudget ?? perstackConfig.reasoningBudget, - maxSteps: input.options.maxSteps ?? perstackConfig.maxSteps, - maxRetries: input.options.maxRetries ?? perstackConfig.maxRetries, - timeout: input.options.timeout ?? perstackConfig.timeout, - perstackApiBaseUrl: perstackConfig.perstackApiBaseUrl, - perstackApiKey: env.PERSTACK_API_KEY, - perstackBaseSkillCommand: perstackConfig.perstackBaseSkillCommand, - env, - verbose: input.options.verbose, - }, - checkpoint: currentCheckpoint, + providerConfig, + reasoningBudget: input.options.reasoningBudget ?? perstackConfig.reasoningBudget, + maxSteps: input.options.maxSteps ?? perstackConfig.maxSteps, + maxRetries: input.options.maxRetries ?? perstackConfig.maxRetries, + timeout: input.options.timeout ?? perstackConfig.timeout, + perstackApiBaseUrl: perstackConfig.perstackApiBaseUrl, + perstackApiKey: env.PERSTACK_API_KEY, + perstackBaseSkillCommand: perstackConfig.perstackBaseSkillCommand, + env, + verbose: input.options.verbose, }, - { - eventListener, - storeCheckpoint: defaultStoreCheckpoint, - storeEvent: defaultStoreEvent, - retrieveCheckpoint: defaultRetrieveCheckpoint, - storeJob, - retrieveJob, - createJob: createInitialJob, - lockfile, - }, - ) - - // Wait for execution TUI to complete (user input or timeout) - const result = await executionResult - - // Check if user wants to continue - const canContinue = - runResult.status === "completed" || - runResult.status === "stoppedByExceededMaxSteps" || - runResult.status === "stoppedByError" || - runResult.status === "stoppedByInteractiveTool" - - if (result.nextQuery && canContinue) { - currentQuery = result.nextQuery - currentCheckpoint = runResult - currentJobId = runResult.jobId - - // If the run stopped for interactive tool, the next query is an interactive tool result - isNextQueryInteractiveToolResult = runResult.status === "stoppedByInteractiveTool" - - // Mark first iteration as complete (subsequent TUIs won't show historical events) - isFirstIteration = false - } else { - currentQuery = null - } - } - } catch (error) { - if (error instanceof Error) { - console.error(error.message) + checkpoint: currentCheckpoint, + }, + { + eventListener, + storeCheckpoint: defaultStoreCheckpoint, + storeEvent: defaultStoreEvent, + retrieveCheckpoint: defaultRetrieveCheckpoint, + storeJob, + retrieveJob, + createJob: createInitialJob, + lockfile, + }, + ) + + // Wait for execution TUI to complete (user input or timeout) + const result = await executionResult + + // Check if user wants to continue + const canContinue = + runResult.status === "completed" || + runResult.status === "stoppedByExceededMaxSteps" || + runResult.status === "stoppedByError" || + runResult.status === "stoppedByInteractiveTool" + + if (result.nextQuery && canContinue) { + currentQuery = result.nextQuery + currentCheckpoint = runResult + currentJobId = runResult.jobId + + // If the run stopped for interactive tool, the next query is an interactive tool result + isNextQueryInteractiveToolResult = runResult.status === "stoppedByInteractiveTool" + + // Mark first iteration as complete (subsequent TUIs won't show historical events) + isFirstIteration = false } else { - console.error(error) + currentQuery = null } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0dc2a97..2e8d56bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: apps/create-expert: dependencies: + '@perstack/core': + specifier: workspace:* + version: link:../../packages/core '@perstack/perstack-toml': specifier: workspace:* version: link:../../packages/perstack-toml @@ -162,6 +165,9 @@ importers: specifier: ^14.0.3 version: 14.0.3 devDependencies: + '@perstack/core': + specifier: workspace:* + version: link:../../packages/core '@perstack/installer': specifier: workspace:* version: link:../../packages/installer