diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 83d01b2..7f4698f 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -23,6 +23,7 @@ "automation/prefer-tagged-error-handling": "error", "automation/no-ambient-nondeterminism": "error", "anti-slop/no-reflect-apply": "error", + "anti-slop/no-runtime-typeof": "error", "typescript/consistent-type-imports": ["error", { "fixStyle": "inline-type-imports" }], "typescript/no-import-type-side-effects": "error", "import/no-duplicates": "error", @@ -76,6 +77,7 @@ "anti-slop/no-module-mocking": "off", "anti-slop/no-object-parameters": "off", "anti-slop/no-reflect-apply": "off", + "anti-slop/no-runtime-typeof": "off", "automation/no-disable-validation": "off", "automation/no-shadowed-standard-array-static": "off", "automation/no-silent-error-swallow": "off", diff --git a/packages/fold-agent/src/Bin/ManagedBinaries.ts b/packages/fold-agent/src/Bin/ManagedBinaries.ts index c842e54..4fb20f0 100644 --- a/packages/fold-agent/src/Bin/ManagedBinaries.ts +++ b/packages/fold-agent/src/Bin/ManagedBinaries.ts @@ -24,7 +24,7 @@ import { accessSync, constants, statSync } from 'node:fs' import { delimiter, join } from 'node:path' import { promisify } from 'node:util' -import { Cause, Effect, FileSystem, Schema } from 'effect' +import { Cause, Effect, FileSystem, Predicate, Schema } from 'effect' import { managedBinaryRegistry, type ManagedBinaryAsset, type ManagedBinaryDefinition } from './Registry' @@ -149,13 +149,9 @@ const defaultDownload: DownloadSeam = (url) => /** The ONE mapper from execFile rejections to the typed exec error. */ const execErrorFrom = (command: string, args: ReadonlyArray, cause: unknown): BinaryExecError => { - const stderr = - typeof cause === 'object' && cause !== null && 'stderr' in cause && typeof cause.stderr === 'string' - ? cause.stderr.trim() - : '' - const reason = cause instanceof Error ? cause.message : String(cause) + const reason = Predicate.isError(cause) ? cause.message : String(cause) return new BinaryExecError({ - message: `${command} ${args.join(' ')}: ${reason}${stderr === '' ? '' : ` (${stderr.slice(0, 400)})`}`, + message: `${command} ${args.join(' ')}: ${reason}`, }) } @@ -410,9 +406,7 @@ const resolveOne = (context: ResolveContext, definition: ManagedBinaryDefinition /** Human-readable message for one squashed cause value (typed errors carry `message`; guard, never cast). */ const failureMessageOf = (value: unknown): string => - typeof value === 'object' && value !== null && 'message' in value && typeof value.message === 'string' - ? value.message - : String(value) + Predicate.isError(value) ? value.message : String(value) /** One binary's resolution, degraded to `unavailable` on ANY failure or defect (capture, then keep going). */ const resolveOneNeverFailing = ( diff --git a/packages/fold-agent/src/Compatibility/CodexPlugins.ts b/packages/fold-agent/src/Compatibility/CodexPlugins.ts index b6c5b3a..2ab4a9f 100644 --- a/packages/fold-agent/src/Compatibility/CodexPlugins.ts +++ b/packages/fold-agent/src/Compatibility/CodexPlugins.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' -import { Effect, FileSystem, Path, Schema } from 'effect' +import { Effect, FileSystem, Option, Path, Schema } from 'effect' export const CodexPluginDiagnostic = Schema.Struct({ stage: Schema.Literals(['config', 'cache', 'manifest']), @@ -53,8 +53,23 @@ type SemanticVersion = { readonly prerelease: Array } -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value) +const RelativeSkillRoot = Schema.String.check( + Schema.makeFilter((value: string) => + !value.startsWith('./') || + value === './' || + value.includes('\\') || + value.includes('\0') || + value.split('/').includes('..') + ? 'skill root must be a relative path without traversal' + : undefined, + ), +) +const PluginManifest = Schema.Struct({ + name: Schema.String, + skills: Schema.optionalKey(Schema.Union([RelativeSkillRoot, Schema.Array(Schema.Unknown)])), +}) +const decodePluginManifest = Schema.decodeUnknownOption(Schema.fromJsonString(PluginManifest)) +const decodeRelativeSkillRoot = Schema.decodeUnknownOption(RelativeSkillRoot) const parseSemanticVersion = (value: string): SemanticVersion | null => { const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value) @@ -91,18 +106,8 @@ const selectedVersion = (versions: ReadonlyArray): string | null => { ) } -const safeRelativeSkillRoot = (value: unknown): string | null => { - if ( - typeof value !== 'string' || - !value.startsWith('./') || - value === './' || - value.includes('\\') || - value.includes('\0') - ) - return null - if (value.split('/').includes('..')) return null - return value -} +const safeRelativeSkillRoot = (value: unknown): string | null => + Option.getOrNull(decodeRelativeSkillRoot(value)) export const discoverCodexPluginSkillRoots = (options: CodexPluginOptions) => Effect.gen(function* () { @@ -124,32 +129,28 @@ export const discoverCodexPluginSkillRoots = (options: CodexPluginOptions) => const version = selectedVersion(directories) if (version === null) continue const bundle = path.resolve(cachePath, version) - let manifest: unknown = null + let manifest: typeof PluginManifest.Type | null = null let manifestPath = '' for (const relativePath of ['plugin.json', '.codex-plugin/plugin.json', '.claude-plugin/plugin.json']) { const candidate = path.join(bundle, relativePath) const contents = yield* fs.readFileString(candidate).pipe(Effect.catch(() => Effect.succeed(null))) if (contents === null) continue manifestPath = candidate - try { - manifest = JSON.parse(contents) - } catch { + const decoded = Option.getOrUndefined(decodePluginManifest(contents)) + if (decoded === undefined) { diagnostics.push({ stage: 'manifest', code: 'manifest_parse_failed', path: candidate }) + break } + manifest = decoded break } - if (!isRecord(manifest)) continue + if (manifest === null) continue const record = manifest if (record.name !== plugin.name) { diagnostics.push({ stage: 'manifest', code: 'manifest_name_mismatch', path: manifestPath }) continue } - const declared = - record.skills === undefined - ? ['./skills'] - : Array.isArray(record.skills) - ? record.skills - : [record.skills] + const declared = record.skills === undefined ? ['./skills'] : Array.isArray(record.skills) ? record.skills : [record.skills] for (const value of declared) { const relativeRoot = safeRelativeSkillRoot(value) if (relativeRoot === null) { diff --git a/packages/fold-agent/src/Compatibility/CodexSkills.ts b/packages/fold-agent/src/Compatibility/CodexSkills.ts index 3ecfb3e..042a4e8 100644 --- a/packages/fold-agent/src/Compatibility/CodexSkills.ts +++ b/packages/fold-agent/src/Compatibility/CodexSkills.ts @@ -1,7 +1,7 @@ import { homedir } from 'node:os' import { SkillNotFoundError, type Skill, type SkillMeta, type SkillSourceService } from '@humanlayer/fold-core' -import { Effect, FileSystem, Path } from 'effect' +import { Effect, FileSystem, Option, Path, Schema } from 'effect' import { parse as parseYaml } from 'yaml' export type CodexSkillOptions = { @@ -47,8 +47,11 @@ const ancestorSkillRoots = (cwd: string, home: string | null): Effect.Effect => - typeof value === 'object' && value !== null && !Array.isArray(value) +const SkillFrontmatter = Schema.Struct({ + name: Schema.optionalKey(Schema.String), + description: Schema.String, +}) +const decodeSkillFrontmatter = Schema.decodeUnknownOption(SkillFrontmatter) const loadSkill = ( skillPath: string, @@ -63,16 +66,10 @@ const loadSkill = ( if (!normalized.startsWith('---\n')) return null const end = normalized.indexOf('\n---', 4) if (end < 0) return null - const parsed: unknown = parseYaml(normalized.slice(4, end)) - if ( - !isRecord(parsed) || - typeof parsed.description !== 'string' || - parsed.description.trim().length === 0 - ) - return null + const parsed = Option.getOrUndefined(decodeSkillFrontmatter(parseYaml(normalized.slice(4, end)))) + if (parsed === undefined || parsed.description.trim().length === 0) return null const directory = path.dirname(skillPath) - const rawName = - typeof parsed.name === 'string' && parsed.name.length > 0 ? parsed.name : path.basename(directory) + const rawName = parsed.name !== undefined && parsed.name.length > 0 ? parsed.name : path.basename(directory) const name = namespace === undefined ? rawName : `${namespace}:${rawName}` return { name, diff --git a/packages/fold-agent/src/Compatibility/GrokPlugins.ts b/packages/fold-agent/src/Compatibility/GrokPlugins.ts index a467cae..bcc2532 100644 --- a/packages/fold-agent/src/Compatibility/GrokPlugins.ts +++ b/packages/fold-agent/src/Compatibility/GrokPlugins.ts @@ -1,6 +1,6 @@ import { homedir } from 'node:os' -import { Effect, FileSystem, Path, Schema } from 'effect' +import { Effect, FileSystem, Option, Path, Schema } from 'effect' export const GrokPluginDiagnostic = Schema.Struct({ stage: Schema.Literals(['manifest', 'discovery']), @@ -19,8 +19,26 @@ export type GrokPluginOptions = { readonly configuredPaths?: ReadonlyArray } -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value) +const RelativeSkillPath = Schema.String.check( + Schema.makeFilter((value: string) => { + if (value.length === 0 || value.includes('\\') || value.includes('\0')) { + return 'skill path must be a relative path without traversal' + } + const normalized = value.replace(/^\.\//, '') + return normalized.length === 0 || + normalized.startsWith('/') || + /^[A-Za-z]:\//.test(normalized) || + normalized.split('/').includes('..') + ? 'skill path must be a relative path without traversal' + : undefined + }), +) +const PluginManifest = Schema.Struct({ + name: Schema.optionalKey(Schema.String), + skills: Schema.optionalKey(Schema.Union([RelativeSkillPath, Schema.Array(Schema.Unknown)])), +}) +const decodePluginManifest = Schema.decodeUnknownOption(Schema.fromJsonString(PluginManifest)) +const decodeRelativeSkillPath = Schema.decodeUnknownOption(RelativeSkillPath) const isAncestor = (ancestor: string, candidate: string): Effect.Effect => Effect.gen(function* () { @@ -53,21 +71,17 @@ const ancestorDirectories = ( }) const safeRelativePath = (value: unknown): string | null => { - if (typeof value !== 'string' || value.length === 0 || value.includes('\\') || value.includes('\0')) return null - const normalized = value.replace(/^\.\//, '') - if ( - normalized.length === 0 || - normalized.startsWith('/') || - /^[A-Za-z]:\//.test(normalized) || - normalized.split('/').includes('..') - ) - return null - return normalized + const decoded = Option.getOrUndefined(decodeRelativeSkillPath(value)) + return decoded === undefined ? null : decoded.replace(/^\.\//, '') } const readManifest = ( root: string, -): Effect.Effect<{ path: string; value: unknown } | null, never, FileSystem.FileSystem | Path.Path> => +): Effect.Effect< + { path: string; value: typeof PluginManifest.Type | null } | null, + never, + FileSystem.FileSystem | Path.Path +> => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path @@ -75,8 +89,7 @@ const readManifest = ( const manifestPath = path.join(root, name) const contents = yield* fs.readFileString(manifestPath).pipe(Effect.orElseSucceed(() => null)) if (contents === null) continue - const value = yield* Effect.try(() => JSON.parse(contents)).pipe(Effect.orElseSucceed(() => null)) - return { path: manifestPath, value } + return { path: manifestPath, value: Option.getOrNull(decodePluginManifest(contents)) } } return null }) @@ -122,15 +135,12 @@ export const discoverGrokPluginSkillRoots = Effect.fn('fold.grok_compatibility.d seenPaths.add(normalized) const manifest = candidate === parent && parentManifest !== null ? parentManifest : yield* readManifest(candidate) - if (manifest !== null && !isRecord(manifest.value)) { + if (manifest !== null && manifest.value === null) { diagnostics.push({ stage: 'manifest', code: 'manifest_parse_failed', path: manifest.path }) continue } - const manifestValue = manifest === null || !isRecord(manifest.value) ? null : manifest.value - const name = - manifestValue !== null && typeof manifestValue.name === 'string' - ? manifestValue.name - : path.basename(candidate) + const manifestValue = manifest?.value ?? null + const name = manifestValue?.name ?? path.basename(candidate) if (name.length === 0 || seenNames.has(name)) continue const declared = manifestValue === null || manifestValue.skills === undefined diff --git a/packages/fold-agent/src/Compatibility/GrokSkills.ts b/packages/fold-agent/src/Compatibility/GrokSkills.ts index 994cf0c..70fdb90 100644 --- a/packages/fold-agent/src/Compatibility/GrokSkills.ts +++ b/packages/fold-agent/src/Compatibility/GrokSkills.ts @@ -1,7 +1,7 @@ import { homedir } from 'node:os' import { SkillNotFoundError, type Skill, type SkillMeta, type SkillSourceService } from '@humanlayer/fold-core' -import { Effect, FileSystem, Path } from 'effect' +import { Effect, FileSystem, Option, Path, Schema } from 'effect' import { parse as parseYaml } from 'yaml' export type GrokSkillOptions = { @@ -21,8 +21,11 @@ const exists = (path: string): Effect.Effect false)) }) -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value) +const SkillFrontmatter = Schema.Struct({ + name: Schema.optionalKey(Schema.String), + description: Schema.optionalKey(Schema.String), +}) +const decodeSkillFrontmatter = Schema.decodeUnknownOption(SkillFrontmatter) const isAncestor = (ancestor: string, candidate: string): Effect.Effect => Effect.gen(function* () { @@ -76,14 +79,12 @@ const loadSkill = ( content = normalized.slice(end + 4).trim() } } - const record = isRecord(parsed) ? parsed : {} + const record = Option.getOrUndefined(decodeSkillFrontmatter(parsed)) const rawName = - typeof record.name === 'string' && record.name.length > 0 - ? record.name - : path.basename(directory) + record?.name !== undefined && record.name.length > 0 ? record.name : path.basename(directory) const name = namespace === undefined ? rawName : `${namespace}:${rawName}` const description = - typeof record.description === 'string' && record.description.trim().length > 0 + record?.description !== undefined && record.description.trim().length > 0 ? record.description.trim() : content .split(/\n\s*\n/)[0] diff --git a/packages/fold-agent/src/Session/SessionLayout.ts b/packages/fold-agent/src/Session/SessionLayout.ts index fc7e535..e4f971a 100644 --- a/packages/fold-agent/src/Session/SessionLayout.ts +++ b/packages/fold-agent/src/Session/SessionLayout.ts @@ -52,6 +52,13 @@ export type SessionSummary = SessionLogRef & { const decodeSessionId = Schema.decodeUnknownOption(SessionId) +const SessionMetaFields = Schema.Struct({ + mode: Schema.optionalKey(Schema.String), + profile: Schema.optionalKey(Schema.String), + rpi: Schema.optionalKey(Schema.Boolean), +}) +const decodeSessionMetaFields = Schema.decodeUnknownOption(SessionMetaFields) + /** * The project slug for one working directory (pi-style escaped cwd): every non-alphanumeric run * becomes a single dash, so `/Users/kyle/projects/fold` -> `Users-kyle-projects-fold`. Deterministic, @@ -234,7 +241,7 @@ const isFinishedAssistantMessage = (entry: LogEntry): entry is FinishedAssistant const userMessageText = (entry: Extract): string => { const content = entry.message.content - return typeof content === 'string' + return Predicate.isString(content) ? content : content.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('') } @@ -278,7 +285,7 @@ const sessionSummary = (ref: SessionLogRef, entries: ReadonlyArray): S const modelEntry = rootEntries.findLast(carriesModel) const model = modelEntry?.model ?? null const latestUsage = rootEntries.findLast(isFinishedAssistantMessage) - const meta = started?.meta ?? {} + const meta = Option.getOrUndefined(decodeSessionMetaFields(started?.meta ?? {})) const lastFinished = rootEntries.findLast(isAgentFinished) const latestRootEntry = rootEntries.findLast((entry) => !isSessionTitle(entry)) const status = computeStatus(lastFinished, latestRootEntry) @@ -292,9 +299,9 @@ const sessionSummary = (ref: SessionLogRef, entries: ReadonlyArray): S modelId: model?.modelId ?? null, model, contextTokens: latestUsage !== undefined ? usageInputTotal(latestUsage.finish.usage) : null, - mode: typeof meta.mode === 'string' ? meta.mode : null, - rpi: meta.rpi === true, - profile: typeof meta.profile === 'string' ? meta.profile : null, + mode: meta?.mode ?? null, + rpi: meta?.rpi === true, + profile: meta?.profile ?? null, } } diff --git a/packages/fold-agent/src/Session/TitleGenerator.ts b/packages/fold-agent/src/Session/TitleGenerator.ts index 3ee5bf0..7047f4c 100644 --- a/packages/fold-agent/src/Session/TitleGenerator.ts +++ b/packages/fold-agent/src/Session/TitleGenerator.ts @@ -12,7 +12,7 @@ const isMessageEntry = (entry: LogEntry): entry is MessageEntry => Predicate.isTagged(entry, 'user-message') || Predicate.isTagged(entry, 'assistant-message') const extractMessageText = (entry: MessageEntry): string => - typeof entry.message.content === 'string' + Predicate.isString(entry.message.content) ? entry.message.content : entry.message.content.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('') diff --git a/packages/fold-agent/src/Skills/DiskSkills.ts b/packages/fold-agent/src/Skills/DiskSkills.ts index 744986b..ae48845 100644 --- a/packages/fold-agent/src/Skills/DiskSkills.ts +++ b/packages/fold-agent/src/Skills/DiskSkills.ts @@ -21,7 +21,7 @@ import { type SkillSourceService, type FoldSkills, } from '@humanlayer/fold-core' -import { Effect, FileSystem } from 'effect' +import { Effect, FileSystem, Option, Schema } from 'effect' import { parse as parseYaml } from 'yaml' import { cwdFor } from '../Fs/DefaultFileSystem' @@ -57,13 +57,17 @@ const findGitRoot = (fs: FileSystem.FileSystem, cwd: string): Effect.Effect => - typeof value === 'object' && value !== null && !Array.isArray(value) +const SkillFrontmatter = Schema.Struct({ + name: Schema.optionalKey(Schema.String), + description: Schema.optionalKey(Schema.String), +}) +type SkillFrontmatter = typeof SkillFrontmatter.Type +const decodeSkillFrontmatter = Schema.decodeUnknownOption(SkillFrontmatter) /** Split SKILL.md into YAML frontmatter and body. Null when there is no leading `---` block. */ const extractFrontmatter = ( rawContent: string, -): { readonly frontmatter: Record; readonly body: string } | null => { +): { readonly frontmatter: SkillFrontmatter; readonly body: string } | null => { // Normalize newlines first (pi parity): CRLF frontmatter would otherwise leave a trailing \r on // the last field, corrupting descriptions and failing name validation. const content = rawContent.replace(/\r\n/g, '\n').replace(/\r/g, '\n') @@ -76,8 +80,8 @@ const extractFrontmatter = ( const body = content.slice(endIndex + 4).trim() try { - const parsed: unknown = parseYaml(rawYaml) - if (!isRecord(parsed)) return null + const parsed = Option.getOrUndefined(decodeSkillFrontmatter(parseYaml(rawYaml))) + if (parsed === undefined) return null return { frontmatter: parsed, body } } catch { return null @@ -100,10 +104,10 @@ const loadSkillFile = (fs: FileSystem.FileSystem, skillFilePath: string): Effect } const directory = dirname(skillFilePath) - const rawName = parsed.frontmatter.name - const name = typeof rawName === 'string' && rawName.length > 0 ? rawName : basename(directory) - const rawDescription = parsed.frontmatter.description - const description = typeof rawDescription === 'string' ? rawDescription : '' + const name = parsed.frontmatter.name !== undefined && parsed.frontmatter.name.length > 0 + ? parsed.frontmatter.name + : basename(directory) + const description = parsed.frontmatter.description ?? '' const problem = skillNameProblem(name) ?? skillDescriptionProblem(description) if (problem !== null) { diff --git a/packages/fold-agent/src/Tools/ReadTool.ts b/packages/fold-agent/src/Tools/ReadTool.ts index 5bbd1ab..5605f94 100644 --- a/packages/fold-agent/src/Tools/ReadTool.ts +++ b/packages/fold-agent/src/Tools/ReadTool.ts @@ -33,18 +33,20 @@ export const platformErrorMessage = (action: string, path: string, error: Platfo ) } -/** Extract the POSIX errno code (ENOENT, EACCES, ...) from a platform error, pi's error vocabulary. */ -export const errnoCode = (error: PlatformError.PlatformError): string => { - const cause: unknown = error.reason.cause - if (typeof cause === 'object' && cause !== null && 'code' in cause && typeof cause.code === 'string') { - return cause.code - } - - return Match.value(error.reason).pipe( - Match.tags({ NotFound: () => 'ENOENT', PermissionDenied: () => 'EACCES' }), +/** POSIX errno for a platform error, using the tagged reason Effect already classified. */ +export const errnoCode = (error: PlatformError.PlatformError): string => + Match.value(error.reason).pipe( + Match.tags({ + NotFound: () => 'ENOENT', + PermissionDenied: () => 'EACCES', + AlreadyExists: () => 'EEXIST', + BadResource: () => 'EBADF', + Busy: () => 'EBUSY', + TimedOut: () => 'ETIMEDOUT', + WouldBlock: () => 'EAGAIN', + }), Match.orElse((reason) => reason._tag), ) -} /** Build the read tool over the ambient FileSystem service. */ export const readTool = (options?: { readonly cwd?: string }): FoldTool => diff --git a/packages/fold-agent/src/Tools/WebSearchTool.ts b/packages/fold-agent/src/Tools/WebSearchTool.ts index 531dc84..36eea3f 100644 --- a/packages/fold-agent/src/Tools/WebSearchTool.ts +++ b/packages/fold-agent/src/Tools/WebSearchTool.ts @@ -1,5 +1,5 @@ import { CurrentAgent, defineTool, webSearchToolContract, type FoldTool } from '@humanlayer/fold-core' -import { Effect, Predicate } from 'effect' +import { Effect, Option, Predicate, Schema } from 'effect' const defaultTimeoutMs = 25_000 const maxNumResults = 20 @@ -52,24 +52,18 @@ const selectProvider = (seed: string, options?: WebSearchToolOptions): WebSearch return checksum(seed) % 2 === 0 ? 'exa' : 'parallel' } -const textField = (value: unknown): string | undefined => { - if (typeof value !== 'object' || value === null || !('text' in value)) return undefined - const text = Reflect.get(value, 'text') - return typeof text === 'string' && text.length > 0 ? text : undefined -} +const McpSearchPayload = Schema.Struct({ + result: Schema.Struct({ + content: Schema.Array(Schema.Struct({ text: Schema.optionalKey(Schema.String) })), + }), +}) +const decodeMcpSearchPayload = Schema.decodeUnknownOption(Schema.fromJsonString(McpSearchPayload)) const parsePayload = (payload: string): string | undefined => { const trimmed = payload.trim() if (!trimmed.startsWith('{')) return undefined - - const decoded: unknown = JSON.parse(trimmed) - if (typeof decoded !== 'object' || decoded === null || !('result' in decoded)) return undefined - const result = Reflect.get(decoded, 'result') - if (typeof result !== 'object' || result === null || !('content' in result)) return undefined - const content = Reflect.get(result, 'content') - if (!Array.isArray(content)) return undefined - - return content.map(textField).find((text) => text !== undefined) + const decoded = Option.getOrUndefined(decodeMcpSearchPayload(trimmed)) + return decoded?.result.content.map((part) => part.text).find((text) => text !== undefined && text.length > 0) } const parseMcpResponse = (body: string): Effect.Effect => diff --git a/packages/fold-agent/test/TestHelpers.ts b/packages/fold-agent/test/TestHelpers.ts index 9b6a8a4..1c0c647 100644 --- a/packages/fold-agent/test/TestHelpers.ts +++ b/packages/fold-agent/test/TestHelpers.ts @@ -23,7 +23,7 @@ import { type FoldTool, type ToolHandlerServices, } from '@humanlayer/fold-core' -import { Effect, FileSystem, Layer, PlatformError, Ref, type Schema } from 'effect' +import { Effect, FileSystem, Layer, Option, PlatformError, Ref, Schema } from 'effect' /** Run a tool handler effect with stubbed ambient services and recorded ToolEvents/InterruptNote feeds. */ export const makeAmbientServices = (): Effect.Effect<{ @@ -159,22 +159,22 @@ export const memoryFileSystem = (initialFiles: Record): FileSyst export const memoryFileFor = (fs: FileSystem.FileSystem, path: string): Effect.Effect => fs.readFileString(path).pipe(Effect.catch(() => Effect.succeed(null))) -/** Narrow one string-valued field out of an unknown tool result/failure (assertion helper). */ -const stringField = - (field: string) => - (value: unknown): string => { - if (typeof value === 'object' && value !== null && field in value) { - const candidate: unknown = Reflect.get(value, field) - if (typeof candidate === 'string') return candidate - } - throw new Error(`expected a value with a string "${field}" field`) - } +const decodeMessageField = Schema.decodeUnknownOption(Schema.Struct({ message: Schema.String })) +const decodeOutputField = Schema.decodeUnknownOption(Schema.Struct({ output: Schema.String })) /** The `message` field of a tool success/failure value. */ -export const messageOf: (value: unknown) => string = stringField('message') +export const messageOf = (value: unknown): string => { + const decoded = Option.getOrUndefined(decodeMessageField(value)) + if (decoded !== undefined) return decoded.message + throw new Error('expected a value with a string "message" field') +} /** The `output` field of a bash tool success value. */ -export const outputOf: (value: unknown) => string = stringField('output') +export const outputOf = (value: unknown): string => { + const decoded = Option.getOrUndefined(decodeOutputField(value)) + if (decoded !== undefined) return decoded.output + throw new Error('expected a value with a string "output" field') +} const parentDirs = (path: string): ReadonlyArray => { const parents: Array = [] diff --git a/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts b/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts index bc24413..0b19337 100644 --- a/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts +++ b/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts @@ -9,7 +9,7 @@ import { createServer, type Server } from 'node:http' import { it } from '@effect/vitest' import { ToolResultContent } from '@humanlayer/fold-core' -import { Effect, Schema } from 'effect' +import { Effect, Predicate, Schema } from 'effect' import { afterAll, beforeAll, expect } from 'vitest' import { webFetchTool } from '../../src/index' @@ -101,7 +101,7 @@ beforeAll(async () => { await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) const address = server.address() - const port = typeof address === 'object' && address !== null ? address.port : 0 + const port = Predicate.isObject(address) ? address.port : 0 baseUrl = `http://127.0.0.1:${port}` }) diff --git a/packages/fold-cli/src/Commands.ts b/packages/fold-cli/src/Commands.ts index 731d027..37dc0cb 100644 --- a/packages/fold-cli/src/Commands.ts +++ b/packages/fold-cli/src/Commands.ts @@ -29,7 +29,7 @@ import { import { SessionId, type ModelCatalogEntry } from '@humanlayer/fold-core' import { makeOpenCodeAuth, makeOpenCodeAuthStore, type OpenCodeAuthError } from '@humanlayer/fold-opencode' import { makeXaiAuth, makeXaiAuthStore, type XaiAuthError } from '@humanlayer/fold-xai' -import { Clock, Console, Effect, Option, Schema } from 'effect' +import { Clock, Console, Effect, Option, Predicate, Schema } from 'effect' import { type CliError, Command, Flag } from 'effect/unstable/cli' import { FetchHttpClient } from 'effect/unstable/http' @@ -37,6 +37,8 @@ import { makeJsonOutputRenderer, makePromptOutputRenderer, type JsonOutputMode } import { ResumeTarget, runPrompt, type CliSessionOptions } from './Run' declare const FOLD_VERSION: string +// Bun injects FOLD_VERSION at build time. `typeof` is the only check that does not throw when tests leave it undeclared. +// oxlint-disable-next-line anti-slop/no-runtime-typeof const version = typeof FOLD_VERSION === 'string' ? FOLD_VERSION : '0.0.0' type Mutable = { -readonly [Key in keyof Type]: Type[Key] } @@ -353,7 +355,7 @@ const run = Command.make('foldcode', commonFlags, (input) => Effect.scoped( Effect.gen(function* () { const prompt = optionValue(input.prompt) - if (prompt === undefined && typeof Bun === 'undefined') { + if (prompt === undefined && !Predicate.hasProperty(globalThis, 'Bun')) { yield* printFailure( 'The full-screen TUI requires the native @humanlayer/fold package. Use foldcode --prompt "..." or install @humanlayer/fold globally.', ) @@ -398,7 +400,7 @@ const run = Command.make('foldcode', commonFlags, (input) => const launchTui = (options: CliSessionOptions, catalog: ReadonlyArray, prompt?: string) => Effect.gen(function* () { - if (typeof Bun === 'undefined') { + if (!Predicate.hasProperty(globalThis, 'Bun')) { yield* printFailure( 'The full-screen TUI requires the native @humanlayer/fold package. Use foldcode --prompt "..." or install @humanlayer/fold globally.', ) @@ -470,7 +472,7 @@ const sessions = Command.make( const tui = Command.make('tui', commonFlags, (input) => Effect.scoped( Effect.gen(function* () { - if (typeof Bun === 'undefined') { + if (!Predicate.hasProperty(globalThis, 'Bun')) { yield* printFailure( 'The full-screen TUI requires the native @humanlayer/fold package. Use foldcode --prompt "..." or install @humanlayer/fold globally.', ) diff --git a/packages/fold-cli/src/Renderer.ts b/packages/fold-cli/src/Renderer.ts index e1c694c..d33718c 100644 --- a/packages/fold-cli/src/Renderer.ts +++ b/packages/fold-cli/src/Renderer.ts @@ -18,20 +18,24 @@ import { type UsageEncoded, type FoldEvent, } from '@humanlayer/fold-core' -import { Data, Effect, Match } from 'effect' +import { Data, Effect, Match, Option, Predicate, Schema } from 'effect' import { makeAnsiPalette, type AnsiPalette } from './Ansi' type Writer = (text: string) => Effect.Effect -type EncodedPart = { - readonly type: string - readonly text?: string - readonly name?: string - readonly params?: unknown - readonly result?: unknown - readonly isFailure?: boolean -} +const EncodedPart = Schema.Struct({ + type: Schema.String, + text: Schema.optionalKey(Schema.String), + name: Schema.optionalKey(Schema.String), + params: Schema.optionalKey(Schema.Unknown), + result: Schema.optionalKey(Schema.Unknown), + isFailure: Schema.optionalKey(Schema.Boolean), +}) +type EncodedPart = typeof EncodedPart.Type + +const EncodedContent = Schema.Union([Schema.String, Schema.Array(EncodedPart)]) +const decodeEncodedContent = Schema.decodeUnknownOption(EncodedContent) /** Creation options for the colored headless output renderer. */ export type RendererOptions = { @@ -167,14 +171,11 @@ export const makePromptOutputRenderer = (options?: RendererOptions): OutputRende const truncate = (text: string, max: number): string => text.length <= max ? text : `${text.slice(0, max)}... (${text.length - max} more chars)` -const contentParts = (content: unknown): ReadonlyArray => { - if (typeof content === 'string') return [{ type: 'text', text: content }] - if (!Array.isArray(content)) return [] - - return content.filter( - (part): part is EncodedPart => typeof part === 'object' && part !== null && typeof part.type === 'string', - ) -} +const contentParts = (content: unknown): ReadonlyArray => + Option.match(decodeEncodedContent(content), { + onNone: () => [], + onSome: (decoded) => (Predicate.isString(decoded) ? [{ type: 'text', text: decoded }] : decoded), + }) const textContent = (content: unknown): string => contentParts(content) diff --git a/packages/fold-codex/src/OAuthFlows.ts b/packages/fold-codex/src/OAuthFlows.ts index 10d9795..5c17fe6 100644 --- a/packages/fold-codex/src/OAuthFlows.ts +++ b/packages/fold-codex/src/OAuthFlows.ts @@ -86,38 +86,14 @@ export type CodexJwtClaims = { readonly organizations?: ReadonlyArray<{ readonly id: string }> } -const decodeJwtJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)) - -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value) - -const getString = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined) - -const toJwtClaims = (value: unknown): Option.Option => { - if (!isRecord(value)) return Option.none() - - const accountId = getString(value['chatgpt_account_id']) - const authValue = value['https://api.openai.com/auth'] - const nestedAccountId = isRecord(authValue) ? getString(authValue['chatgpt_account_id']) : undefined - const organizationsValue = value['organizations'] - const organizationId = - Array.isArray(organizationsValue) && organizationsValue[0] !== undefined && isRecord(organizationsValue[0]) - ? getString(organizationsValue[0]['id']) - : undefined - - const claims: { - chatgpt_account_id?: string - 'https://api.openai.com/auth'?: { chatgpt_account_id?: string } - organizations?: Array<{ id: string }> - } = {} - if (accountId !== undefined) claims.chatgpt_account_id = accountId - if (nestedAccountId !== undefined) { - claims['https://api.openai.com/auth'] = { chatgpt_account_id: nestedAccountId } - } - if (organizationId !== undefined) claims.organizations = [{ id: organizationId }] - - return Option.some(claims) -} +const CodexJwtClaimsSchema = Schema.Struct({ + chatgpt_account_id: Schema.optionalKey(Schema.String), + 'https://api.openai.com/auth': Schema.optionalKey( + Schema.Struct({ chatgpt_account_id: Schema.optionalKey(Schema.String) }), + ), + organizations: Schema.optionalKey(Schema.Array(Schema.Struct({ id: Schema.String }))), +}) +const decodeJwtJson = Schema.decodeUnknownOption(Schema.fromJsonString(CodexJwtClaimsSchema)) const decodeJwtPayload = (token: string): Option.Option => { const parts = token.split('.') @@ -131,7 +107,7 @@ const decodeJwtPayload = (token: string): Option.Option => { /** Best-effort JWT claim parse - malformed tokens are `none`, never failures. */ export const parseJwtClaims = (token: string): Option.Option => - decodeJwtPayload(token).pipe(Option.flatMap(decodeJwtJson), Option.flatMap(toJwtClaims)) + decodeJwtPayload(token).pipe(Option.flatMap(decodeJwtJson)) /** ChatGPT account id lookup order: direct claim, namespaced claim, first organization. */ export const extractAccountIdFromClaims = (claims: CodexJwtClaims): Option.Option => { diff --git a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts index 8564b8c..14bfd1d 100644 --- a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts +++ b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts @@ -528,7 +528,7 @@ export const liveAgentRuntimeLayer: Layer.Layer< const agentBlocks = input.systemPrompt === null ? [] - : typeof input.systemPrompt === 'string' + : Predicate.isString(input.systemPrompt) ? [input.systemPrompt] : input.systemPrompt diff --git a/packages/fold-core/src/Api/ModelDescriptor.ts b/packages/fold-core/src/Api/ModelDescriptor.ts index adeb2bb..9b64795 100644 --- a/packages/fold-core/src/Api/ModelDescriptor.ts +++ b/packages/fold-core/src/Api/ModelDescriptor.ts @@ -45,7 +45,7 @@ export type FoldModel = { } const redact = (apiKey: string | Redacted.Redacted): Redacted.Redacted => - typeof apiKey === 'string' ? Redacted.make(apiKey) : apiKey + Redacted.isRedacted(apiKey) ? apiKey : Redacted.make(apiKey) /** The anthropic model used when {@link AnthropicModelOptions.model} is omitted. */ export const DEFAULT_ANTHROPIC_MODEL_ID = 'claude-opus-4-8' diff --git a/packages/fold-core/src/Api/StartSession.ts b/packages/fold-core/src/Api/StartSession.ts index 4f080b6..9ed0255 100644 --- a/packages/fold-core/src/Api/StartSession.ts +++ b/packages/fold-core/src/Api/StartSession.ts @@ -72,6 +72,7 @@ import { liveModelRequestSettingsLayer } from '../Model/ModelRequestSettings' import { runtimeForAgent } from '../Projection/Projection' import { AgentNotRunningError } from '../Session/Errors' import { + isProfileRole, makeProfiles, profileModelFor, Profiles, @@ -290,7 +291,7 @@ const eventLogLayerFor = (log: FoldEventLog): Layer.Layer | null): ReadonlyArray => - systemPrompt === null ? [] : typeof systemPrompt === 'string' ? [systemPrompt] : systemPrompt + systemPrompt === null ? [] : Predicate.isString(systemPrompt) ? [systemPrompt] : systemPrompt /** Everything one assembled session shares between `startSession` and `resumeSession`. */ type SessionGraph = { @@ -355,7 +356,7 @@ const assembleSessionGraph = (options: { // models need no profiles at all. const initialProfiles = options.profiles ?? {} for (const entry of registry.entries) { - if (typeof entry.model !== 'string') continue + if (!isProfileRole(entry.model)) continue if (profileModelFor(initialProfiles, entry.model) !== undefined) continue const needed = entry.model === 'orchestrator' ? 'profiles.orchestrator (or profiles.smart)' : `profiles.${entry.model}` @@ -569,7 +570,7 @@ const assembleSessionGraph = (options: { ), ] for (const entry of bindings) { - if (typeof entry.model !== 'string') continue + if (!isProfileRole(entry.model)) continue if (profileModelFor(candidateProfiles, entry.model) !== undefined) continue throw new Error( `subagent type "${entry.name}" binds model role "${entry.model}", but the session has no covering profile binding`, diff --git a/packages/fold-core/src/Compaction/CompactionEngine.ts b/packages/fold-core/src/Compaction/CompactionEngine.ts index 9b1e807..626ce43 100644 --- a/packages/fold-core/src/Compaction/CompactionEngine.ts +++ b/packages/fold-core/src/Compaction/CompactionEngine.ts @@ -1,4 +1,4 @@ -import { Match, Predicate } from 'effect' +import { Match, Option, Predicate, Schema } from 'effect' /** * This file is the pure auto-compaction engine (D11): the threshold arithmetic over API-reported @@ -101,14 +101,18 @@ export const latestReportedContextTokens = (visibleEntries: ReadonlyArray { try { @@ -118,14 +122,11 @@ const safeStringify = (value: unknown): string => { } } -const contentParts = (content: unknown): ReadonlyArray => { - if (typeof content === 'string') return [{ type: 'text', text: content }] - if (!Array.isArray(content)) return [] - - return content.filter( - (part): part is EncodedPart => typeof part === 'object' && part !== null && typeof part.type === 'string', - ) -} +const contentParts = (content: unknown): ReadonlyArray => + Option.match(decodeEncodedContent(content), { + onNone: () => [], + onSome: (decoded) => (Predicate.isString(decoded) ? [{ type: 'text', text: decoded }] : decoded), + }) const estimatePartChars = (part: EncodedPart): number => { switch (part.type) { diff --git a/packages/fold-core/src/Projection/Projection.ts b/packages/fold-core/src/Projection/Projection.ts index 8085470..ddf1abb 100644 --- a/packages/fold-core/src/Projection/Projection.ts +++ b/packages/fold-core/src/Projection/Projection.ts @@ -96,7 +96,7 @@ const findAgentFinished = (entries: ReadonlyArray, agentId: AgentId): const compareSeq = (left: LogEntry, right: LogEntry) => left.seq - right.seq const userMessageText = (entry: UserMessageLogEntry): string => - typeof entry.message.content === 'string' + Predicate.isString(entry.message.content) ? entry.message.content : entry.message.content.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('') @@ -108,7 +108,7 @@ const isInjectedSkillMessage = (entry: LogEntry): boolean => { const isSettledAssistantText = (entry: LogEntry): boolean => { if (!Predicate.isTagged(entry, 'assistant-message')) return false - if (typeof entry.message.content === 'string') return entry.message.content.trim().length > 0 + if (Predicate.isString(entry.message.content)) return entry.message.content.trim().length > 0 return entry.message.content.length > 0 && entry.message.content.every((part) => part.type === 'text') } @@ -272,7 +272,7 @@ const latestCompaction = (entries: ReadonlyArray): CompactionLogEntry entries.findLast((entry): entry is CompactionLogEntry => Predicate.isTagged(entry, 'compaction')) ?? null const toolCallIdsForAssistantMessage = (message: AssistantMessageEncoded): ReadonlyArray => { - if (typeof message.content === 'string') return [] + if (Predicate.isString(message.content)) return [] return message.content.flatMap((part) => (part.type === 'tool-call' ? [part.id] : [])) } diff --git a/packages/fold-core/src/StopConditions/StopConditions.ts b/packages/fold-core/src/StopConditions/StopConditions.ts index fdf52af..ef7ba8c 100644 --- a/packages/fold-core/src/StopConditions/StopConditions.ts +++ b/packages/fold-core/src/StopConditions/StopConditions.ts @@ -4,7 +4,7 @@ * emits the same tool-call batch repeatedly, fold lets the current batch settle, then stops gracefully * before another model request. */ -import { Context } from 'effect' +import { Context, Predicate } from 'effect' /** Doom-loop detector configuration. Omitted means disabled. */ export type DoomLoopStopCondition = @@ -43,7 +43,7 @@ export const initialDoomLoopState: DoomLoopState = { fingerprint: null, count: 0 const normalizeForFingerprint = (value: unknown): unknown => { if (Array.isArray(value)) return value.map(normalizeForFingerprint) - if (typeof value !== 'object' || value === null) return value + if (!Predicate.isObject(value)) return value return Object.fromEntries( Object.entries(value) diff --git a/packages/fold-core/src/Subagents/SubagentsLayer.ts b/packages/fold-core/src/Subagents/SubagentsLayer.ts index c253eba..4d63bca 100644 --- a/packages/fold-core/src/Subagents/SubagentsLayer.ts +++ b/packages/fold-core/src/Subagents/SubagentsLayer.ts @@ -36,7 +36,7 @@ import { import type { HookConfig } from '../HookRunner/Types' import { Ids, type AgentId, type ToolCallId } from '../Ids' import { runtimeForAgent } from '../Projection/Projection' -import { Profiles } from '../Session/Profiles' +import { isProfileRole, Profiles } from '../Session/Profiles' import { SessionControls } from '../Session/SessionControls' import { SkillNotFoundError, type SkillSourceService } from '../Skills/SkillSource' import { renderSkillContent } from '../Skills/SkillTool' @@ -137,7 +137,7 @@ const SubagentLaunch = Data.taggedEnum() /** Fold a leading-prompt config value into an ordered block list. */ const promptBlocksOf = (systemPrompt: string | ReadonlyArray | null): ReadonlyArray => - systemPrompt === null ? [] : typeof systemPrompt === 'string' ? [systemPrompt] : systemPrompt + systemPrompt === null ? [] : Predicate.isString(systemPrompt) ? [systemPrompt] : systemPrompt /** Leading blocks for one agent: its own blocks, then its tools' contributed blocks. */ const leadingBlocksFor = ( @@ -197,7 +197,7 @@ const lastAssistantTextForRun = ( if (lastAssistant === undefined) return null const content = lastAssistant.message.content - if (typeof content === 'string') return content.length > 0 ? content : null + if (Predicate.isString(content)) return content.length > 0 ? content : null const text = content.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('') return text.length > 0 ? text : null @@ -237,7 +237,7 @@ export const makeSubagents = ( /** Resolve one registry entry's model binding: a role name reads the current profiles map. */ const resolveModelBinding = (binding: SubagentModelBinding): Effect.Effect => - typeof binding === 'string' ? profiles.resolve(binding) : Effect.succeed(binding) + isProfileRole(binding) ? profiles.resolve(binding) : Effect.succeed(binding) const appendToEventLog = (input: LogEntryInput): Effect.Effect => eventLog.append(input).pipe(Effect.orDie) diff --git a/packages/fold-core/src/Tools/EditEngine.ts b/packages/fold-core/src/Tools/EditEngine.ts index 891b1fd..dfb1d85 100644 --- a/packages/fold-core/src/Tools/EditEngine.ts +++ b/packages/fold-core/src/Tools/EditEngine.ts @@ -6,7 +6,7 @@ * byte-identical even when a normalized match was needed; BOM and CRLF endings are preserved. Error * strings are pi's, verbatim. Pure and isomorphic: platform handlers do the file IO around it. */ -import { Effect, Schema } from 'effect' +import { Effect, Predicate, Schema } from 'effect' /** One targeted replacement: exact old text and its replacement. */ export type EditPair = { @@ -298,16 +298,17 @@ export const applyEdits = (input: { } }) -const isEditPair = (value: unknown): value is EditPair => { - if (typeof value !== 'object' || value === null) return false - if (!('oldText' in value) || !('newText' in value)) return false - return typeof value.oldText === 'string' && typeof value.newText === 'string' -} +const EditPairSchema = Schema.Struct({ + oldText: Schema.String, + newText: Schema.String, +}) +const decodeEditPairsFromJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(EditPairSchema))) /** * Normalize edit-tool input into an edit batch (pi's `prepareEditArguments` + `validateEditInput`): * accepts the batch form, a JSON-string edits array (some models stringify it), and the legacy - * top-level oldText/newText pair, which appends as the final edit. + * top-level oldText/newText pair, which appends as the final edit. The tool contract already decoded + * the wire shape; this parses the remaining JSON-string and legacy-pair forms into one batch. */ export const normalizeEditInput = (input: { readonly edits?: ReadonlyArray | string | undefined @@ -318,27 +319,14 @@ export const normalizeEditInput = (input: { const invalidEdits = new EditEngineError({ message: 'Edit tool input is invalid. edits must be an array of {oldText, newText}.', }) - let edits: Array = [] - - if (typeof input.edits === 'string') { - const editsText = input.edits - const parsed = yield* Effect.try({ - try: (): unknown => JSON.parse(editsText), - catch: () => invalidEdits, - }) - if (!Array.isArray(parsed)) return yield* invalidEdits - - for (const item of parsed) { - if (!isEditPair(item)) return yield* invalidEdits - edits.push({ oldText: item.oldText, newText: item.newText }) - } - } else if (input.edits !== undefined) { - edits = [...input.edits] - } - - if (typeof input.oldText === 'string' && typeof input.newText === 'string') { - edits.push({ oldText: input.oldText, newText: input.newText }) - } + const fromEdits = Predicate.isString(input.edits) + ? yield* decodeEditPairsFromJson(input.edits).pipe(Effect.mapError(() => invalidEdits)) + : (input.edits ?? []) + const legacy = + Predicate.isString(input.oldText) && Predicate.isString(input.newText) + ? [{ oldText: input.oldText, newText: input.newText }] + : [] + const edits = [...fromEdits, ...legacy] if (edits.length === 0) { return yield* new EditEngineError({ diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimePrefix.vi.test.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimePrefix.vi.test.ts index f186b49..e72c3e4 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimePrefix.vi.test.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimePrefix.vi.test.ts @@ -1,5 +1,5 @@ import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Effect, Predicate } from 'effect' import { AgentRuntime } from '../../src/index' import { makeScriptedLanguageModel, textTurn } from '../TestLayers/ScriptedLanguageModel' @@ -8,13 +8,13 @@ import { agentRuntimeBaseLayer, runInput, startInput } from './AgentRuntimeTestH const withoutCacheControl = (value: unknown): unknown => { if (Array.isArray(value)) return value.map(withoutCacheControl) - if (typeof value !== 'object' || value === null) return value + if (!Predicate.isObject(value)) return value const out: Record = {} for (const [key, nested] of Object.entries(value)) { if (key === 'cacheControl') continue const normalized = withoutCacheControl(nested) - if (key === 'anthropic' && typeof normalized === 'object' && normalized !== null) { + if (key === 'anthropic' && Predicate.isObject(normalized)) { if (Object.keys(normalized).length === 0) continue } out[key] = normalized @@ -24,7 +24,7 @@ const withoutCacheControl = (value: unknown): unknown => { const stablePromptJson = (value: unknown): string => JSON.stringify(withoutCacheControl(value), (key, nested) => { - if (key.length === 0 || Array.isArray(nested) || typeof nested !== 'object' || nested === null) return nested + if (key.length === 0 || Array.isArray(nested) || !Predicate.isObject(nested)) return nested return Object.fromEntries(Object.entries(nested).sort(([left], [right]) => left.localeCompare(right))) }) diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimeToolRun.vi.test.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimeToolRun.vi.test.ts index b271439..a77c087 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimeToolRun.vi.test.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimeToolRun.vi.test.ts @@ -47,7 +47,7 @@ it.effect('runs a tool turn end to end, rewriting and restoring provider tool-ca Predicate.isTagged(entry, 'assistant-message'), ) const assistantContent = assistant?.message.content - if (typeof assistantContent === 'string' || assistantContent === undefined) { + if (Predicate.isString(assistantContent) || assistantContent === undefined) { throw new Error('expected structured assistant content') } const persistedToolCall = assistantContent.find((part) => part.type === 'tool-call') diff --git a/packages/fold-core/test/Api/StartSession.vi.test.ts b/packages/fold-core/test/Api/StartSession.vi.test.ts index 5489799..accf584 100644 --- a/packages/fold-core/test/Api/StartSession.vi.test.ts +++ b/packages/fold-core/test/Api/StartSession.vi.test.ts @@ -119,8 +119,9 @@ it.effect('injects a skill as a linked synthetic tool call and result without a const injected = yield* session.injectSkill('terminal-control', 'terminal instructions') const entries = yield* session.entries - const callPart = - typeof injected.call.message.content === 'string' ? undefined : injected.call.message.content[0] + const callPart = Predicate.isString(injected.call.message.content) + ? undefined + : injected.call.message.content[0] const resultPart = injected.result.message.content[0] if (callPart?.type !== 'tool-call') throw new Error('expected injected skill tool call') if (resultPart?.type !== 'tool-result') throw new Error('expected injected skill tool result') diff --git a/packages/fold-core/test/Model/RequestBuilderImages.vi.test.ts b/packages/fold-core/test/Model/RequestBuilderImages.vi.test.ts index 3183b2d..aab4b29 100644 --- a/packages/fold-core/test/Model/RequestBuilderImages.vi.test.ts +++ b/packages/fold-core/test/Model/RequestBuilderImages.vi.test.ts @@ -1,5 +1,5 @@ import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Effect, Predicate } from 'effect' import type { Prompt } from 'effect/unstable/ai' import { buildPrompt, imageOmittedPlaceholder, MessageId, ToolCallId, type ProjectedMessage } from '../../src/index' @@ -82,7 +82,7 @@ it.effect('lifts multiple image blocks in result order', () => const followUp = prompt.content[2] if (followUp?.role !== 'user') throw new Error('expected a trailing user message') const fileParts = followUp.content.filter((part) => part.type === 'file') - expect(fileParts.map((part) => (typeof part.data === 'string' ? part.data : null))).toEqual([ + expect(fileParts.map((part) => (Predicate.isString(part.data) ? part.data : null))).toEqual([ 'Zmlyc3Q=', 'c2Vjb25k', ]) diff --git a/packages/fold-core/test/Skills/SkillTool.vi.test.ts b/packages/fold-core/test/Skills/SkillTool.vi.test.ts index 688b0fb..8ad10e5 100644 --- a/packages/fold-core/test/Skills/SkillTool.vi.test.ts +++ b/packages/fold-core/test/Skills/SkillTool.vi.test.ts @@ -1,6 +1,6 @@ import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { describe, expect, it } from '@effect/vitest' -import { Effect, Layer, Ref } from 'effect' +import { Effect, Layer, Option, Ref, Schema } from 'effect' import { AgentId, @@ -36,10 +36,12 @@ const ambientServices = Layer.mergeAll( NodeFileSystem.layer, ) +const SkillToolResult = Schema.Struct({ content: Schema.String }) +const decodeSkillToolResult = Schema.decodeUnknownOption(SkillToolResult) + const skillContentOf = (result: unknown): string => { - if (typeof result === 'object' && result !== null && 'content' in result && typeof result.content === 'string') { - return result.content - } + const decoded = Option.getOrUndefined(decodeSkillToolResult(result)) + if (decoded !== undefined) return decoded.content throw new Error('expected a skill tool result with string content') } diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeDefect.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeDefect.vi.test.ts index 7a46f5f..b43c4d6 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeDefect.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeDefect.vi.test.ts @@ -79,8 +79,8 @@ it.effect('truncates long defect messages before projecting them to the model', const result = projectedToolResultPart(projected).result - expect(typeof result).toBe('string') - if (typeof result !== 'string') return + expect(Predicate.isString(result)).toBe(true) + if (!Predicate.isString(result)) return expect(result).toContain('Tool "echo" failed unexpectedly: prefix ') expect(result).toContain('...') expect(result).not.toContain('suffix') diff --git a/packages/fold-xai/src/XaiModel.ts b/packages/fold-xai/src/XaiModel.ts index 1d0e134..11d2a3b 100644 --- a/packages/fold-xai/src/XaiModel.ts +++ b/packages/fold-xai/src/XaiModel.ts @@ -8,7 +8,7 @@ import type { import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { customModel, resolveOpenAiReasoning } from '@humanlayer/fold-core' import type { FoldModel, ReasoningLevel } from '@humanlayer/fold-core' -import { Context, Effect, Layer, Option, Predicate, Schema, Stream } from 'effect' +import { Context, Effect, Layer, Match, Option, Predicate, Schema, Stream } from 'effect' import type { Scope } from 'effect' import type { LanguageModel } from 'effect/unstable/ai' import { FetchHttpClient, HttpClient } from 'effect/unstable/http' @@ -48,7 +48,11 @@ const normalizeXaiResponse = - typeof response === 'string' || '_tag' in response ? response : normalizeXaiResponse(response) + Match.value(response).pipe( + Match.when('[DONE]', (done) => done), + Match.tag('UnknownChatCompletionEvent', (event) => event), + Match.orElse(normalizeXaiResponse), + ) /** Normalize xAI's token semantics before the stock OpenAI-compatible model derives usage details. */ export const decorateXaiClient = (inner: OpenAiClient.Service): OpenAiClient.Service => ({ diff --git a/scripts/build/packages.ts b/scripts/build/packages.ts index e15413e..d43e904 100644 --- a/scripts/build/packages.ts +++ b/scripts/build/packages.ts @@ -1,6 +1,8 @@ import { mkdir, rm } from 'node:fs/promises' import { join } from 'node:path' +import { Predicate } from 'effect' + import { libraries, root, json } from '../release/manifest' const version = process.argv.find((_, index, args) => args[index - 1] === '--version') ?? '0.0.0' @@ -17,7 +19,7 @@ for (const name of libraries) { }>(join(dir, 'package.json')) const exports = Object.values(manifest.exports) const entries = exports - .map((value) => (typeof value === 'string' ? value : value.source)) + .map((value) => (Predicate.isString(value) ? value : value.source)) .filter((entry): entry is string => entry !== undefined) for (const entry of Object.values(manifest.bin ?? {})) { const sourceEntry = entry.replace(/^(?:\.\/)?dist\//, './src/').replace(/\.js$/, '.ts') diff --git a/scripts/release/prepare.ts b/scripts/release/prepare.ts index 3921f44..cca11ed 100644 --- a/scripts/release/prepare.ts +++ b/scripts/release/prepare.ts @@ -2,6 +2,8 @@ import { chmod, cp, mkdir, rm } from 'node:fs/promises' import { join } from 'node:path' import { parseArgs } from 'node:util' +import { Predicate } from 'effect' + import { internal, json, libraries, root, stage, targetName, targets } from './manifest' const version = parseArgs({ options: { version: { type: 'string' } } }).values.version @@ -51,7 +53,7 @@ function dependencies(manifest: PackageManifest) { (() => { throw new Error(`Missing catalog entry ${name}`) })() - if (typeof range === 'string' && range.startsWith('workspace:')) { + if (Predicate.isString(range) && range.startsWith('workspace:')) { if (internal.has(name)) delete dependencyMap[name] else dependencyMap[name] = version } @@ -75,9 +77,9 @@ for (const packageDir of libraries) { const rewrite = (value: string) => value.replace(/^\.\/src\//, './dist/').replace(/\.(tsx?|jsx?)$/, '.js') const dts = (value: string) => rewrite(value).replace(/\.js$/, '.d.ts') const firstExport = Object.values(manifest.exports)[0] - const mainSource = typeof firstExport === 'string' ? firstExport : (firstExport?.source ?? './src/index.ts') + const mainSource = Predicate.isString(firstExport) ? firstExport : (firstExport?.source ?? './src/index.ts') for (const [key, value] of Object.entries(manifest.exports)) { - const sourcePath = typeof value === 'string' ? value : value.source + const sourcePath = Predicate.isString(value) ? value : value.source manifest.exports[key] = { types: dts(sourcePath), import: rewrite(sourcePath), default: rewrite(sourcePath) } } manifest.module = rewrite(mainSource) diff --git a/tools/oxlint/anti-slop/index.ts b/tools/oxlint/anti-slop/index.ts index 3917980..84fe85f 100644 --- a/tools/oxlint/anti-slop/index.ts +++ b/tools/oxlint/anti-slop/index.ts @@ -4,6 +4,7 @@ import { noConditionalEmptyObjectSpreadRule } from './rules/no-conditional-empty import { noModuleMockingRule } from './rules/no-module-mocking.ts' import { noObjectParametersRule } from './rules/no-object-parameters.ts' import { noReflectApplyRule } from './rules/no-reflect-apply.ts' +import { noRuntimeTypeofRule } from './rules/no-runtime-typeof.ts' export default eslintCompatPlugin({ meta: { name: 'anti-slop' }, @@ -12,5 +13,6 @@ export default eslintCompatPlugin({ 'no-module-mocking': noModuleMockingRule, 'no-object-parameters': noObjectParametersRule, 'no-reflect-apply': noReflectApplyRule, + 'no-runtime-typeof': noRuntimeTypeofRule, }, }) diff --git a/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts new file mode 100644 index 0000000..ceb3804 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts @@ -0,0 +1,53 @@ +import { defineRule, type ESTree } from '@oxlint/plugins' + +type RuntimeFunction = ESTree.ArrowFunctionExpression | ESTree.Function + +const isRuntimeFunction = (node: ESTree.Node): node is RuntimeFunction => + node.type === 'ArrowFunctionExpression' || + node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' + +const isInsideTypeGuard = (node: ESTree.Node): boolean => { + let current: ESTree.Node | null = node.parent + while (current !== null && current.type !== 'Program') { + if (isRuntimeFunction(current)) return current.returnType?.typeAnnotation.type === 'TSTypePredicate' + current = current.parent + } + return false +} + +export const noRuntimeTypeofRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Disallow runtime typeof checks; external values must be decoded at their I/O boundary.', + }, + messages: { + runtimeTypeof: + 'A `typeof` check narrows a representation without establishing its contract. Decode input at its I/O boundary, then use Predicate, Match, or typed Effect error handling.', + }, + schema: [ + { + type: 'object', + properties: { allowInTypeGuards: { type: 'boolean' } }, + additionalProperties: false, + }, + ], + defaultOptions: [{ allowInTypeGuards: false }], + }, + createOnce(context) { + return { + UnaryExpression(node) { + const option = context.options?.[0] + const allowInTypeGuards = + typeof option === 'object' && + option !== null && + !Array.isArray(option) && + option.allowInTypeGuards === true + if (node.operator === 'typeof' && (!allowInTypeGuards || !isInsideTypeGuard(node))) { + context.report({ node, messageId: 'runtimeTypeof' }) + } + }, + } + }, +})