Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions packages/fold-agent/scripts/update-managed-binaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

import { Effect, Schema } from 'effect'
import { Effect, Predicate, Schema } from 'effect'

import { MANAGED_BINARY_PLATFORMS, managedBinaryRegistry } from '../src/Bin/Registry'

Expand All @@ -33,7 +33,9 @@ const fetchBytes = (url: string): Effect.Effect<Uint8Array, ChecksumBakeError> =
return new Uint8Array(await response.arrayBuffer())
},
catch: (cause) =>
new ChecksumBakeError({ message: `GET ${url}: ${cause instanceof Error ? cause.message : String(cause)}` }),
new ChecksumBakeError({
message: `GET ${url}: ${Predicate.isError(cause) ? cause.message : String(cause)}`,
}),
}).pipe(
Effect.timeout(downloadTimeoutMillis),
Effect.catchTag('TimeoutError', () =>
Expand Down
6 changes: 3 additions & 3 deletions packages/fold-agent/src/Catalog/LoadCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import { dirname, join } from 'node:path'

import { ModelCatalogEntry } from '@humanlayer/fold-core'
import { Clock, Effect, Schema, type FileSystem } from 'effect'
import { Clock, Effect, Predicate, Schema, type FileSystem } from 'effect'

import { fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem'
import { bakedModelCatalog } from './BakedCatalog'
Expand Down Expand Up @@ -75,7 +75,7 @@ export const modelCatalogCachePath = (foldHome: string): string => join(foldHome

/** The ONE mapper from thrown fetch failures to the typed catalog fetch error. */
const catalogFetchErrorFrom = (cause: unknown): CatalogFetchError =>
new CatalogFetchError({ message: cause instanceof Error ? cause.message : String(cause) })
new CatalogFetchError({ message: Predicate.isError(cause) ? cause.message : String(cause) })

const defaultFetchJson = (url: string): Effect.Effect<unknown, CatalogFetchError> =>
Effect.tryPromise({
Expand Down Expand Up @@ -104,7 +104,7 @@ const readCache = (fs: FileSystem.FileSystem, path: string): Effect.Effect<Model
return yield* Effect.try({
try: (): unknown => JSON.parse(text),
catch: (cause) =>
new CatalogCacheParseError({ message: cause instanceof Error ? cause.message : String(cause) }),
new CatalogCacheParseError({ message: Predicate.isError(cause) ? cause.message : String(cause) }),
}).pipe(
Effect.flatMap((parsed) => decodeCache(parsed)),
Effect.catch((error) =>
Expand Down
4 changes: 2 additions & 2 deletions packages/fold-agent/src/Config/Load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import { homedir } from 'node:os'
import { join } from 'node:path'

import { Effect, Schema } from 'effect'
import { Effect, Predicate, Schema } from 'effect'

import { fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem'
import { FoldConfig } from './ConfigSchema'
Expand Down Expand Up @@ -135,7 +135,7 @@ export const parseFoldConfig = (
const parsed = yield* Effect.try({
try: (): unknown => JSON.parse(stripJsonc(text)),
catch: (cause) =>
new ConfigParseError({ path, message: cause instanceof Error ? cause.message : String(cause) }),
new ConfigParseError({ path, message: Predicate.isError(cause) ? cause.message : String(cause) }),
})

return yield* decodeConfig(parsed).pipe(
Expand Down
8 changes: 4 additions & 4 deletions packages/fold-agent/src/Tools/WebFetchTool.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineTool, webFetchToolContract, type FoldTool } from '@humanlayer/fold-core'
import { Effect } from 'effect'
import { Effect, Predicate } from 'effect'

const maxResponseSize = 5 * 1024 * 1024
const defaultTimeoutMs = 30_000
Expand Down Expand Up @@ -65,7 +65,7 @@ const readBody = (response: Response): Effect.Effect<string, { message: string }

return new TextDecoder().decode(bytes)
},
catch: (error) => ({ message: error instanceof Error ? error.message : String(error) }),
catch: (error) => ({ message: Predicate.isError(error) ? error.message : String(error) }),
})

export const webFetchTool = (): FoldTool =>
Expand All @@ -90,9 +90,9 @@ export const webFetchTool = (): FoldTool =>
}),
catch: (error) => ({
message:
error instanceof Error && error.name === 'AbortError'
Predicate.isError(error) && error.name === 'AbortError'
? `Request timed out after ${timeoutMs}ms`
: error instanceof Error
: Predicate.isError(error)
? error.message
: String(error),
}),
Expand Down
10 changes: 5 additions & 5 deletions packages/fold-agent/src/Tools/WebSearchTool.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CurrentAgent, defineTool, webSearchToolContract, type FoldTool } from '@humanlayer/fold-core'
import { Effect } from 'effect'
import { Effect, Predicate } from 'effect'

const defaultTimeoutMs = 25_000
const maxNumResults = 20
Expand Down Expand Up @@ -88,7 +88,7 @@ const parseMcpResponse = (body: string): Effect.Effect<string | undefined, { mes
return undefined
},
catch: (error) => ({
message: `Failed to parse web search response: ${error instanceof Error ? error.message : String(error)}`,
message: `Failed to parse web search response: ${Predicate.isError(error) ? error.message : String(error)}`,
}),
})

Expand Down Expand Up @@ -123,9 +123,9 @@ const callMcp = (input: {
}),
catch: (error) => ({
message:
error instanceof Error && error.name === 'AbortError'
Predicate.isError(error) && error.name === 'AbortError'
? `${input.tool} request timed out`
: error instanceof Error
: Predicate.isError(error)
? error.message
: String(error),
}),
Expand All @@ -140,7 +140,7 @@ const callMcp = (input: {
const body = yield* Effect.tryPromise({
try: () => response.text(),
catch: (error) => ({
message: `Failed to read web search response: ${error instanceof Error ? error.message : String(error)}`,
message: `Failed to read web search response: ${Predicate.isError(error) ? error.message : String(error)}`,
}),
})
return yield* parseMcpResponse(body)
Expand Down
6 changes: 3 additions & 3 deletions packages/fold-agent/test/EventLog/EventLogJsonl.vi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
StateId,
type LogEntryInput,
} from '@humanlayer/fold-core'
import { Effect, Fiber, FileSystem, Stream } from 'effect'
import { Effect, Fiber, FileSystem, Predicate, Stream } from 'effect'

import { layerJsonl } from '../../src/index'

Expand Down Expand Up @@ -105,7 +105,7 @@ it.effect('jsonl layer maps invalid persisted lines to EventLogCorruptEntryError
return yield* Stream.runCollect(log.entries())
}).pipe(Effect.provide(layerJsonl(filePath)), Effect.flip)

if (!(error instanceof EventLogCorruptEntryError)) {
if (!Predicate.isTagged(error, 'EventLogCorruptEntryError')) {
throw new Error(`expected EventLogCorruptEntryError, got ${error._tag}`)
}
expect(error.line).toBe(1)
Expand Down Expand Up @@ -201,7 +201,7 @@ it.effect('jsonl layer rejects event formats newer than the installed Fold runti
}).pipe(Effect.provide(layerJsonl(filePath)), Effect.flip)

expect(error).toBeInstanceOf(EventLogUnsupportedVersionError)
if (error instanceof EventLogUnsupportedVersionError) {
if (Predicate.isTagged(error, 'EventLogUnsupportedVersionError')) {
expect(error.version).toBe(2)
expect(error.supportedVersions).toEqual([1])
}
Expand Down
6 changes: 3 additions & 3 deletions packages/fold-codex/src/Hardening.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,19 +65,19 @@ export const codexAcquisitionStallError = (timeoutMs: number): AiError.AiError =

/** True for the first-event stall errors this package mints (the only retryable stall class). */
export const isCodexFirstEventStall = (error: unknown): error is AiError.AiError =>
error instanceof AiError.AiError && error.module === CODEX_ERROR_MODULE && error.method === FIRST_EVENT_METHOD
AiError.isAiError(error) && error.module === CODEX_ERROR_MODULE && error.method === FIRST_EVENT_METHOD

/** True for the mid-stream idle stall errors this package mints. */
export const isCodexIdleStall = (error: unknown): error is AiError.AiError =>
error instanceof AiError.AiError && error.module === CODEX_ERROR_MODULE && error.method === IDLE_METHOD
AiError.isAiError(error) && error.module === CODEX_ERROR_MODULE && error.method === IDLE_METHOD

/**
* A retryable provider failure is safe to repeat only before the model has emitted any stream event.
* Mid-stream failures are intentionally excluded because a fresh request could duplicate content or
* repeat a tool call that has already reached the agent runtime.
*/
export const isCodexRetryableBeforeFirstEvent = (error: unknown): error is AiError.AiError =>
error instanceof AiError.AiError && error.isRetryable && !isCodexIdleStall(error)
AiError.isAiError(error) && error.isRetryable && !isCodexIdleStall(error)

/**
* Bound the stream's producer latency: the first event must arrive within `firstEventTimeoutMs` and
Expand Down
6 changes: 4 additions & 2 deletions packages/fold-codex/test/CodexAuth.vi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { describe, expect, it } from '@effect/vitest'
import { Effect, Layer, Option } from 'effect'
import { Effect, Layer, Option, Predicate } from 'effect'
import { FetchHttpClient, type HttpClient } from 'effect/unstable/http'

import {
Expand All @@ -22,6 +22,8 @@ const jwtWith = (claims: Record<string, unknown>): string =>

type RecordedRequest = { readonly url: string; readonly body: string }

const isWebRequest = (input: string | URL | Request): input is Request => Predicate.hasProperty(input, 'url')

/** A FetchHttpClient layer whose network is a scripted function, recording every request it serves. */
const scriptedFetchLayer = (
respond: (request: RecordedRequest) => Response,
Expand All @@ -31,7 +33,7 @@ const scriptedFetchLayer = (
// Bun's `typeof fetch` carries a `preconnect` property; borrow the real one alongside the fake body.
const fakeFetch: typeof fetch = Object.assign(
async (input: string | URL | Request, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(String(input), init)
const request = isWebRequest(input) ? input : new Request(String(input), init)
const recorded = { url: request.url, body: await request.clone().text() }
requests.push(recorded)
return respond(recorded)
Expand Down
4 changes: 2 additions & 2 deletions packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* those deltas are ephemeral and never persisted. Model provider failures become durable error +
* agent-finished entries, never service failures.
*/
import { Array as Arr, Cause, Effect, Exit, Layer, Ref, Result, Schema, Stream } from 'effect'
import { Array as Arr, Cause, Effect, Exit, Layer, Predicate, Ref, Result, Schema, Stream } from 'effect'
import { LanguageModel, Prompt, type Response, type Tool, type Toolkit } from 'effect/unstable/ai'

import { AgentEvents } from '../AgentEvents/AgentEventsService'
Expand Down Expand Up @@ -79,7 +79,7 @@ type CompactionEnvelope = Pick<CompactAgentInput, 'agentId' | 'parentAgentId' |

/** Derive a short human-readable message from a model provider failure. */
const describeModelError = (error: unknown): string => {
if (error instanceof Error) return error.message
if (Predicate.isError(error)) return error.message

try {
return JSON.stringify(error)
Expand Down
4 changes: 2 additions & 2 deletions packages/fold-core/src/Compaction/CompactionLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/
import { AnthropicLanguageModel } from '@effect/ai-anthropic'
import { OpenAiLanguageModel } from '@effect/ai-openai'
import { Effect, Stream } from 'effect'
import { Effect, Predicate, Stream } from 'effect'
import { LanguageModel, Prompt } from 'effect/unstable/ai'

import { ModelCatalog } from '../Model/ModelCatalog'
Expand Down Expand Up @@ -44,7 +44,7 @@ import {
export type EnabledAutoCompactConfig = Extract<AutoCompactConfig, { readonly enabled: true }>

const describeSummarizerError = (error: unknown): string => {
if (error instanceof Error) return error.message
if (Predicate.isError(error)) return error.message

try {
return JSON.stringify(error)
Expand Down
23 changes: 14 additions & 9 deletions packages/fold-core/src/HookRunner/Errors.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import type { Cause } from 'effect'
import { Data, Predicate, type Cause } from 'effect'

export type HookPhase = 'preRequest' | 'preToolUse' | 'postToolUse' | 'onComplete'

export class HookExecutionError extends Error {
readonly _tag = 'HookExecutionError'
export class HookExecutionError extends Data.TaggedError('HookExecutionError')<{
readonly phase: HookPhase
readonly hookName: string
readonly cause: Cause.Cause<never>
}> {
constructor(phase: HookPhase, hookName: string, cause: Cause.Cause<never>) {
super({ phase, hookName, cause })
}

constructor(
readonly phase: HookPhase,
readonly hookName: string,
override readonly cause: Cause.Cause<never>,
) {
super(`${phase} hook "${hookName}" failed`)
override get message(): string {
return `${this.phase} hook "${this.hookName}" failed`
}
}

export const isHookExecutionError = (error: unknown): error is HookExecutionError =>
Predicate.isTagged(error, 'HookExecutionError')
6 changes: 3 additions & 3 deletions packages/fold-core/src/ToolRuntime/ModelVisibleErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* result with an error message, D21) narrow raw Causes through these helpers, so the model always sees
* the same escaped, truncated, single-line description regardless of which boundary caught the defect.
*/
import { Cause } from 'effect'
import { Cause, Predicate } from 'effect'

const maxModelVisibleErrorMessageLength = 300

Expand All @@ -25,7 +25,7 @@ export const truncateModelVisibleErrorMessage = (message: string): string => {
}

const stringifyUnknown = (value: unknown): string => {
if (value instanceof Error) return value.message
if (Predicate.isError(value)) return value.message

try {
return JSON.stringify(value)
Expand All @@ -36,7 +36,7 @@ const stringifyUnknown = (value: unknown): string => {

/** Render an unknown thrown/failed value as safe model-visible text. */
export const modelVisibleErrorDetailsFromUnknown = (value: unknown): string => {
const raw = value instanceof Error ? value.message : stringifyUnknown(value)
const raw = Predicate.isError(value) ? value.message : stringifyUnknown(value)

return escapeSystemInformationContent(truncateModelVisibleErrorMessage(raw === '' ? 'unknown error' : raw))
}
Expand Down
4 changes: 2 additions & 2 deletions packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Prompt } from 'effect/unstable/ai'

import { EventLog } from '../EventLog/EventLogService'
import type { LogEntry, ToolResultLogEntry } from '../EventLog/Schemas'
import { HookExecutionError } from '../HookRunner/Errors'
import { isHookExecutionError, type HookExecutionError } from '../HookRunner/Errors'
import { HookRunner } from '../HookRunner/HookRunnerService'
import { Ids, ToolCallId, type AgentId } from '../Ids'
import { Subagents } from '../Subagents/SubagentsService'
Expand Down Expand Up @@ -159,7 +159,7 @@ const hookFailureResult = (error: HookExecutionError, toolName: string): string
const hookExecutionErrorFromCause = (cause: Cause.Cause<unknown>): HookExecutionError | undefined => {
const reason = cause.reasons.find(Cause.isFailReason)

return reason?.error instanceof HookExecutionError ? reason.error : undefined
return isHookExecutionError(reason?.error) ? reason.error : undefined
}

const failureResultFromCause = (toolName: string, cause: Cause.Cause<unknown>): string => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { it, expect } from '@effect/vitest'
import { Effect } from 'effect'
import { Effect, Predicate } from 'effect'

import {
AgentId,
Expand Down Expand Up @@ -60,7 +60,7 @@ it.effect('rejects an unsupported event format without guessing its schema', ()
const error = yield* decodeStoredLogEntry(sessionStartedEntry(2)).pipe(Effect.flip)

expect(error).toBeInstanceOf(EventLogUnsupportedVersionError)
if (error instanceof EventLogUnsupportedVersionError) {
if (Predicate.isTagged(error, 'EventLogUnsupportedVersionError')) {
expect(error.version).toBe(2)
expect(error.seq).toBe(0)
expect(error.supportedVersions).toEqual([1])
Expand Down
6 changes: 4 additions & 2 deletions packages/fold-core/test/Tools/PatchEngine.vi.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from '@effect/vitest'
import { Effect, Result } from 'effect'
import { Effect, Predicate, Result } from 'effect'

import {
applyChunks,
Expand Down Expand Up @@ -430,7 +430,9 @@ describe('computePatch', () => {
).pipe(Effect.result)

if (!Result.isFailure(result)) throw new Error('expected failure')
if (!(result.failure instanceof PatchFileNotFoundError)) throw new Error('expected PatchFileNotFoundError')
if (!Predicate.isTagged(result.failure, 'PatchFileNotFoundError')) {
throw new Error('expected PatchFileNotFoundError')
}
expect(result.failure.message).toBe('Failed to read file to update: gone.txt')
}),
)
Expand Down
4 changes: 4 additions & 0 deletions packages/fold-tui-theme/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
},
"devDependencies": {
"@types/react": "19.2.2",
"effect": "catalog:",
"typescript": "catalog:"
},
"peerDependencies": {
"effect": "catalog:"
}
}
Loading
Loading