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
22 changes: 22 additions & 0 deletions .oxlintrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
"automation/no-shadowed-standard-array-static": "error",
"automation/no-disable-validation": "error",
"automation/no-silent-error-swallow": "error",
"automation/no-manual-tag-comparison": "error",
"automation/no-manual-tagged-construction": "error",
"automation/prefer-effect-match": "error",
"automation/prefer-tagged-error-handling": "error",
"automation/no-ambient-nondeterminism": "error",
"anti-slop/no-reflect-apply": "error",
"typescript/consistent-type-imports": ["error", { "fixStyle": "inline-type-imports" }],
Expand Down Expand Up @@ -73,10 +76,29 @@
"automation/no-disable-validation": "off",
"automation/no-shadowed-standard-array-static": "off",
"automation/no-silent-error-swallow": "off",
"automation/no-manual-tag-comparison": "off",
"automation/no-manual-tagged-construction": "off",
"automation/prefer-effect-match": "off",
"automation/prefer-tagged-error-handling": "off",
"automation/no-ambient-nondeterminism": "off",
},
},
{
// Runtime tagged values in tests, fixtures, and examples are often deliberate raw inputs.
"files": [
"**/test/**",
"**/tests/**",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.vi.test.ts",
"**/*.vi.test.tsx",
"**/fixtures/**",
"**/examples/**",
],
"rules": {
"automation/no-manual-tagged-construction": "off",
},
},
{
// no-ambient-nondeterminism targets production seams. Tests deliberately use fixed or real
// time and node crypto, driven through explicit layers, so the rule is off for test files.
Expand Down
4 changes: 2 additions & 2 deletions packages/fold-agent/examples/ApplyPatchAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { defineAgent, openaiModel, startSession } from '@humanlayer/fold-core'
import { Console, Effect } from 'effect'
import { Predicate, Console, Effect } from 'effect'

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

Expand Down Expand Up @@ -41,7 +41,7 @@ const makeProgram = (apiKey: string) =>
)
const entries = yield* session.entries
const toolNames = entries.flatMap((entry) =>
entry._tag === 'tool-result'
Predicate.isTagged(entry, 'tool-result')
? [entry.message.content[0]?.type === 'tool-result' ? entry.message.content[0].name : '']
: [],
)
Expand Down
6 changes: 4 additions & 2 deletions packages/fold-agent/examples/CodingAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { anthropicModel, defineAgent, startSession } from '@humanlayer/fold-core'
import { Console, Effect } from 'effect'
import { Predicate, Console, Effect } from 'effect'

import { codingTools, jsonlEventLog } from '../src/index'

Expand Down Expand Up @@ -46,7 +46,9 @@ const makeProgram = (apiKey: string) =>
yield* Console.log(`finished: ${finished.outcome}`)
yield* Console.log(`result: ${finished.resultText ?? '(no text)'}`)
yield* Console.log(`log rows: ${entries.length} (persisted to ${logPath})`)
yield* Console.log(`tools used: ${entries.filter((entry) => entry._tag === 'tool-result').length} tool results`)
yield* Console.log(
`tools used: ${entries.filter((entry) => Predicate.isTagged(entry, 'tool-result')).length} tool results`,
)
}).pipe(Effect.scoped)

if (apiKey === undefined || apiKey === '') {
Expand Down
12 changes: 7 additions & 5 deletions packages/fold-agent/examples/SubagentsAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { anthropicModel, defineAgent, defineSubagent, startSession, subagentTool } from '@humanlayer/fold-core'
import { Console, Effect } from 'effect'
import { Predicate, Console, Effect } from 'effect'

import { bashTool, jsonlEventLog, readTool } from '../src/index'

Expand Down Expand Up @@ -75,14 +75,16 @@ const makeProgram = (apiKey: string) =>

// Read the story back off the durable log: one subagent, resumed under a second tool call.
const entries = yield* session.entries
const subagentStarts = entries.filter((entry) => entry._tag === 'agent_started' && entry.parentAgentId !== null)
const researcherId = subagentStarts[0]?._tag === 'agent_started' ? subagentStarts[0].agentId : null
const subagentStarts = entries.filter(
(entry) => Predicate.isTagged(entry, 'agent_started') && entry.parentAgentId !== null,
)
const researcherId = Predicate.isTagged(subagentStarts[0], 'agent_started') ? subagentStarts[0].agentId : null
const researcherTurns = entries.filter(
(entry) => entry._tag === 'assistant-message' && entry.agentId === researcherId,
(entry) => Predicate.isTagged(entry, 'assistant-message') && entry.agentId === researcherId,
).length
const researcherCalls = new Set(
entries
.filter((entry) => entry._tag === 'user-message' && entry.agentId === researcherId)
.filter((entry) => Predicate.isTagged(entry, 'user-message') && entry.agentId === researcherId)
.map((entry) => entry.toolCallId),
).size

Expand Down
4 changes: 2 additions & 2 deletions packages/fold-agent/src/Config/ModelSelections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { DEFAULT_CODEX_MODEL_ID } from '@humanlayer/fold-codex'
import { DEFAULT_ANTHROPIC_MODEL_ID, type ModelCatalogEntry, type FoldModel } from '@humanlayer/fold-core'
import { DEFAULT_OPENCODE_MODEL_ID, GROK_BUILD_MODEL_ID } from '@humanlayer/fold-opencode'
import { DEFAULT_XAI_MODEL_ID } from '@humanlayer/fold-xai'
import { Effect, Match } from 'effect'
import { Predicate, Effect, Match } from 'effect'

import { agentModelsFromConfig, type AgentModelsOptions, RoleResolutionError } from './AgentModels'
import type { ConfigRole, ProfileConfig, ProfileModeName, RoleBinding, FoldConfig } from './ConfigSchema'
Expand Down Expand Up @@ -169,7 +169,7 @@ export const resolveConfiguredModelSelection = (
const rootRole = roleForMode(mode)
return Effect.gen(function* () {
let roles = config.roles
if (selection._tag === 'profile') {
if (Predicate.isTagged(selection, 'profile')) {
const profileRoles = rolesForProfile(config, selection.profile)
if (profileRoles === null) {
return yield* new RoleResolutionError({
Expand Down
8 changes: 4 additions & 4 deletions packages/fold-agent/src/Fs/MutationQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ const queueKey = (fs: FileSystem.FileSystem, path: string): Effect.Effect<string
const resolved = resolve(path)

return fs.realPath(resolved).pipe(
Effect.catchIf(
(error) => error.reason._tag === 'NotFound' || error.reason._tag === 'BadResource',
() => Effect.succeed(resolved),
),
Effect.catchReasons('PlatformError', {
NotFound: () => Effect.succeed(resolved),
BadResource: () => Effect.succeed(resolved),
}),
)
}

Expand Down
9 changes: 6 additions & 3 deletions packages/fold-agent/src/Mode/Launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
type FoldTool,
type Ids,
} from '@humanlayer/fold-core'
import { Effect, Match, Schema, Semaphore, type Scope } from 'effect'
import { Predicate, Effect, Match, Schema, Semaphore, type Scope } from 'effect'

import { loadModelCatalog } from '../Catalog/LoadCatalog'
import { agentModelsFromConfig, type EnvLookup, type RoleResolutionError } from '../Config/AgentModels'
Expand Down Expand Up @@ -428,9 +428,12 @@ const withGeneratedTitles = (
Effect.flatMap((entries) => {
const rootUsers = entries.filter(
(entry) =>
entry._tag === 'user-message' && entry.agentId === session.rootAgentId,
Predicate.isTagged(entry, 'user-message') &&
entry.agentId === session.rootAgentId,
)
const lastTitle = entries.findLast((entry) =>
Predicate.isTagged(entry, 'session_title'),
)
const lastTitle = entries.findLast((entry) => entry._tag === 'session_title')
const generatedTurns = lastTitle?.rootUserTurns ?? 0
if (rootUsers.length <= generatedTurns) return Effect.void
return generateSessionTitle(entries, session.rootAgentId, model).pipe(
Expand Down
32 changes: 18 additions & 14 deletions packages/fold-agent/src/Session/SessionLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { join } from 'node:path'

import { SessionId, makeSessionId, usageInputTotal } from '@humanlayer/fold-core'
import type { ActiveModel, LogEntry, FoldEventLog, Ids } from '@humanlayer/fold-core'
import { Clock, Effect, Exit, Match, Option, Schema, Stream } from 'effect'
import { Predicate, Clock, Effect, Exit, Match, Option, Schema, Stream } from 'effect'

import { jsonlEventLog } from '../EventLog/JsonlDescriptor'
import { fileSystemFor, type FsToolOptions } from '../Fs/DefaultFileSystem'
Expand Down Expand Up @@ -199,26 +199,26 @@ export const listSessionLogs = (options?: SessionLayoutOptions): Effect.Effect<R

// Type-safe entry predicates that narrow the LogEntry union.
const isSessionStarted = (entry: LogEntry): entry is Extract<LogEntry, { readonly _tag: 'session_started' }> =>
entry._tag === 'session_started'
Predicate.isTagged(entry, 'session_started')

const isSessionTitle = (entry: LogEntry): entry is Extract<LogEntry, { readonly _tag: 'session_title' }> =>
entry._tag === 'session_title'
Predicate.isTagged(entry, 'session_title')

const isUserMessage = (entry: LogEntry): entry is Extract<LogEntry, { readonly _tag: 'user-message' }> =>
entry._tag === 'user-message'
Predicate.isTagged(entry, 'user-message')

const isAgentFinished = (entry: LogEntry): entry is Extract<LogEntry, { readonly _tag: 'agent-finished' }> =>
entry._tag === 'agent-finished'
Predicate.isTagged(entry, 'agent-finished')

type ModelCarrier = Extract<LogEntry, { readonly _tag: 'agent_started' | 'model-change' }>
const carriesModel = (entry: LogEntry): entry is ModelCarrier =>
entry._tag === 'agent_started' || entry._tag === 'model-change'
Predicate.isTagged(entry, 'agent_started') || Predicate.isTagged(entry, 'model-change')

type FinishedAssistantMessage = Extract<LogEntry, { readonly _tag: 'assistant-message' }> & {
readonly finish: NonNullable<Extract<LogEntry, { readonly _tag: 'assistant-message' }>['finish']>
}
const isFinishedAssistantMessage = (entry: LogEntry): entry is FinishedAssistantMessage =>
entry._tag === 'assistant-message' && entry.finish !== null
Predicate.isTagged(entry, 'assistant-message') && entry.finish !== null

const userMessageText = (entry: Extract<LogEntry, { readonly _tag: 'user-message' }>): string => {
const content = entry.message.content
Expand All @@ -233,7 +233,8 @@ const computeStatus = (
): SessionSummary['status'] => {
// No finish yet, or activity after the last finish → derive from latest activity
if (lastFinished === undefined || (latestRootEntry !== undefined && latestRootEntry.seq > lastFinished.seq)) {
return latestRootEntry?._tag === 'system-message' || latestRootEntry?._tag === 'agent_started'
return Predicate.isTagged(latestRootEntry, 'system-message') ||
Predicate.isTagged(latestRootEntry, 'agent_started')
? 'ready'
: 'running'
}
Expand Down Expand Up @@ -306,7 +307,7 @@ const isCacheHit = (
ref: SessionLogRef,
): cached is typeof SummaryIndexRecord.Type =>
cached !== undefined &&
cached._tag === 'summary' &&
Predicate.isTagged(cached, 'summary') &&
cached.sourceMtimeMs === ref.mtimeMs &&
cached.sourceSize === (ref.size ?? 0)

Expand Down Expand Up @@ -344,7 +345,11 @@ export const listSessionSummaries = (options?: SessionLayoutOptions): Effect.Eff
summary === null
? Effect.void
: appendSessionIndexRecord(
{ _tag: 'summary', sourceMtimeMs: ref.mtimeMs, sourceSize: ref.size ?? 0, summary },
SummaryIndexRecord.make({
sourceMtimeMs: ref.mtimeMs,
sourceSize: ref.size ?? 0,
summary,
}),
options,
),
),
Expand Down Expand Up @@ -376,7 +381,7 @@ export const deleteSession = (
const outputExists = yield* fs.exists(outputDirectory).pipe(Effect.orDie)
yield* fs.remove(logPath).pipe(Effect.orDie)
const ts = yield* Clock.currentTimeMillis
yield* appendSessionIndexRecord({ _tag: 'deleted', sessionId, ts }, options)
yield* appendSessionIndexRecord(DeletedIndexRecord.make({ sessionId, ts }), options)
if (!outputExists) return { deleted: true, outputRemoved: true }

const outputRemoval = yield* Effect.exit(fs.remove(outputDirectory, { recursive: true }))
Expand Down Expand Up @@ -417,12 +422,11 @@ export const refreshSessionSummaryIndex = (sessionId: SessionId, options?: Sessi
summary === null
? Effect.void
: appendSessionIndexRecord(
{
_tag: 'summary',
SummaryIndexRecord.make({
sourceMtimeMs: ref.mtimeMs,
sourceSize: ref.size ?? 0,
summary,
},
}),
options,
),
),
Expand Down
11 changes: 6 additions & 5 deletions packages/fold-agent/src/Session/TitleGenerator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { LogEntry, FoldModel } from '@humanlayer/fold-core'
import { languageModelLayerFor } from '@humanlayer/fold-core'
import { Effect, Schema } from 'effect'
import { Predicate, Effect, Schema } from 'effect'
import { LanguageModel } from 'effect/unstable/ai'

const TitleResult = Schema.Struct({ title: Schema.String })
Expand All @@ -9,7 +9,7 @@ const MAX_TRANSCRIPT_CHARS = 12_000
type MessageEntry = Extract<LogEntry, { readonly _tag: 'user-message' | 'assistant-message' }>

const isMessageEntry = (entry: LogEntry): entry is MessageEntry =>
entry._tag === 'user-message' || entry._tag === 'assistant-message'
Predicate.isTagged(entry, 'user-message') || Predicate.isTagged(entry, 'assistant-message')

const extractMessageText = (entry: MessageEntry): string =>
typeof entry.message.content === 'string'
Expand All @@ -30,7 +30,7 @@ export const normalizeSessionTitle = (title: string): string =>
.join(' ')

export const fallbackSessionTitle = (entries: ReadonlyArray<LogEntry>, rootAgentId: string): string => {
const first = entries.find((entry) => entry._tag === 'user-message' && entry.agentId === rootAgentId)
const first = entries.find((entry) => Predicate.isTagged(entry, 'user-message') && entry.agentId === rootAgentId)
return normalizeSessionTitle(first === undefined ? '' : messageText(first)) || 'Untitled session'
}

Expand All @@ -39,9 +39,10 @@ export const titleTranscript = (entries: ReadonlyArray<LogEntry>, rootAgentId: s
entries
.filter(
(entry) =>
entry.agentId === rootAgentId && (entry._tag === 'user-message' || entry._tag === 'assistant-message'),
entry.agentId === rootAgentId &&
(Predicate.isTagged(entry, 'user-message') || Predicate.isTagged(entry, 'assistant-message')),
)
.map((entry) => `${entry._tag === 'user-message' ? 'User' : 'Assistant'}: ${messageText(entry)}`)
.map((entry) => `${Predicate.isTagged(entry, 'user-message') ? 'User' : 'Assistant'}: ${messageText(entry)}`)
.join('\n')
.slice(0, MAX_TRANSCRIPT_CHARS)

Expand Down
Loading
Loading