diff --git a/.claude/skills/effect-program-design/REFERENCE.md b/.claude/skills/effect-program-design/REFERENCE.md index 73d2695..8afbafb 100644 --- a/.claude/skills/effect-program-design/REFERENCE.md +++ b/.claude/skills/effect-program-design/REFERENCE.md @@ -10,7 +10,7 @@ conventions from another repository. | Durable tagged data | `packages/fold-core/src/EventLog/Schemas.ts` | `Schema.TaggedStruct`, `Schema.Union`, versioning, and decode-time compatibility. | | Provider/runtime ownership | `packages/fold-core/src/Api/Provisioning.ts` | Scope ownership, fresh memo maps, provider layers, and resource lifetime. | | Complete tagged dispatch | `packages/fold-agent/src/Session/SessionLayout.ts` | An exhaustive `Match` over a Fold-owned tagged union. | -| Focused tag guard | `packages/fold-core/src/HookRunner/Errors.ts` | A reusable `Predicate.isTagged` guard. | +| Focused tag guard | `packages/fold-core/src/Subagents/SubagentsLayer.ts` | `Predicate.isTagged` guards over decoded log entries. | | Pure projection policy | `packages/fold-core/src/Compaction/CompactionEngine.ts` | Pure projections and the distinction between simple loop guards and transformations. | ## Public Fold shape diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 83d01b2..6e0ede9 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -1,28 +1,148 @@ { - "$schema": "./node_modules/oxlint/configuration_schema.json", + "$schema": "./node_modules/@effect/tsgo/oxlint-schema.json", + // Effect-aware type-aware rules from the patched oxlint-tsgolint (`effect-tsgo patch --oxlint`). + "extends": ["./node_modules/@effect/tsgo/oxlint-presets/recommended.json"], // The `correctness` category is enabled by default. Keep the broader Effect Channels // plugin set and Vitest's test-specific rules. - "plugins": ["typescript", "import", "oxc", "eslint", "unicorn", "node", "vitest"], + "plugins": ["typescript", "import", "oxc", "eslint", "unicorn", "node", "vitest", "effecttsgo"], "jsPlugins": [ { "name": "anti-slop", "specifier": "./tools/oxlint/anti-slop/index.ts" }, + { "name": "anti-slop-effect", "specifier": "./tools/oxlint/anti-slop/effect/index.ts" }, { "name": "automation", "specifier": "./tools/oxlint/automation/index.ts" }, + { "name": "import-extensions", "specifier": "./tools/oxlint/import-extensions.mjs" }, + ], + "ignorePatterns": [ + "**/node_modules/**", + "**/dist/**", + ".release/**", + ".agent/**", + ".agents/**", + ".claude/**", + ".codex/**", + ".cursor/**", + ".humanlayer/**", + ".opencode/**", + ".pi/**", + "tools/**", + "scripts/**", + "packages/fold/postinstall.mjs", ], - "ignorePatterns": ["**/node_modules/**", "**/dist/**", ".release/**", "tools/oxlint/**"], "rules": { + // The @effect/tsgo preset ships most rules as warnings; Fold holds them at zero, so they fail lint. + "effecttsgo/abort-controller-in-effect": "error", + "effecttsgo/acquire-release-disposable": "error", + "effecttsgo/all-of-map-to-for-each": "error", + "effecttsgo/catch-all-tag-dispatch-to-catch-tag": "error", + "effecttsgo/catch-all-to-map-error": "error", + "effecttsgo/catch-chain-to-first-success-of": "error", + "effecttsgo/catch-conditional-refail-to-catch-if": "error", + "effecttsgo/catch-die-to-or-die": "error", + "effecttsgo/catch-if-tag-to-catch-tag": "error", + "effecttsgo/catch-refail-to-tap-error": "error", + "effecttsgo/catch-tag-to-catch-reason": "error", + "effecttsgo/catch-to-ignore": "error", + "effecttsgo/catch-to-or-else-succeed": "error", + "effecttsgo/catch-unfailable-effect": "error", + "effecttsgo/duplicate-package": "error", + "effecttsgo/effect-fn-iife": "error", + "effecttsgo/effect-fn-opportunity": "error", + "effecttsgo/effect-gen-uses-adapter": "error", + "effecttsgo/effect-in-failure": "error", + "effecttsgo/effect-in-void-success": "error", + "effecttsgo/effect-map-flatten": "error", + "effecttsgo/effect-map-void": "error", + "effecttsgo/effect-succeed-with-void": "error", + "effecttsgo/flat-map-conditional-to-filter-or-fail": "error", + "effecttsgo/flat-map-ignored-param-to-and-then": "error", + "effecttsgo/flat-map-to-map": "error", + "effecttsgo/generic-effect-services": "error", + "effecttsgo/global-error-in-effect-catch": "error", + "effecttsgo/global-error-in-effect-failure": "error", + "effecttsgo/layer-merge-all-with-dependencies": "error", + "effecttsgo/lazy-effect": "error", + "effecttsgo/lazy-promise-in-effect-sync": "error", + "effecttsgo/leaking-requirements": "error", + "effecttsgo/map-some-to-as-some": "error", + "effecttsgo/match-effect-to-map-both": "error", + "effecttsgo/match-effect-to-match": "error", + "effecttsgo/multiple-catch-tag": "error", + "effecttsgo/multiple-effect-provide": "error", + "effecttsgo/obsolete-match-import": "error", + "effecttsgo/obsolete-schema-import": "error", + "effecttsgo/option-match-to-from-option": "error", + "effecttsgo/outdated-api": "error", + "effecttsgo/prefer-succeed-some-or-none": "error", + "effecttsgo/prefer-typed-schema-decoder": "error", + "effecttsgo/prefer-unsafe-constructor": "error", + "effecttsgo/promise-in-effect-success": "error", + "effecttsgo/provide-layer-succeed-to-provide-service": "error", + "effecttsgo/race-first-with-sleep-to-timeout": "error", + "effecttsgo/redundant-map-error": "error", + "effecttsgo/redundant-or-die": "error", + "effecttsgo/redundant-schema-tag-identifier": "error", + "effecttsgo/return-effect-in-gen": "error", + "effecttsgo/run-effect-inside-effect": "error", + "effecttsgo/run-of-exit-to-run-exit": "error", + "effecttsgo/schema-number": "error", + "effecttsgo/schema-struct-with-tag": "error", + "effecttsgo/schema-sync-in-effect": "error", + "effecttsgo/scope-in-layer-effect": "error", + "effecttsgo/sync-to-succeed": "error", + "effecttsgo/timeout-catch-tag-to-timeout-or-else": "error", + "effecttsgo/try-catch-in-effect-gen": "error", + "effecttsgo/unknown-in-effect-catch": "error", + "effecttsgo/unnecessary-effect-gen": "error", + "effecttsgo/unnecessary-fail-yieldable-error": "error", + "effecttsgo/unnecessary-pipe": "error", + "effecttsgo/unnecessary-pipe-chain": "error", + "effecttsgo/unnecessary-typeof-type": "error", "anti-slop/no-conditional-empty-object-spread": "error", "anti-slop/no-module-mocking": "error", "anti-slop/no-object-parameters": "error", "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/no-service-option": "error", - "automation/prefer-effect-match": "error", "automation/prefer-option-from-nullable": "error", "automation/prefer-tagged-error-handling": "error", "automation/no-ambient-nondeterminism": "error", "anti-slop/no-reflect-apply": "error", + "anti-slop/no-unknown-type-aliases": "error", + "anti-slop/no-widen-then-assert": "error", + "anti-slop-effect/no-manual-effect-error-tag": "error", + "anti-slop-effect/no-manual-tag-comparison": "error", + "anti-slop-effect/prefer-effect-match": "error", + "automation/no-cascading-layer-provide": "error", + "automation/no-nested-effect-array-methods": "error", + "automation/no-nested-layer-provide": "error", + "automation/no-sql-type-parameter": "error", + "automation/no-static-effect-service-forwarders": "error", + "automation/pipe-max-arguments": "error", + "automation/require-context-service-in-services": "error", + "anti-slop/max-ternary-depth": "error", + "anti-slop/no-chained-type-assertions": "error", + "anti-slop/no-reflect-get": "error", + "anti-slop/no-reprovide-ambient-service": "error", + "anti-slop/no-shape-in-symbol-names": "error", + "anti-slop/no-unknown-returns": "error", + "anti-slop/require-safety-comment-for-type-assertion": "error", + "automation/no-banned-type-assertions": "error", + "automation/no-direct-fetch": "error", + "automation/no-global-json": "error", + "automation/no-in-operator": "error", + "automation/no-switch": "error", + "automation/no-type-assertion": "error", + "automation/no-try-catch": "error", + "automation/no-typeof-object": "error", + "anti-slop/no-runtime-typeof": "error", + "anti-slop/no-unknown-parameters": "error", + "anti-slop/no-unsafe-dictionary-type": "error", + "anti-slop-effect/no-service-constructor-imports": "error", + "anti-slop/no-conditional-spread": "error", + "anti-slop/no-conditional-empty-array-spread": "error", + "anti-slop-effect/no-manual-tagged-construction": "error", + "anti-slop/no-known-value-widening": "error", + "import-extensions/no-relative-source-extensions": "error", "typescript/consistent-type-imports": ["error", { "fixStyle": "inline-type-imports" }], "typescript/no-import-type-side-effects": "error", "import/no-duplicates": "error", @@ -31,6 +151,18 @@ "typescript/no-unnecessary-type-assertion": "error", "typescript/no-unnecessary-type-constraint": "error", "typescript/no-useless-empty-export": "error", + "typescript/await-thenable": "error", + "typescript/no-base-to-string": "error", + "typescript/no-floating-promises": "error", + "typescript/no-meaningless-void-operator": "error", + "typescript/no-misused-promises": "error", + "typescript/no-misused-spread": "error", + "typescript/no-redundant-type-constituents": "error", + "typescript/restrict-template-expressions": "error", + "typescript/unbound-method": "error", + "eslint/complexity": ["error", { "max": 20 }], + "eslint/no-empty-function": "error", + "eslint/no-eq-null": "error", "eslint/no-console": "error", "eslint/no-var": "error", "eslint/no-useless-constructor": "error", @@ -59,6 +191,8 @@ // Effect generator, which the vitest plugin can't see as a test block. Disabled to // avoid a false positive on every Effect test. "vitest/no-standalone-expect": "off", + // Vitest's `expect(value, message)` takes an optional failure message as its second argument. + "vitest/valid-expect": ["error", { "maxArgs": 2 }], // Disallow `.ts` extensions in imports; TypeScript resolves them without extensions. "import/extensions": ["error", "never"], }, @@ -76,16 +210,39 @@ "anti-slop/no-module-mocking": "off", "anti-slop/no-object-parameters": "off", "anti-slop/no-reflect-apply": "off", + "anti-slop-effect/no-manual-tag-comparison": "off", + "anti-slop-effect/prefer-effect-match": "off", + "eslint/complexity": "off", "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/no-service-option": "off", - "automation/prefer-effect-match": "off", "automation/prefer-option-from-nullable": "off", "automation/prefer-tagged-error-handling": "off", "automation/no-ambient-nondeterminism": "off", + "anti-slop/max-ternary-depth": "off", + "anti-slop/no-chained-type-assertions": "off", + "anti-slop/no-reflect-get": "off", + "anti-slop/no-reprovide-ambient-service": "off", + "anti-slop/no-shape-in-symbol-names": "off", + "anti-slop/no-unknown-returns": "off", + "anti-slop/require-safety-comment-for-type-assertion": "off", + "automation/no-banned-type-assertions": "off", + "automation/no-direct-fetch": "off", + "automation/no-global-json": "off", + "automation/no-in-operator": "off", + "automation/no-switch": "off", + "automation/no-type-assertion": "off", + "automation/no-try-catch": "off", + "automation/no-typeof-object": "off", + "anti-slop/no-runtime-typeof": "off", + "anti-slop/no-unknown-parameters": "off", + "anti-slop/no-unsafe-dictionary-type": "off", + "anti-slop-effect/no-service-constructor-imports": "off", + "anti-slop/no-conditional-spread": "off", + "anti-slop/no-conditional-empty-array-spread": "off", + "anti-slop-effect/no-manual-tagged-construction": "off", + "anti-slop/no-known-value-widening": "off", }, }, { @@ -101,7 +258,42 @@ "**/examples/**", ], "rules": { - "automation/no-manual-tagged-construction": "off", + "anti-slop-effect/no-manual-tagged-construction": "off", + }, + }, + { + // The CLI commands build option objects field by field; they are exempt from the widening rule. + "files": ["packages/fold-cli/src/**"], + "rules": { + "anti-slop/no-known-value-widening": "off", + }, + }, + { + // Tests may cast to build deliberately invalid inputs, build JSON fixtures directly, and inspect raw values. + "files": [ + "**/test/**", + "**/tests/**", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.vi.test.ts", + "**/*.vi.test.tsx", + "**/fixtures/**", + ], + "rules": { + "anti-slop/no-chained-type-assertions": "off", + "anti-slop/require-safety-comment-for-type-assertion": "off", + "automation/no-banned-type-assertions": "off", + "automation/no-global-json": "off", + "automation/no-type-assertion": "off", + "automation/no-try-catch": "off", + "automation/no-typeof-object": "off", + "anti-slop/no-runtime-typeof": "off", + "anti-slop/no-unknown-parameters": "off", + "anti-slop/no-unsafe-dictionary-type": "off", + "anti-slop/no-known-value-widening": "off", + "anti-slop-effect/no-service-constructor-imports": "off", + "anti-slop/no-conditional-spread": "off", + "anti-slop/no-conditional-empty-array-spread": "off", }, }, { @@ -126,6 +318,7 @@ "files": ["packages/vitest-config/**/*.ts"], "rules": { "import/extensions": "off", + "import-extensions/no-relative-source-extensions": "off", }, }, { diff --git a/AGENTS.md b/AGENTS.md index e8833b2..b15c0c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,15 +8,15 @@ Bun monorepo. Packages live in `packages/*` and run TypeScript directly (no buil - **Framework:** Effect **v4** (`4.0.0-rc.109`) - **Testing:** Vitest + `@effect/vitest` - **Lint / format:** oxlint + oxfmt (120 col, no semicolons) -- **Effect editor tooling:** `@effect/tsgo` with the `@effect/language-service` TS plugin. `tsc` is patched for - build-time Effect diagnostics via the `prepare` script (`effect-tsgo patch --typescript`). +- **Effect editor tooling:** `@effect/tsgo` with the `@effect/language-service` TS plugin. `tsc` and oxlint are patched + for Effect diagnostics via the `prepare` script (`effect-tsgo patch --typescript --oxlint --force`). ## Commands - `bun install` — install deps (also patches TypeScript for Effect diagnostics) - `bun run test` / `bun run test:watch` — run tests - `bun run typecheck` — typecheck every package -- `bun run lint` / `bun run lint:fix` — oxlint +- `bun run lint` / `bun run lint:fix` — oxlint (with the `@effect/tsgo` recommended preset), then the typed rules in `tools/typed`; see `tools/oxlint/README.md` - `bun run format` / `bun run format:check` — oxfmt ## Dependency versions diff --git a/bun.lock b/bun.lock index 2736fcb..3e41be4 100644 --- a/bun.lock +++ b/bun.lock @@ -5,15 +5,18 @@ "": { "name": "fold", "devDependencies": { - "@effect/tsgo": "0.36.4", - "@oxlint/plugins": "1.77.0", + "@effect/tsgo": "0.47.0", + "@oxlint/plugins": "1.86.0", "@types/bun": "catalog:", "@types/node": "catalog:", "@vitest/coverage-v8": "catalog:", + "effect": "catalog:", + "fast-glob": "3.3.3", "oxfmt": "0.62.0", - "oxlint": "1.77.0", - "oxlint-tsgolint": "0.24.0", + "oxlint": "1.86.0", + "oxlint-tsgolint": "7.0.2003", "typescript": "catalog:", + "typescript-compiler": "npm:typescript@6.0.2", "vitest": "catalog:", }, }, @@ -113,6 +116,7 @@ "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", + "@humanlayer/effect-ai-openai": "workspace:*", "@humanlayer/fold-vitest-config": "workspace:*", "@types/turndown": "catalog:", "effect": "catalog:", @@ -167,7 +171,6 @@ "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", - "@humanlayer/fold-agent": "workspace:*", "@humanlayer/fold-vitest-config": "workspace:*", "effect": "catalog:", "typescript": "catalog:", @@ -453,21 +456,21 @@ "@effect/sql-sqlite-do": ["@effect/sql-sqlite-do@4.0.0-rc.118", "", { "peerDependencies": { "effect": "^4.0.0-rc.118" } }, "sha512-wuT52JOf5vqekrjprLDwNkjBk13xXB0rNUlbdPD645CajQmYgtAfpVlMv/329UNmsCUGoxUdYYglcl8xtpZlfQ=="], - "@effect/tsgo": ["@effect/tsgo@0.36.4", "", { "optionalDependencies": { "@effect/tsgo-darwin-arm64": "0.36.4", "@effect/tsgo-darwin-x64": "0.36.4", "@effect/tsgo-linux-arm": "0.36.4", "@effect/tsgo-linux-arm64": "0.36.4", "@effect/tsgo-linux-x64": "0.36.4", "@effect/tsgo-win32-arm64": "0.36.4", "@effect/tsgo-win32-x64": "0.36.4" }, "bin": { "effect-tsgo": "dist/effect-tsgo.cjs" } }, "sha512-fNmdUV6FgXnvIcG18AVS1SRXt7z9jsyBh5CKuJYmTsE+DgbLaWF0CevKHx7hQPcidDWRVmdJYfU66Jvc/ZEt6w=="], + "@effect/tsgo": ["@effect/tsgo@0.47.0", "", { "optionalDependencies": { "@effect/tsgo-darwin-arm64": "0.47.0", "@effect/tsgo-darwin-x64": "0.47.0", "@effect/tsgo-linux-arm": "0.47.0", "@effect/tsgo-linux-arm64": "0.47.0", "@effect/tsgo-linux-x64": "0.47.0", "@effect/tsgo-win32-arm64": "0.47.0", "@effect/tsgo-win32-x64": "0.47.0" }, "bin": { "effect-tsgo": "dist/effect-tsgo.cjs" } }, "sha512-70L1cp9Lj8rJeKmLseg2RLaQ95IM2CR3BFF6Ytq1fjio3V9cEwMuOzBYSRlIAK2x+dTyxvFdabvbMr+DF7XD7g=="], - "@effect/tsgo-darwin-arm64": ["@effect/tsgo-darwin-arm64@0.36.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oOcWdKQucNch6G4CvidHEzmfgeKEPMvFcz+DXuKGonBLwVHX+i1VYGbZwccEOoiitOg7eWVl4e8S0qKMXg3/gg=="], + "@effect/tsgo-darwin-arm64": ["@effect/tsgo-darwin-arm64@0.47.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-X4MF7+GOiOk0EzcdY2bqSFl8yXYoD3VvzFcDE5ZyEO1oLGa4UGJjRmJBQt/LQFil8MW68uvAmE+Thh/Y2VTyuA=="], - "@effect/tsgo-darwin-x64": ["@effect/tsgo-darwin-x64@0.36.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-4QZh0n/nUoTI7sp4o7gDEviJ/jXH+IFTVqIyQ2w2fqqPoOvaGl9bVoR/2f9j/67hvY4DHcXKaccpmyoRYhaYsw=="], + "@effect/tsgo-darwin-x64": ["@effect/tsgo-darwin-x64@0.47.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vANaJYOFsIpX5KiMI0p3Y1DeFIT6N1Y8uTeBo6WKOCfeI1r0vcMUX1gEiLDPJBdweH7bbvyzbiUFX0/fnWgKEg=="], - "@effect/tsgo-linux-arm": ["@effect/tsgo-linux-arm@0.36.4", "", { "os": "linux", "cpu": "arm" }, "sha512-10oTUnOK/RCfmTMFvMndNIfk61uviyYBRpbxhDd/SiU6DYCmWlnZ/xhGmgyj5uiysOiFNsp3NKxT0d9y4uAz8g=="], + "@effect/tsgo-linux-arm": ["@effect/tsgo-linux-arm@0.47.0", "", { "os": "linux", "cpu": "arm" }, "sha512-nmoRTsK8nfcPl2uhksmxpyXFoKcAHkZPrUiSheO8wEbdCo/aFTe2UKfAgrRtMZ8qr3gD1tpKxXWrRfvlZTwp0g=="], - "@effect/tsgo-linux-arm64": ["@effect/tsgo-linux-arm64@0.36.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-aOv+wVbZWgBt9PGXPA874qgw151GrzkWqaswe+4TXjhdw/M4i35PCUH5T+uaQj3FnQ1NWiPqHIGfNBRjf3xG6w=="], + "@effect/tsgo-linux-arm64": ["@effect/tsgo-linux-arm64@0.47.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-44tFG3S9MU6vQCsDIQlV/72NHrAmo4T9Q6adFJDNjaRzv4eIEu9TPHAflIVghCRFWkmvhElnKp6VSh4vJ5jwlw=="], - "@effect/tsgo-linux-x64": ["@effect/tsgo-linux-x64@0.36.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+BuwiG5d8dLNrrN1PB5GA3gRkBwvlUYmMkm/XkttaLnppp9jePFkB8CgAiNV8j290q7BVO2nQjao5aPkVwmluw=="], + "@effect/tsgo-linux-x64": ["@effect/tsgo-linux-x64@0.47.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DL3DiNZNMKnEdVNHpQPtuDSV4DdAtY6owgfQYYgF1uExjqS/5ZWZICEm1ZpdeLMIaCFbRdeSv7uEu1RrTGCuyw=="], - "@effect/tsgo-win32-arm64": ["@effect/tsgo-win32-arm64@0.36.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-HsW90wbMWOeorUeNMdTqTcMZIDcCXdkeFGXTUkx9K1R21fXSw+QEdPtTmEyKsRMXEH4vqek/HQa0yMkUT0CJzA=="], + "@effect/tsgo-win32-arm64": ["@effect/tsgo-win32-arm64@0.47.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-LctXaODCUDIjmxcSqqQ2OnLffQwLIct09YsAs9ZiTBPnxdRy4eQYPxe4ImfvTIEDkYDG/nfG3h7tL+EIvix45A=="], - "@effect/tsgo-win32-x64": ["@effect/tsgo-win32-x64@0.36.4", "", { "os": "win32", "cpu": "x64" }, "sha512-q4j1q0ESYxxookjA0ECz/84DEH2MsP51K0aZb3uXKfxagHDASPoWDcVu4BJTcwrK5kdZzvw9QrCPjAXe9WZqng=="], + "@effect/tsgo-win32-x64": ["@effect/tsgo-win32-x64@0.47.0", "", { "os": "win32", "cpu": "x64" }, "sha512-keYrly656yhEdB3pigkCJaT2oI7I7RcB1QcF4akTggBNuLsh1WT5Ksy2xuHD8QkUiNz6pRwFUSW9Qh83zJQX8g=="], "@effect/vitest": ["@effect/vitest@4.0.0-rc.112", "", { "peerDependencies": { "effect": "^4.0.0-rc.112", "vitest": ">=4.1.0 <5.0.0" } }, "sha512-mEKh/FI64mt8JK1/v9mpOrJYdnp+UFZdRUBMEdZMiKz7klg6NPqVgg/oeAGH6wOOQc2iAPcfc2H9BbAv1KyzMQ=="], @@ -747,57 +750,57 @@ "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.62.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dlI5SY7XYQCiCBafntWagCR6HcAJB/NpsLtdlPx8x08+Osz8Ok1HHz1GZuusegCe/VoJ6pAnF5a4pd5OZAq7qQ=="], - "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.24.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-C2uMmwK5Bc4ri4ysZ6sA8Rcu+A5zBQTp6ml2u0CLLbRZp4kMFPV3yWk8B5DK9Aw7y9bbjogIm75tUwGLFzlsYQ=="], + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@7.0.2003", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TgV33rXr6ueXBwvc+0nssUkTBSXHJxv77I8p4RCjDjCnvexHtmoPiudVRDfj3S3puMDdeQdHW6jdUgUPy3nr/w=="], - "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.24.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Wgvt/1lRbDxmoNqWQKKcL+UIiqLmdJ+EWLpQa1qzoNVAfNB0PJpa82/8dH1twT/3rSs4zrP5TXPWl4juB71WuQ=="], + "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@7.0.2003", "", { "os": "darwin", "cpu": "x64" }, "sha512-hY3FMAjIaPDdK3FNxyRXRfHPOFeoBn6dmnLyKZgQ2IbTTD1adXhl6PfCIqHhCPvurigv7nf7mYuWZZ19MmJzmg=="], - "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.24.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-PB1rxII7KV83+ASY4sSkXtqvpij6ME66+QCRL49uksi/ofs2Rf/UVboYr095n0Rkbl2wgvlsHGl6DHC361jQUQ=="], + "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@7.0.2003", "", { "os": "linux", "cpu": "arm64" }, "sha512-eAET4JpyfBbg8SO0K74o4R55tEjVC292pIyxGOw2XGf8x/HrPNyxwQ478jMYOM54FIVOHc8sJ6VRHxluy6lucw=="], - "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.24.0", "", { "os": "linux", "cpu": "x64" }, "sha512-xcz3CxKmjTQLREtE/UShh+ruWmm9nAb7UM9zKcD65BStiuYgOakAKkPHl4YS5DztpVcDrE0+HqbOolTlRKYWmw=="], + "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@7.0.2003", "", { "os": "linux", "cpu": "x64" }, "sha512-GXdyO/XyqDJ3s/llR/oOktLsYNjZtWSQBy0JMc+/0gsNPvTcseKPhn9c6KcFyWmnr6H4vljYALbujonqSzzEGw=="], - "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.24.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-A2i6ZGBec3i20S7RaxkgHc6r3HYtD5Mn7j/mb22NkTz14u0JuudvTu6JggAnbGMcv8+dBKQI//EasxSPJLD8pw=="], + "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@7.0.2003", "", { "os": "win32", "cpu": "arm64" }, "sha512-TWauXnPfet0VgpmrstoAK58eJ4gbuwPUuUTQmYQAzE/RG1CpnxXtrKUJULf6lyerPE4KN3gM+DflIA1hOH+38Q=="], - "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.24.0", "", { "os": "win32", "cpu": "x64" }, "sha512-0ZbGd9qRB6zs82moekaKdEvncRANq49EAwfNX62JpTS46feXUhKAuoyVDvZMj6Rywejylrmmu79Wo6faYCo4Ew=="], + "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@7.0.2003", "", { "os": "win32", "cpu": "x64" }, "sha512-DoRmfe7j8VqNlukp8liRVW45GQDhzRccNenjD/pdzelgtffW47pCMd1xbJLkPaPbKnTwID3onn3VZlL7JbebEA=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.77.0", "", { "os": "android", "cpu": "arm" }, "sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.86.0", "", { "os": "android", "cpu": "arm" }, "sha512-63Ozq6yQn79B2UxjSZ9huJmI+3/ZPI7hxAe6TyFh9oQL447KnIMoffOdtog+h4KILbQr61ZRvFPW5gYbAUgpgQ=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.77.0", "", { "os": "android", "cpu": "arm64" }, "sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.86.0", "", { "os": "android", "cpu": "arm64" }, "sha512-JdLGp44phbf/0Du236Dt06yfK6Qw8xhTFeDvHJdjk167r59OfP5+BL31mgMYVnswS8e+j6tFuccSY0QDC/lgCw=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.77.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.86.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-h+vkOr4ik6KLFCXdtNVEj9xfnXfNUpPoF95QjORdhiSpLqQePzcN81kGHjAgsw1/G7WBWK5KsFKbI2UK8EyzMA=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.77.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.86.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-wbglRuH5eKsp68keTOOeKDHU1astWSRYFsh2bwKQTAPb44rD6kVgzYI1ZrAJRTc6/7PH55ZXFCQx0rQXrj0UdA=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.77.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.86.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yTQ7QSOlNfQsrfbeVweTiegQJLKPjUKgXp/JpGiFzza6Hp29S9LAoH77TM3L0RjK39iQRZB5lk5iq2t241Mfag=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.77.0", "", { "os": "linux", "cpu": "arm" }, "sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.86.0", "", { "os": "linux", "cpu": "arm" }, "sha512-QYJGy0E2Tv8GAayyBIeHFTwQb8u+zrvrOYrifuDGZDr9ad5XsDhiMScpZymhS9fwWjqrgG2ZUwT4vpY0ISqp4Q=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.77.0", "", { "os": "linux", "cpu": "arm" }, "sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.86.0", "", { "os": "linux", "cpu": "arm" }, "sha512-oQo83CafLT8p3qh0AGkN0tSUIh5Hm63mfDsoqUmBQUBnSZLNBAbyRaAftfGY5a0cKZ34dG6jAtMYgcY5kZqt5g=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.77.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.86.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EM6wy5c2UM12qPb7iDJDBAZvHyfEJGH6iosFgaFiaRZpirz99//EHcUyD2KAskz07x1TULuiAtsojf4nvWWksw=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.77.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.86.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vCiUQb9ZNzZalxYRU4mlZwUqT5f+c7Sk9DwUOplWHtik3Dl4r3DVnUB5tBeRuTAUe6MUVCTylNW7TkkN8J1oTw=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.77.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.86.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-T0AzE89yjTYmOhB/hb6i+rWTpe2mYt+xiguBhs9DEPIJTZ1H8OYqsE4VxJHHw2Wlq8Mztwvp2P8NV87ZOuhPRw=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.77.0", "", { "os": "linux", "cpu": "none" }, "sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.86.0", "", { "os": "linux", "cpu": "none" }, "sha512-s49r0K2f5Oc7fJoMUO1WkdhYK5Ob79uYRwWQ/XNoGysaOq3RUGmGUbHSuXiyydTTA+P9xEdDEnnlZVXiaSJhlw=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.77.0", "", { "os": "linux", "cpu": "none" }, "sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.86.0", "", { "os": "linux", "cpu": "none" }, "sha512-JIa2tYR8ayb3nvcMSZzn4O/xTy13S7MHllu7cBD+B5qDQXXSRy/Vwk1aNpiTjZmBJGwKCHOy/NvnY5Xcwg27uQ=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.77.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.86.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-XwXMyBsAuWZkhZU74UNcfQKr1CJJAKnS80dZzOnjl6A4fKcf91IjFgidegQugMHA5SN8ulwDXEDV1yPntjXgQQ=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.77.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.86.0", "", { "os": "linux", "cpu": "x64" }, "sha512-C1WjukSyMnr66b+w1/tV8RFVv6d9v0MzDf4p9IxVXknqgmTHBgZh1pccN1eHzFr0b9Tbb3OXoPsAAdAuHAQfeA=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.77.0", "", { "os": "linux", "cpu": "x64" }, "sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.86.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ap6KLmvC38c6MdYzsIh25cXQupqYvjd37tMNftzrX1DCtkX1Gcf2B+S2B17dD4lKa5c5gJog7DQJyDo82PBKyw=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.77.0", "", { "os": "none", "cpu": "arm64" }, "sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.86.0", "", { "os": "none", "cpu": "arm64" }, "sha512-996Q9w/2GLwNyy5Djs39Jt09aFlr4b7TPhoj7dvo5FHmcyiF68nPz73HNc5UAYKIYeK+M9JzPQ4Wg1r1dXvP6A=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.77.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.86.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-9uTCHYTknNkug5+46ViSoUMuOuGWK61D87QnblU6CXaWKJC630Z7dbls61vdHxya/cR2ycQZ6Mm6ckgF6Cag5g=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.77.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.86.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-a47ga7EKVfMU8rO0i1m3mGwfNRj3FhQyt1d/ukO/E9u1HHF3KpR3tyb3VhuJBGEVPF8uR4exSyHUyx7xbE+uxw=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.77.0", "", { "os": "win32", "cpu": "x64" }, "sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.86.0", "", { "os": "win32", "cpu": "x64" }, "sha512-az4dzifCRzES1crwu6pDFXzJTIanrdxXFL9TbiJWrx9LKcuacbR83aPvxDA1LKLNswi/RoqitC7Afcflhu3zBQ=="], - "@oxlint/plugins": ["@oxlint/plugins@1.77.0", "", {}, "sha512-6KtPXskPgpt+0Kyl2nNSxNnYXt7lbkxW1C7vi2L7xwtq/AisZlsmV+hnzOTEyM5tU1TxPv1GpTqpUBbZfPzBLg=="], + "@oxlint/plugins": ["@oxlint/plugins@1.86.0", "", {}, "sha512-JeAiaaRMyUiUEQHQ7zBsJn1aEdbLyt1HJsOs4XKIIlATNxcvWRrRxneZA+RF4G03cQobq3IhTqSfyLhOFv03qA=="], "@paralleldrive/cuid2": ["@paralleldrive/cuid2@3.3.0", "", { "dependencies": { "@noble/hashes": "^2.0.1", "bignumber.js": "^9.3.1", "error-causes": "^3.0.2" }, "bin": { "cuid2": "bin/cuid2.js" } }, "sha512-OqiFvSOF0dBSesELYY2CAMa4YINvlLpvKOz/rv6NeZEqiyttlHgv98Juwv4Ch+GrEV7IZ8jfI2VcEoYUjXXCjw=="], @@ -1313,9 +1316,9 @@ "oxfmt": ["oxfmt@0.62.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.62.0", "@oxfmt/binding-android-arm64": "0.62.0", "@oxfmt/binding-darwin-arm64": "0.62.0", "@oxfmt/binding-darwin-x64": "0.62.0", "@oxfmt/binding-freebsd-x64": "0.62.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.62.0", "@oxfmt/binding-linux-arm-musleabihf": "0.62.0", "@oxfmt/binding-linux-arm64-gnu": "0.62.0", "@oxfmt/binding-linux-arm64-musl": "0.62.0", "@oxfmt/binding-linux-ppc64-gnu": "0.62.0", "@oxfmt/binding-linux-riscv64-gnu": "0.62.0", "@oxfmt/binding-linux-riscv64-musl": "0.62.0", "@oxfmt/binding-linux-s390x-gnu": "0.62.0", "@oxfmt/binding-linux-x64-gnu": "0.62.0", "@oxfmt/binding-linux-x64-musl": "0.62.0", "@oxfmt/binding-openharmony-arm64": "0.62.0", "@oxfmt/binding-win32-arm64-msvc": "0.62.0", "@oxfmt/binding-win32-ia32-msvc": "0.62.0", "@oxfmt/binding-win32-x64-msvc": "0.62.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ=="], - "oxlint": ["oxlint@1.77.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.77.0", "@oxlint/binding-android-arm64": "1.77.0", "@oxlint/binding-darwin-arm64": "1.77.0", "@oxlint/binding-darwin-x64": "1.77.0", "@oxlint/binding-freebsd-x64": "1.77.0", "@oxlint/binding-linux-arm-gnueabihf": "1.77.0", "@oxlint/binding-linux-arm-musleabihf": "1.77.0", "@oxlint/binding-linux-arm64-gnu": "1.77.0", "@oxlint/binding-linux-arm64-musl": "1.77.0", "@oxlint/binding-linux-ppc64-gnu": "1.77.0", "@oxlint/binding-linux-riscv64-gnu": "1.77.0", "@oxlint/binding-linux-riscv64-musl": "1.77.0", "@oxlint/binding-linux-s390x-gnu": "1.77.0", "@oxlint/binding-linux-x64-gnu": "1.77.0", "@oxlint/binding-linux-x64-musl": "1.77.0", "@oxlint/binding-openharmony-arm64": "1.77.0", "@oxlint/binding-win32-arm64-msvc": "1.77.0", "@oxlint/binding-win32-ia32-msvc": "1.77.0", "@oxlint/binding-win32-x64-msvc": "1.77.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg=="], + "oxlint": ["oxlint@1.86.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.86.0", "@oxlint/binding-android-arm64": "1.86.0", "@oxlint/binding-darwin-arm64": "1.86.0", "@oxlint/binding-darwin-x64": "1.86.0", "@oxlint/binding-freebsd-x64": "1.86.0", "@oxlint/binding-linux-arm-gnueabihf": "1.86.0", "@oxlint/binding-linux-arm-musleabihf": "1.86.0", "@oxlint/binding-linux-arm64-gnu": "1.86.0", "@oxlint/binding-linux-arm64-musl": "1.86.0", "@oxlint/binding-linux-ppc64-gnu": "1.86.0", "@oxlint/binding-linux-riscv64-gnu": "1.86.0", "@oxlint/binding-linux-riscv64-musl": "1.86.0", "@oxlint/binding-linux-s390x-gnu": "1.86.0", "@oxlint/binding-linux-x64-gnu": "1.86.0", "@oxlint/binding-linux-x64-musl": "1.86.0", "@oxlint/binding-openharmony-arm64": "1.86.0", "@oxlint/binding-win32-arm64-msvc": "1.86.0", "@oxlint/binding-win32-ia32-msvc": "1.86.0", "@oxlint/binding-win32-x64-msvc": "1.86.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2003", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-og0lhgvZfgGF//gOOmZXvtr+GmBbAGEnbEhv/QUg7UW2Wi4wHMnJbnMD+zuHgPdJxdklfgpPupdlAaAySyxrZg=="], - "oxlint-tsgolint": ["oxlint-tsgolint@0.24.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.24.0", "@oxlint-tsgolint/darwin-x64": "0.24.0", "@oxlint-tsgolint/linux-arm64": "0.24.0", "@oxlint-tsgolint/linux-x64": "0.24.0", "@oxlint-tsgolint/win32-arm64": "0.24.0", "@oxlint-tsgolint/win32-x64": "0.24.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-giCk5sEvG02d5tzPmFMX3hem8ndzEEu1xvGYS5OwNfO2WGl6ZVxt5LjE0yiMDoz94INI7XkXwgFAQiydPvVHDw=="], + "oxlint-tsgolint": ["oxlint-tsgolint@7.0.2003", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "7.0.2003", "@oxlint-tsgolint/darwin-x64": "7.0.2003", "@oxlint-tsgolint/linux-arm64": "7.0.2003", "@oxlint-tsgolint/linux-x64": "7.0.2003", "@oxlint-tsgolint/win32-arm64": "7.0.2003", "@oxlint-tsgolint/win32-x64": "7.0.2003" }, "bin": { "tsgolint": "./bin/tsgolint.js" } }, "sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ=="], "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], @@ -1503,6 +1506,8 @@ "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + "typescript-compiler": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + "uint8array-extras": ["uint8array-extras@1.6.0", "", {}, "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q=="], "undici": ["undici@8.10.1", "", {}, "sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q=="], diff --git a/examples/alchemy-cloudflare/src/BashTool.ts b/examples/alchemy-cloudflare/src/BashTool.ts index d119735..fa597d0 100644 --- a/examples/alchemy-cloudflare/src/BashTool.ts +++ b/examples/alchemy-cloudflare/src/BashTool.ts @@ -30,7 +30,7 @@ const BashParameters = Schema.Struct({ 'Where to run the command: "shell" (default; fast, text commands and git only) or "container" ' + '(full Linux: installs, builds, tests)', }), - timeout_ms: Schema.optionalKey(Schema.Number).annotate({ + timeout_ms: Schema.optionalKey(Schema.Finite).annotate({ description: `Timeout in milliseconds (default ${DEFAULT_TIMEOUT_MS}, maximum ${MAX_TIMEOUT_MS})`, }), workdir: Schema.optionalKey(Schema.String).annotate({ diff --git a/examples/alchemy-cloudflare/src/ChatSession.ts b/examples/alchemy-cloudflare/src/ChatSession.ts index bb93ebc..1eee0b8 100644 --- a/examples/alchemy-cloudflare/src/ChatSession.ts +++ b/examples/alchemy-cloudflare/src/ChatSession.ts @@ -17,7 +17,7 @@ * {@link SessionExpiry}. Its id then starts a new session. */ import { skillsFromDisk } from '@humanlayer/fold-agent/skills' -import { fileTools } from '@humanlayer/fold-agent/tools/files' +import { fileTools, Photon } from '@humanlayer/fold-agent/tools/files' import { SessionId, type AgentId, @@ -36,6 +36,7 @@ import { Config, Effect, FileSystem, + Layer, Match, Option, Path, @@ -46,6 +47,7 @@ import { Stream, SynchronizedRef, } from 'effect' +import { FetchHttpClient } from 'effect/unstable/http' import { bashTool } from './BashTool' import type { Message, WhenRunning } from './ChatSessions' @@ -103,7 +105,7 @@ const openRootMessages = (entries: ReadonlyArray, rootAgentId: AgentId } const isRestartNudge = ({ message }: UserMessageLogEntry) => - typeof message.content === 'string' + Predicate.isString(message.content) ? message.content === RESTART_NUDGE : message.content.some((part) => part.type === 'text' && part.text === RESTART_NUDGE) @@ -152,6 +154,8 @@ export default class ChatSession extends Cloudflare.DurableObject() const expiry = yield* SessionExpiry const workspace = yield* Workspace + // Alchemy runs this outer Effect once per class and the returned Effect once per object instance. + // oxlint-disable-next-line effecttsgo/return-effect-in-gen return Effect.gen(function* () { const sessionId = yield* Schema.decodeUnknownEffect(SessionId)(state.id.name) const eventLog = yield* eventLogs.open @@ -211,9 +215,9 @@ export default class ChatSession extends Cloudflare.DurableObject() onSome: (session) => Effect.succeed([session, current] as const), onNone: () => (isEmpty ? start(repos) : resume).pipe( - // fold hands these to the file tools and the skill loader. + // fold hands these to the file tools, the skill loader, and the model. Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provide(Path.layer), + Effect.provide(Layer.mergeAll(Path.layer, FetchHttpClient.layer, Photon.layer)), Scope.provide(scope), Effect.map((session) => [session, Option.some(session)] as const), ), diff --git a/examples/alchemy-cloudflare/src/ChatSessions.ts b/examples/alchemy-cloudflare/src/ChatSessions.ts index 02dada6..65bb617 100644 --- a/examples/alchemy-cloudflare/src/ChatSessions.ts +++ b/examples/alchemy-cloudflare/src/ChatSessions.ts @@ -59,7 +59,7 @@ export class ChatSessions extends Context.Service< .send(message) .pipe( Effect.catch((error) => - Schema.decodeUnknownEffect(RepoCloneError)(error).pipe( + Schema.decodeEffect(RepoCloneError)(error).pipe( Effect.catch(() => Effect.die(error)), Effect.flatMap(Effect.fail), ), diff --git a/examples/alchemy-cloudflare/src/DurableObjectEventLog.ts b/examples/alchemy-cloudflare/src/DurableObjectEventLog.ts index b040c91..e50bfb5 100644 --- a/examples/alchemy-cloudflare/src/DurableObjectEventLog.ts +++ b/examples/alchemy-cloudflare/src/DurableObjectEventLog.ts @@ -14,7 +14,7 @@ import { LogEntry, decodeStoredLogEntry, layerLiveIdFactory, - makeStoredLogEntry, + storedLogEntry, type EventLogError, type EventLogOperation, type EventLogService, @@ -33,7 +33,7 @@ const LogEntryJson = Schema.fromJsonString(LogEntry) /** Parse a row's JSON, then decode it by its stored version (older versions upcast to the current shape). */ const decodeRow = (row: LogRow) => - Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(row.entry).pipe( + Schema.decodeEffect(Schema.fromJsonString(Schema.Json))(row.entry).pipe( Effect.mapError( (cause) => new EventLogCorruptEntryError({ @@ -43,7 +43,7 @@ const decodeRow = (row: LogRow) => cause, }), ), - Effect.flatMap(decodeStoredLogEntry), + Effect.flatMap((json) => decodeStoredLogEntry(json)), ) const encodeEntry = (entry: LogEntry) => @@ -103,7 +103,7 @@ export class DurableObjectEventLog extends Context.Service< appendLock.withPermit( Effect.gen(function* () { const current = yield* Ref.get(entriesRef) - const stored = yield* makeStoredLogEntry(input, current.length, ids) + const stored = yield* storedLogEntry(input, current.length, yield* ids.makeEventId) const entry = yield* encodeEntry(stored) yield* query('append', 'INSERT INTO fold_log (seq, entry) VALUES (?, ?)', stored.seq, entry) diff --git a/examples/alchemy-cloudflare/src/SessionExpiry.ts b/examples/alchemy-cloudflare/src/SessionExpiry.ts index 4824b33..92c273c 100644 --- a/examples/alchemy-cloudflare/src/SessionExpiry.ts +++ b/examples/alchemy-cloudflare/src/SessionExpiry.ts @@ -7,7 +7,7 @@ * heartbeat that finds no turn running hands it back here. */ import * as Cloudflare from 'alchemy/Cloudflare' -import { Clock, Context, Duration, Effect, Layer } from 'effect' +import { Clock, Context, Duration, Effect, Layer, Option, Schema } from 'effect' /** How long a session lasts after its last message. */ export const IDLE_TIME = Duration.days(14) @@ -88,7 +88,9 @@ export class SessionExpiry extends Context.Service< { get: (key) => Effect.promise(() => raw.storage.get(key)).pipe( - Effect.map((value) => (typeof value === 'number' ? value : undefined)), + Effect.map((value) => + Option.getOrUndefined(Schema.decodeUnknownOption(Schema.Finite)(value)), + ), ), put: (key, value) => Effect.promise(() => raw.storage.put(key, value)), getAlarm: Effect.promise(() => raw.storage.getAlarm()), @@ -99,15 +101,13 @@ export class SessionExpiry extends Context.Service< }), // Once the alarm has finished: Cloudflare holds its completion until the delete is saved, // and an abort before then fails the alarm, which Cloudflare retries. - restart: Effect.sync(() => { - setTimeout(() => { - try { - raw.abort('session expired') - } catch { - // abort throws to unwind; the object resets either way. - } - }, RESTART_DELAY_MILLIS) - }), + restart: Effect.sleep(Duration.millis(RESTART_DELAY_MILLIS)).pipe( + // abort throws to unwind; the object resets either way. + Effect.andThen(Effect.try(() => raw.abort('session expired'))), + Effect.ignore, + Effect.forkDetach(), + Effect.asVoid, + ), }, IDLE_TIME, ) diff --git a/examples/alchemy-cloudflare/src/Workspace.ts b/examples/alchemy-cloudflare/src/Workspace.ts index cfe6a08..3d73e23 100644 --- a/examples/alchemy-cloudflare/src/Workspace.ts +++ b/examples/alchemy-cloudflare/src/Workspace.ts @@ -39,7 +39,7 @@ export const Repos = Schema.Array(Repo).check( Schema.makeFilter((repos) => { const names = repos.map(repoName) const invalid = names.find((name) => !REPO_NAME.test(name)) - if (invalid !== undefined) return `repo directory name ${JSON.stringify(invalid)} is invalid; pass a "name"` + if (invalid !== undefined) return `repo directory name "${invalid}" is invalid; pass a "name"` return new Set(names).size === names.length || 'repo directory names must be distinct' }), ) @@ -103,7 +103,7 @@ export class Workspace extends Context.Service< .prepare(repos.map((repo) => ({ name: repoName(repo), url: repo.url, ref: repo.ref ?? null }))) .pipe( Effect.tap((cloned) => - Effect.logInfo('workspace.prepare', JSON.stringify({ sessionId, cloned })), + Effect.logInfo('workspace.prepare').pipe(Effect.annotateLogs({ sessionId, cloned })), ), Effect.mapError((error) => new RepoCloneError({ message: error.message })), ), @@ -115,9 +115,8 @@ export class Workspace extends Context.Service< .expireAt(deleteAt) .pipe( Effect.catch((error) => - Effect.logWarning( - 'workspace.expireAt failed', - JSON.stringify({ sessionId, error: error.message }), + Effect.logWarning('workspace.expireAt failed').pipe( + Effect.annotateLogs({ sessionId, error: error.message }), ), ), ), @@ -126,11 +125,12 @@ export class Workspace extends Context.Service< .getByName(sessionId) .destroy() .pipe( - Effect.tap(() => Effect.logInfo('workspace.destroyed', JSON.stringify({ sessionId }))), + Effect.tap(() => + Effect.logInfo('workspace.destroyed').pipe(Effect.annotateLogs({ sessionId })), + ), Effect.catch((error) => - Effect.logWarning( - 'workspace.destroy failed', - JSON.stringify({ sessionId, error: error.message }), + Effect.logWarning('workspace.destroy failed').pipe( + Effect.annotateLogs({ sessionId, error: error.message }), ), ), ), @@ -146,10 +146,9 @@ export class Workspace extends Context.Service< ) const details = { sessionId, millis: (yield* Clock.currentTimeMillis) - started } yield* result.ok - ? Effect.logInfo('workspace.container.started', JSON.stringify(details)) - : Effect.logWarning( - 'workspace.container.start failed', - JSON.stringify({ ...details, error: result.message }), + ? Effect.logInfo('workspace.container.started').pipe(Effect.annotateLogs(details)) + : Effect.logWarning('workspace.container.start failed').pipe( + Effect.annotateLogs({ ...details, error: result.message }), ) }), exec: Effect.fn('alchemy_cloudflare.workspace.exec')(function* (sessionId, input) { @@ -157,9 +156,8 @@ export class Workspace extends Context.Service< .getByName(sessionId) .exec(input) .pipe(Effect.mapError((error) => new ShellError({ message: error.message }))) - yield* Effect.logInfo( - 'workspace.exec', - JSON.stringify({ + yield* Effect.logInfo('workspace.exec').pipe( + Effect.annotateLogs({ backend: input.backend, command: input.command, cwd: input.cwd, diff --git a/examples/alchemy-cloudflare/src/WorkspaceFileSystem.ts b/examples/alchemy-cloudflare/src/WorkspaceFileSystem.ts index 718c240..b0fc425 100644 --- a/examples/alchemy-cloudflare/src/WorkspaceFileSystem.ts +++ b/examples/alchemy-cloudflare/src/WorkspaceFileSystem.ts @@ -65,11 +65,10 @@ const run = ( }), ), ), - Effect.tap(() => Effect.logInfo('workspace.file', JSON.stringify({ method, path, outcome: 'ok' }))), + Effect.tap(() => Effect.logInfo('workspace.file').pipe(Effect.annotateLogs({ method, path, outcome: 'ok' }))), Effect.tapError((error) => - Effect.logInfo( - 'workspace.file', - JSON.stringify({ method, path, outcome: error.reason._tag, error: error.message }), + Effect.logInfo('workspace.file').pipe( + Effect.annotateLogs({ method, path, outcome: error.reason._tag, error: error.message }), ), ), ) diff --git a/examples/alchemy-cloudflare/src/computer/Computer.ts b/examples/alchemy-cloudflare/src/computer/Computer.ts index f678a48..7f43f65 100644 --- a/examples/alchemy-cloudflare/src/computer/Computer.ts +++ b/examples/alchemy-cloudflare/src/computer/Computer.ts @@ -16,12 +16,15 @@ import { type DurableObjectStorageLike, getWorkspace, withWorkspace } from '@clo import { CloudflareContainerBackend, type CloudflareContainerBackendOptions, + type IWorkspaceContainerAPI, + type WorkspaceContainerAPI, withWorkspaceContainer, } from '@cloudflare/computer/backends/container' import { WorkerShellBackend } from '@cloudflare/computer/backends/worker-shell' import { createGitClient, type GitClient, type GitCloneOptions } from '@cloudflare/computer/git' import jq from '@cloudflare/computer/shell/jq' import { DurableObject } from 'cloudflare:workers' +import { Data, Effect, Option, Schema } from 'effect' import { type Backend, @@ -67,7 +70,9 @@ const workspaceStorage = (storage: DurableObjectStorage): DurableObjectStorageLi } /** What `withWorkspaceContainer` adds to the object. */ -type ContainerOwner = { getWorkspaceContainer(): unknown } +type ContainerOwner = { + getWorkspaceContainer(): WorkspaceContainerAPI | IWorkspaceContainerAPI | Promise +} /** * The object as the container backend's host. `@cloudflare/computer` types the container API against its @@ -78,16 +83,32 @@ function containerHost(self: ContainerOwner): ContainerOwner { return self } +/** A thrown workspace error carries its POSIX-style code, like `ENOENT`. */ +const ErrorCode = Schema.Struct({ code: Schema.String }) +const errorCodeOf = (cause: unknown): string => + Option.match(Schema.decodeUnknownOption(ErrorCode)(cause), { onNone: () => 'UNKNOWN', onSome: ({ code }) => code }) + +class WorkspaceOperationError extends Data.TaggedError('WorkspaceOperationError')<{ + readonly code: string + readonly message: string +}> {} + /** Run one file operation, returning a thrown workspace error as its code and message. */ -const attempt = async (operation: () => Promise): Promise> => { - try { - return { ok: true, value: await operation() } - } catch (cause) { - const code = - cause instanceof Error && 'code' in cause && typeof cause.code === 'string' ? cause.code : 'UNKNOWN' - return { ok: false, code, message: String(cause) } - } -} +const attempt = (operation: () => Promise): Promise> => + Effect.runPromise( + Effect.tryPromise({ + try: operation, + catch: (cause) => new WorkspaceOperationError({ code: errorCodeOf(cause), message: String(cause) }), + }).pipe( + Effect.match({ + onSuccess: (value): ComputerResult => ({ ok: true, value }), + onFailure: ({ code, message }): ComputerResult => ({ ok: false, code, message }), + }), + ), + ) + +/** Abort the object so it restarts empty. abort throws to unwind; the object resets either way. */ +const abortQuietly = (abort: () => void) => Effect.runSync(Effect.ignore(Effect.try(abort))) class ComputerBase extends withWorkspaceContainer(class extends DurableObject {}) { // For withWorkspace's options, which see the instance but not `ctx` or `env`: DurableObject keeps them @@ -142,17 +163,20 @@ export class Computer extends withWorkspace(ComputerBase, (self) => ({ await workspace.fs.mkdir(WORKSPACE_ROOT, { recursive: true }) const cloned: Array = [] - for (const repo of repos) { + const clone = async (repo: RepoSpec): Promise => { const dir = `${WORKSPACE_ROOT}/${repo.name}` - try { - await workspace.fs.rm(dir, { recursive: true, force: true }) - const options: GitCloneOptions = { url: repo.url, dir } - if (repo.ref !== null) options.ref = repo.ref - await git.clone(options) - cloned.push({ ...repo, dir, commit: await git.revParse({ dir, ref: 'HEAD' }) }) - } catch (cause) { - throw new Error(`Cloning ${repo.name} from ${repo.url} failed: ${String(cause)}`, { cause }) - } + await workspace.fs.rm(dir, { recursive: true, force: true }) + const options: GitCloneOptions = { url: repo.url, dir } + if (repo.ref !== null) options.ref = repo.ref + await git.clone(options) + return { ...repo, dir, commit: await git.revParse({ dir, ref: 'HEAD' }) } + } + for (const repo of repos) { + cloned.push( + await clone(repo).catch((cause: unknown) => { + throw new Error(`Cloning ${repo.name} from ${repo.url} failed: ${String(cause)}`, { cause }) + }), + ) } return cloned } @@ -214,13 +238,7 @@ export class Computer extends withWorkspace(ComputerBase, (self) => ({ await this.ctx.storage.deleteAll() // The in-memory workspace must go: its tables are gone. Wait until this call has answered: Cloudflare // holds the answer until the delete is saved, and an abort before then fails the call. - setTimeout(() => { - try { - this.ctx.abort('workspace deleted') - } catch { - // abort throws to unwind; the object resets either way. - } - }, RESTART_DELAY_MILLIS) + setTimeout(() => abortQuietly(() => this.ctx.abort('workspace deleted')), RESTART_DELAY_MILLIS) } /** Only `expireAt` sets the alarm, and each call replaces it, so it fires at the latest deadline. */ diff --git a/examples/alchemy-cloudflare/test/ToolCalls.ts b/examples/alchemy-cloudflare/test/ToolCalls.ts index 73c8f11..a43508d 100644 --- a/examples/alchemy-cloudflare/test/ToolCalls.ts +++ b/examples/alchemy-cloudflare/test/ToolCalls.ts @@ -1,5 +1,5 @@ /** - * Call a fold tool's handler outside a session. fold provides these services around every tool call; here + * Call a fold tool's handler outside a session, decoding its erased result back to the tool result schemas. fold provides these services around every tool call; here * they are stubs. Platform services (FileSystem, Path) are the caller's to provide. */ import { @@ -8,13 +8,14 @@ import { CurrentToolCall, InterruptNote, StopController, - Subagents, ToolCallId, ToolEvents, + ToolResultFailure, + ToolResultSuccess, ToolState, type FoldTool, } from '@humanlayer/fold-core' -import { Effect, Layer } from 'effect' +import { Effect, Layer, Schema } from 'effect' const toolCallServices = Layer.mergeAll( Layer.succeed(ToolState, { get: () => Effect.succeed(null), set: () => Effect.void }), @@ -23,20 +24,25 @@ const toolCallServices = Layer.mergeAll( Layer.succeed(CurrentAgent, { agentId: AgentId.create(), parentAgentId: null }), Layer.succeed(CurrentToolCall, { toolCallId: ToolCallId.create() }), Layer.succeed(InterruptNote, { set: () => Effect.void }), - Layer.succeed(Subagents, { - dispatch: () => Effect.die('no subagents'), - fork: () => Effect.die('no subagents'), - resume: () => Effect.die('no subagents'), - continueSubagent: () => Effect.die('no subagents'), - }), ) /** Run the tool named `name` from `tools` with `params`, as the model would call it. */ -export const callTool = (tools: ReadonlyArray, name: string, params: unknown) => { +export const callTool = (tools: ReadonlyArray>, name: string, params: unknown) => { const tool = tools.find((candidate) => candidate.name === name) if (tool === undefined) return Effect.die(`no ${name} tool`) return tool.init.pipe( - Effect.flatMap((contribution) => contribution.handler(params)), + Effect.flatMap((contribution) => + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- the erased handler is decoded here + contribution.handler(params).pipe( + Effect.catch((error) => + Schema.decodeUnknownEffect(ToolResultFailure)(error).pipe( + Effect.orDie, + Effect.flatMap(Effect.fail), + ), + ), + Effect.flatMap((result) => Schema.decodeUnknownEffect(ToolResultSuccess)(result).pipe(Effect.orDie)), + ), + ), Effect.provide(toolCallServices), ) } diff --git a/examples/alchemy-cloudflare/test/WorkspaceFileSystem.test.ts b/examples/alchemy-cloudflare/test/WorkspaceFileSystem.test.ts index f5687ae..2a725fa 100644 --- a/examples/alchemy-cloudflare/test/WorkspaceFileSystem.test.ts +++ b/examples/alchemy-cloudflare/test/WorkspaceFileSystem.test.ts @@ -4,9 +4,9 @@ */ import { it } from '@effect/vitest' import { makeDiskSkillSource } from '@humanlayer/fold-agent/skills' -import { fileTools } from '@humanlayer/fold-agent/tools/files' +import { fileTools, Photon } from '@humanlayer/fold-agent/tools/files' import type { FoldTool } from '@humanlayer/fold-core' -import { Effect, FileSystem, Path, type PlatformError } from 'effect' +import { Effect, FileSystem, Layer, Path, type PlatformError } from 'effect' import { expect } from 'vitest' import type { FileInfo, ComputerResult } from '../src/computer/Contract' @@ -71,8 +71,16 @@ const fakeComputer = (): ComputerFiles => { } /** Call one of fold's tools with the workspace as its FileSystem. */ -const call = (fs: FileSystem.FileSystem, tools: ReadonlyArray, name: string, params: unknown) => - callTool(tools, name, params).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) +const call = ( + fs: FileSystem.FileSystem, + tools: ReadonlyArray>, + name: string, + params: unknown, +) => + callTool(tools, name, params).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provide(Layer.mergeAll(Path.layer, Photon.layer)), + ) const reasonTag = (error: PlatformError.PlatformError) => error.reason._tag diff --git a/package.json b/package.json index 1f6ded9..455fd87 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,11 @@ "typecheck": "bun run build:providers && bun run --parallel --if-present --filter './packages/*' --filter './examples/*' typecheck", "test": "bun run --parallel --if-present --filter './packages/*' --filter './examples/*' test", "test:watch": "bun run --parallel --if-present --filter './packages/*' --filter './examples/*' test:watch", - "lint": "oxlint --type-aware", - "lint:fix": "oxlint --type-aware --fix", + "lint": "oxlint --type-aware && bun tools/typed/src/cli.ts check --config typed-lint.config.ts", + "lint:fix": "oxlint --type-aware --fix && bun tools/typed/src/cli.ts check --config typed-lint.config.ts", "format": "oxfmt", "format:check": "oxfmt --check", - "prepare": "effect-tsgo patch --typescript" + "prepare": "effect-tsgo patch --typescript --oxlint --force" }, "workspaces": { "packages": [ @@ -54,15 +54,18 @@ } }, "devDependencies": { - "@effect/tsgo": "0.36.4", - "@oxlint/plugins": "1.77.0", + "@effect/tsgo": "0.47.0", + "@oxlint/plugins": "1.86.0", "@types/bun": "catalog:", "@types/node": "catalog:", "@vitest/coverage-v8": "catalog:", + "effect": "catalog:", + "fast-glob": "3.3.3", "oxfmt": "0.62.0", - "oxlint": "1.77.0", - "oxlint-tsgolint": "0.24.0", + "oxlint": "1.86.0", + "oxlint-tsgolint": "7.0.2003", "typescript": "catalog:", + "typescript-compiler": "npm:typescript@6.0.2", "vitest": "catalog:" } } diff --git a/packages/effect-ai-anthropic/test/HumanlayerFork.test.ts b/packages/effect-ai-anthropic/test/HumanlayerFork.test.ts index 057e273..3ebde98 100644 --- a/packages/effect-ai-anthropic/test/HumanlayerFork.test.ts +++ b/packages/effect-ai-anthropic/test/HumanlayerFork.test.ts @@ -120,7 +120,7 @@ describe('@humanlayer/effect-ai-anthropic', () => { yield* LanguageModel.generateText({ prompt }).pipe(Effect.provide(modelLayer)) - const body = Schema.decodeUnknownSync(CapturedRequest)(requestBody) + const body = yield* Schema.decodeUnknownEffect(CapturedRequest)(requestBody) const toolResults = body.messages.flatMap((message) => message.content.filter((block) => block['type'] === 'tool_result'), ) diff --git a/packages/effect-ai-openai/test/HumanlayerFork.test.ts b/packages/effect-ai-openai/test/HumanlayerFork.test.ts index bd0d8e1..3a0825a 100644 --- a/packages/effect-ai-openai/test/HumanlayerFork.test.ts +++ b/packages/effect-ai-openai/test/HumanlayerFork.test.ts @@ -129,7 +129,7 @@ describe('@humanlayer/effect-ai-openai', () => { const request = requests[0] assert.isDefined(request) - const body = Schema.decodeUnknownSync(CapturedRequest)(JSON.parse(request)) + const body = yield* Schema.decodeUnknownEffect(CapturedRequest)(JSON.parse(request)) const outputs = body.input.filter((item) => item['type'] === 'function_call_output') const textOutput = outputs.find((item) => item['call_id'] === 'call_text') const multipartOutput = outputs.find((item) => item['call_id'] === 'call_multipart') @@ -193,11 +193,11 @@ describe('@humanlayer/effect-ai-openai', () => { }) it('accepts incomplete flat and nested error events', () => { - const flat = Schema.decodeUnknownSync(OpenAiSchema.ResponseStreamEvent)({ + const flat = Schema.decodeSync(OpenAiSchema.ResponseStreamEvent)({ type: 'error', message: 'flat provider error', }) - const nested = Schema.decodeUnknownSync(OpenAiSchema.ResponseStreamEvent)({ + const nested = Schema.decodeSync(OpenAiSchema.ResponseStreamEvent)({ type: 'error', error: { message: 'nested provider error' }, }) diff --git a/packages/effect-branded-id/test/branded-id.test.ts b/packages/effect-branded-id/test/branded-id.test.ts index 48915d8..a393ab9 100644 --- a/packages/effect-branded-id/test/branded-id.test.ts +++ b/packages/effect-branded-id/test/branded-id.test.ts @@ -26,10 +26,10 @@ it('accepts custom brand name', () => { }) it('throws on invalid prefix', () => { - expect(() => makeBrandedId('')).toThrow() - expect(() => makeBrandedId('123')).toThrow() - expect(() => makeBrandedId('UPPER')).toThrow() - expect(() => makeBrandedId('_starts_underscore')).toThrow() + expect(() => makeBrandedId('')).toThrow('Invalid prefix') + expect(() => makeBrandedId('123')).toThrow('Invalid prefix') + expect(() => makeBrandedId('UPPER')).toThrow('Invalid prefix') + expect(() => makeBrandedId('_starts_underscore')).toThrow('Invalid prefix') }) it('generates valid IDs with prefix', () => { @@ -57,7 +57,7 @@ it('make() creates branded ID from valid string', () => { it('make() validates input and throws on invalid', () => { // In Effect Schema, make() validates. Use for trusted input that still needs type narrowing. - expect(() => UserId.make('usr_invalid')).toThrow() + expect(() => UserId.make('usr_invalid')).toThrow('Schema validation failed') }) it('is() returns true for valid IDs', () => { @@ -83,7 +83,7 @@ it('is() returns false for malformed input', () => { it.effect('decodes valid IDs', () => Effect.gen(function* () { const id = UserId.create() - const exit = yield* Effect.exit(Schema.decodeUnknownEffect(UserId)(id)) + const exit = yield* Effect.exit(Schema.decodeEffect(UserId)(id)) expect(exit).toStrictEqual(Exit.succeed(id)) }), ) @@ -91,21 +91,21 @@ it.effect('decodes valid IDs', () => it.effect('fails to decode wrong prefix', () => Effect.gen(function* () { const orderId = OrderId.create() - const exit = yield* Effect.exit(Schema.decodeUnknownEffect(UserId)(orderId)) + const exit = yield* Effect.exit(Schema.decodeEffect(UserId)(orderId)) expect(exit._tag).toBe('Failure') }), ) it.effect('fails to decode invalid CUID', () => Effect.gen(function* () { - const exit = yield* Effect.exit(Schema.decodeUnknownEffect(UserId)('usr_invalid')) + const exit = yield* Effect.exit(Schema.decodeEffect(UserId)('usr_invalid')) expect(exit._tag).toBe('Failure') }), ) it.effect('fails to decode missing prefix', () => Effect.gen(function* () { - const exit = yield* Effect.exit(Schema.decodeUnknownEffect(UserId)('noprefixhere')) + const exit = yield* Effect.exit(Schema.decodeEffect(UserId)('noprefixhere')) expect(exit._tag).toBe('Failure') }), ) diff --git a/packages/fold-agent/examples/ApplyPatchAgent.ts b/packages/fold-agent/examples/ApplyPatchAgent.ts index 9d19c32..bfdb017 100644 --- a/packages/fold-agent/examples/ApplyPatchAgent.ts +++ b/packages/fold-agent/examples/ApplyPatchAgent.ts @@ -12,9 +12,9 @@ import { join } from 'node:path' import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { defineAgent, openaiModel, startSession } from '@humanlayer/fold-core' -import { Predicate, Console, Effect } from 'effect' +import { Predicate, Console, Effect, Layer } from 'effect' -import { codingTools } from '../src/index' +import { codingTools, layerCodingToolServices } from '../src/index' const modelId = process.env.OPENAI_MODEL ?? 'gpt-5.5' const apiKey = process.env.OPENAI_API_KEY @@ -25,6 +25,10 @@ const makeProgram = (apiKey: string) => writeFileSync(join(workspace, 'config.json'), '{\n "retries": 1,\n "verbose": false\n}\n') yield* Console.log(`workspace: ${workspace}`) + // The coding tools' services live as long as the session, in this program's scope. + const toolServices = yield* Layer.build( + layerCodingToolServices({ outputDirectory: join(workspace, 'tool-output') }), + ) const session = yield* startSession({ agent: defineAgent({ name: 'apply-patch-demo', @@ -35,7 +39,7 @@ const makeProgram = (apiKey: string) => tools: codingTools({ cwd: workspace }), }), cwd: workspace, - }) + }).pipe(Effect.provideContext(toolServices)) const finished = yield* session.send( 'Read config.json, then use apply_patch to set "retries" to 3 and read it back to confirm.', diff --git a/packages/fold-codex/examples/CodexAgent.ts b/packages/fold-agent/examples/CodexAgent.ts similarity index 81% rename from packages/fold-codex/examples/CodexAgent.ts rename to packages/fold-agent/examples/CodexAgent.ts index 5ef8079..c16d342 100644 --- a/packages/fold-codex/examples/CodexAgent.ts +++ b/packages/fold-agent/examples/CodexAgent.ts @@ -5,18 +5,18 @@ * hidden by the family policy - and streaming rides the hardened first-event/idle timeout + retry * pipeline. * - * Run: bun packages/fold-codex/examples/CodexAgent.ts + * Run: bun packages/fold-agent/examples/CodexAgent.ts */ import { mkdtempSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' -import { codingTools, jsonlEventLog } from '@humanlayer/fold-agent' +import { codexModel } from '@humanlayer/fold-codex' import { defineAgent, startSession } from '@humanlayer/fold-core' -import { Predicate, Console, Effect } from 'effect' +import { Predicate, Console, Effect, Layer } from 'effect' -import { codexModel } from '../src/index' +import { codingTools, jsonlEventLog, layerCodingToolServices } from '../src/index' const modelId = process.env.FOLD_CODEX_MODEL ?? 'gpt-5.5' @@ -25,6 +25,10 @@ const program = Effect.gen(function* () { const logPath = join(workspace, 'session.jsonl') yield* Console.log(`workspace: ${workspace}`) + // The coding tools' services live as long as the session, in this program's scope. + const toolServices = yield* Layer.build( + layerCodingToolServices({ outputDirectory: join(workspace, 'tool-output') }), + ) const session = yield* startSession({ agent: defineAgent({ name: 'codex-demo', @@ -36,7 +40,7 @@ const program = Effect.gen(function* () { }), log: jsonlEventLog(logPath), cwd: workspace, - }) + }).pipe(Effect.provideContext(toolServices)) const finished = yield* session.send( 'Create a file called greet.ts exporting `greet(name: string): string` returning "hello, {name}". ' + diff --git a/packages/fold-agent/examples/CodingAgent.ts b/packages/fold-agent/examples/CodingAgent.ts index c334a1f..9e5c61c 100644 --- a/packages/fold-agent/examples/CodingAgent.ts +++ b/packages/fold-agent/examples/CodingAgent.ts @@ -12,9 +12,9 @@ import { join } from 'node:path' import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { anthropicModel, defineAgent, startSession } from '@humanlayer/fold-core' -import { Predicate, Console, Effect } from 'effect' +import { Predicate, Console, Effect, Layer } from 'effect' -import { codingTools, jsonlEventLog } from '../src/index' +import { codingTools, jsonlEventLog, layerCodingToolServices } from '../src/index' const modelId = process.env.ANTHROPIC_MODEL ?? 'claude-opus-4-8' const apiKey = process.env.ANTHROPIC_API_KEY @@ -25,6 +25,10 @@ const makeProgram = (apiKey: string) => const logPath = join(workspace, 'session.jsonl') yield* Console.log(`workspace: ${workspace}`) + // The coding tools' services live as long as the session, in this program's scope. + const toolServices = yield* Layer.build( + layerCodingToolServices({ outputDirectory: join(workspace, 'tool-output') }), + ) const session = yield* startSession({ agent: defineAgent({ name: 'coding-demo', @@ -36,7 +40,7 @@ const makeProgram = (apiKey: string) => }), log: jsonlEventLog(logPath), cwd: workspace, - }) + }).pipe(Effect.provideContext(toolServices)) const finished = yield* session.send( 'Create a file called greet.ts exporting `greet(name: string): string` returning "hello, {name}". ' + diff --git a/packages/fold-agent/examples/ConfigAgent.ts b/packages/fold-agent/examples/ConfigAgent.ts index b0f0583..4256e0a 100644 --- a/packages/fold-agent/examples/ConfigAgent.ts +++ b/packages/fold-agent/examples/ConfigAgent.ts @@ -1,4 +1,4 @@ -import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' +import * as NodeServices from '@effect/platform-node/NodeServices' /** * Config-driven coding agent (D25/D27): the batteries-included launch path the CLI/OpenTUI will use. * Loads `~/.fold/config.jsonc` (writing a commented starter on first run), resolves the mode's `smart` @@ -11,8 +11,9 @@ import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' */ import { layerLiveIdFactory } from '@humanlayer/fold-core' import { Console, Effect, Layer } from 'effect' +import { FetchHttpClient } from 'effect/unstable/http' -import { configInit, launchSession, loadFoldConfigOrNull } from '../src/index' +import { configInit, launchSession, loadFoldConfigOrNull, Photon } from '../src/index' const prompt = process.argv[2] ?? 'List the files in the current directory and briefly summarize this project.' @@ -30,7 +31,10 @@ const program = Effect.gen(function* () { const finished = yield* session.send(prompt) yield* Console.log(`\n[${finished.outcome}] ${finished.resultText ?? '(no text)'}`) -}).pipe(Effect.provide(Layer.mergeAll(layerLiveIdFactory, NodeFileSystem.layer)), Effect.scoped) +}).pipe( + Effect.provide(Layer.mergeAll(layerLiveIdFactory, NodeServices.layer, FetchHttpClient.layer, Photon.layer)), + Effect.scoped, +) Effect.runPromise(program).catch((error) => { console.error(error) diff --git a/packages/fold-agent/examples/SkillsFromDisk.ts b/packages/fold-agent/examples/SkillsFromDisk.ts index 927b510..a68885c 100644 --- a/packages/fold-agent/examples/SkillsFromDisk.ts +++ b/packages/fold-agent/examples/SkillsFromDisk.ts @@ -13,7 +13,8 @@ import { join } from 'node:path' import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { anthropicModel, defineAgent, skillTool, startSession } from '@humanlayer/fold-core' -import { Console, Effect } from 'effect' +import { Console, Effect, Layer } from 'effect' +import { FetchHttpClient } from 'effect/unstable/http' import { skillsFromDisk } from '../src/index' @@ -24,7 +25,7 @@ const skillFile = (name: string, description: string, body: string): string => `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n` /** Lay out a fake home + repo: one global skill, one repo skill that shadows a global duplicate. */ -const materializeSkillTree = (): { readonly home: string; readonly cwd: string } => { +const materializeSkillTree = () => { const root = mkdtempSync(join(tmpdir(), 'fold-skills-demo-')) const home = join(root, 'home') const repo = join(root, 'repo') @@ -74,7 +75,7 @@ const makeProgram = (apiKey: string) => yield* Console.log(`finished: ${finished.outcome}`) yield* Console.log(`result:\n${finished.resultText ?? '(no text)'}`) - }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)) + }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeFileSystem.layer, FetchHttpClient.layer))) if (apiKey === undefined || apiKey === '') { console.error('Set ANTHROPIC_API_KEY to run this example.') diff --git a/packages/fold-agent/examples/SubagentsAgent.ts b/packages/fold-agent/examples/SubagentsAgent.ts index 437eabf..5f141e7 100644 --- a/packages/fold-agent/examples/SubagentsAgent.ts +++ b/packages/fold-agent/examples/SubagentsAgent.ts @@ -13,9 +13,9 @@ import { join } from 'node:path' import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { anthropicModel, defineAgent, defineSubagent, startSession, subagentTool } from '@humanlayer/fold-core' -import { Predicate, Console, Effect } from 'effect' +import { Predicate, Console, Effect, Layer } from 'effect' -import { bashTool, jsonlEventLog, readTool } from '../src/index' +import { bashTool, jsonlEventLog, layerCodingToolServices, readTool } from '../src/index' const modelId = process.env.ANTHROPIC_MODEL ?? 'claude-opus-4-8' const apiKey = process.env.ANTHROPIC_API_KEY @@ -48,6 +48,10 @@ const makeProgram = (apiKey: string) => }) // The root is a pure orchestrator: its ONLY tool is the subagent tool over its roster. + // The coding tools' services live as long as the session, in this program's scope. + const toolServices = yield* Layer.build( + layerCodingToolServices({ outputDirectory: join(workspace, 'tool-output') }), + ) const session = yield* startSession({ agent: defineAgent({ name: 'subagents-demo', @@ -59,7 +63,7 @@ const makeProgram = (apiKey: string) => }), log: jsonlEventLog(logPath), cwd: workspace, - }) + }).pipe(Effect.provideContext(toolServices)) const first = yield* session.send( 'Dispatch the researcher to list what is in this workspace and summarize each file in one line.', diff --git a/packages/fold-agent/package.json b/packages/fold-agent/package.json index 644fa2e..a877d06 100644 --- a/packages/fold-agent/package.json +++ b/packages/fold-agent/package.json @@ -32,6 +32,7 @@ "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", + "@humanlayer/effect-ai-openai": "workspace:*", "@humanlayer/fold-vitest-config": "workspace:*", "@types/turndown": "catalog:", "effect": "catalog:", diff --git a/packages/fold-agent/scripts/update-managed-binaries.ts b/packages/fold-agent/scripts/update-managed-binaries.ts index 29310e6..09dc8c3 100644 --- a/packages/fold-agent/scripts/update-managed-binaries.ts +++ b/packages/fold-agent/scripts/update-managed-binaries.ts @@ -12,7 +12,8 @@ import { writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { Effect, Predicate, Schema } from 'effect' +import { Array as Arr, Duration, Effect, Schema } from 'effect' +import { FetchHttpClient, HttpClient, HttpClientResponse } from 'effect/unstable/http' import { MANAGED_BINARY_PLATFORMS, managedBinaryRegistry } from '../src/Bin/Registry' @@ -25,22 +26,19 @@ class ChecksumBakeError extends Schema.TaggedError()('Checksu message: Schema.String, }) {} -const fetchBytes = (url: string): Effect.Effect => - Effect.tryPromise({ - try: async (signal): Promise => { - const response = await fetch(url, { signal }) - if (!response.ok) throw new Error(`responded ${response.status}`) - return new Uint8Array(await response.arrayBuffer()) - }, - catch: (cause) => - new ChecksumBakeError({ - message: `GET ${url}: ${Predicate.isError(cause) ? cause.message : String(cause)}`, - }), - }).pipe( - Effect.timeout(downloadTimeoutMillis), - Effect.catchTag('TimeoutError', () => - Effect.fail(new ChecksumBakeError({ message: `GET ${url} timed out after ${downloadTimeoutMillis}ms` })), - ), +const fetchBytes = (url: string): Effect.Effect => + HttpClient.get(url).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.arrayBuffer), + Effect.map((buffer) => new Uint8Array(buffer)), + Effect.mapError((error) => new ChecksumBakeError({ message: `GET ${url}: ${error.message}` })), + Effect.timeoutOrElse({ + duration: Duration.millis(downloadTimeoutMillis), + orElse: () => + Effect.fail( + new ChecksumBakeError({ message: `GET ${url} timed out after ${downloadTimeoutMillis}ms` }), + ), + }), ) /** Every pinned asset URL across the registry, in registry-then-platform order, deduplicated. */ @@ -105,7 +103,7 @@ const program = Effect.gen(function* () { spawnSync('bunx', ['oxfmt', targetPath], { stdio: 'inherit' }) yield* Effect.log(`wrote ${digests.length} digests to ${targetPath}`) - if (failures.length > 0) { + if (Arr.isArrayNonEmpty(failures)) { yield* Effect.logWarning( `${failures.length} assets could not be hashed and stay UNPINNED (verification skipped at install):\n` + failures.join('\n'), @@ -113,7 +111,7 @@ const program = Effect.gen(function* () { } }) -Effect.runPromise(program).catch((cause: unknown) => { +Effect.runPromise(program.pipe(Effect.provide(FetchHttpClient.layer))).catch((cause: unknown) => { console.error(cause) process.exitCode = 1 }) diff --git a/packages/fold-agent/scripts/update-model-catalog.ts b/packages/fold-agent/scripts/update-model-catalog.ts index a19517a..265e9e5 100644 --- a/packages/fold-agent/scripts/update-model-catalog.ts +++ b/packages/fold-agent/scripts/update-model-catalog.ts @@ -11,10 +11,12 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import * as NodeRuntime from '@effect/platform-node/NodeRuntime' import type { ModelCatalogEntry, ModelPricing } from '@humanlayer/fold-core' -import { Effect, Schema } from 'effect' +import { Array as Arr, Effect, Schema } from 'effect' +import { FetchHttpClient, HttpClient, HttpClientResponse } from 'effect/unstable/http' -import { decodeModelsDevModels } from '../src/Catalog/ModelsDevSchema' +import { decodeModelsDevModels, ModelsDevPayload } from '../src/Catalog/ModelsDevSchema' import { modelCatalogEntriesFromModelsDev } from '../src/Catalog/Normalize' const SNAPSHOT_PATH = '/tmp/models_dev_api.json' @@ -29,24 +31,35 @@ class CatalogBakeError extends Schema.TaggedError()('CatalogBa message: Schema.String, }) {} -/** The raw payload: the local snapshot when present, a fresh fetch otherwise. */ -const readPayload: Effect.Effect = Effect.suspend(() => { +const decodePayloadText = Schema.decodeEffect(Schema.fromJsonString(ModelsDevPayload)) + +/** The decoded provider map: the local snapshot when present, a fresh fetch otherwise. */ +const readPayload: Effect.Effect = Effect.suspend(() => { if (existsSync(SNAPSHOT_PATH)) { return Effect.try({ - try: (): unknown => JSON.parse(readFileSync(SNAPSHOT_PATH, 'utf8')), - catch: (cause) => new CatalogBakeError({ message: `could not parse ${SNAPSHOT_PATH}: ${String(cause)}` }), - }).pipe(Effect.tap(() => Effect.log(`using local snapshot ${SNAPSHOT_PATH}`))) + try: () => readFileSync(SNAPSHOT_PATH, 'utf8'), + catch: (cause) => new CatalogBakeError({ message: `could not read ${SNAPSHOT_PATH}: ${String(cause)}` }), + }).pipe( + Effect.flatMap((text) => + decodePayloadText(text).pipe( + Effect.mapError( + (error) => + new CatalogBakeError({ message: `could not parse ${SNAPSHOT_PATH}: ${error.message}` }), + ), + ), + ), + Effect.tap(() => Effect.log(`using local snapshot ${SNAPSHOT_PATH}`)), + ) } - return Effect.tryPromise({ - try: async (signal): Promise => { - const response = await fetch(MODELS_DEV_URL, { signal }) - if (!response.ok) throw new Error(`GET ${MODELS_DEV_URL} responded ${response.status}`) - const body: unknown = await response.json() - return body - }, - catch: (cause) => new CatalogBakeError({ message: `could not fetch ${MODELS_DEV_URL}: ${String(cause)}` }), - }).pipe(Effect.tap(() => Effect.log(`fetched ${MODELS_DEV_URL}`))) + return HttpClient.get(MODELS_DEV_URL).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(ModelsDevPayload)), + Effect.mapError( + (error) => new CatalogBakeError({ message: `could not fetch ${MODELS_DEV_URL}: ${error.message}` }), + ), + Effect.tap(() => Effect.log(`fetched ${MODELS_DEV_URL}`)), + ) }) const stringLiteral = (value: string): string => `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'` @@ -110,7 +123,7 @@ const main = Effect.gen(function* () { : left.providerId.localeCompare(right.providerId), ) - if (entries.length === 0) { + if (Arr.isArrayEmpty(entries)) { return yield* new CatalogBakeError({ message: 'no anthropic/openai/xai entries survived normalization; refusing to bake', }) @@ -124,7 +137,4 @@ const main = Effect.gen(function* () { yield* Effect.log(`wrote ${entries.length} entries to ${targetPath}`) }) -void Effect.runPromise(main).catch((error: unknown) => { - console.error(error) - process.exitCode = 1 -}) +main.pipe(Effect.provide(FetchHttpClient.layer), NodeRuntime.runMain) diff --git a/packages/fold-agent/src/Bin/BinaryChecksums.ts b/packages/fold-agent/src/Bin/BinaryChecksums.ts index a8726a5..db37b88 100644 --- a/packages/fold-agent/src/Bin/BinaryChecksums.ts +++ b/packages/fold-agent/src/Bin/BinaryChecksums.ts @@ -6,35 +6,65 @@ * Pinned sha256 digests for the registry's release assets (D18). An asset URL missing from this * table reads as an unpinned asset: the download still happens, but verification is skipped. */ -export const managedBinaryChecksums: Readonly> = { - 'https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-aarch64-apple-darwin.tar.gz': +export const managedBinaryChecksums: ReadonlyMap = new Map([ + [ + 'https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-aarch64-apple-darwin.tar.gz', '378e973289176ca0c6054054ee7f631a065874a352bf43f0fa60ef079b6ba715', - 'https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-apple-darwin.tar.gz': + ], + [ + 'https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-apple-darwin.tar.gz', '64811cb24e77cac3057d6c40b63ac9becf9082eedd54ca411b475b755d334882', - 'https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-unknown-linux-musl.tar.gz': + ], + [ + 'https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-unknown-linux-musl.tar.gz', '1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599', - 'https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-aarch64-unknown-linux-gnu.tar.gz': + ], + [ + 'https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-aarch64-unknown-linux-gnu.tar.gz', '2b661c6ef508e902f388e9098d9c4c5aca72c87b55922d94abdba830b4dc885e', - 'https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-pc-windows-msvc.zip': + ], + [ + 'https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-pc-windows-msvc.zip', '124510b94b6baa3380d051fdf4650eaa80a302c876d611e9dba0b2e18d87493a', - 'https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-aarch64-apple-darwin.tar.gz': + ], + [ + 'https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-aarch64-apple-darwin.tar.gz', '623dc0afc81b92e4d4606b380d7bc91916ba7b97814263e554d50923a39e480a', - 'https://github.com/sharkdp/fd/releases/download/v10.3.0/fd-v10.3.0-x86_64-apple-darwin.tar.gz': + ], + [ + 'https://github.com/sharkdp/fd/releases/download/v10.3.0/fd-v10.3.0-x86_64-apple-darwin.tar.gz', '50d30f13fe3d5914b14c4fff5abcbd4d0cdab4b855970a6956f4f006c17117a3', - 'https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-x86_64-unknown-linux-musl.tar.gz': + ], + [ + 'https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-x86_64-unknown-linux-musl.tar.gz', 'e3257d48e29a6be965187dbd24ce9af564e0fe67b3e73c9bdcd180f4ec11bdde', - 'https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-aarch64-unknown-linux-musl.tar.gz': + ], + [ + 'https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-aarch64-unknown-linux-musl.tar.gz', 'f32d3657473fba74e2600babc8db0b93420d51169223b7e8143b2ed55d8fd9e8', - 'https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-x86_64-pc-windows-msvc.zip': + ], + [ + 'https://github.com/sharkdp/fd/releases/download/v10.4.2/fd-v10.4.2-x86_64-pc-windows-msvc.zip', 'b2816e506390a89941c63c9187d58a3cc10e9a55f2ef0685f9ea0eccaf7c98c8', - 'https://github.com/ast-grep/ast-grep/releases/download/0.44.1/app-aarch64-apple-darwin.zip': + ], + [ + 'https://github.com/ast-grep/ast-grep/releases/download/0.44.1/app-aarch64-apple-darwin.zip', '0a2fef273b0ff1238b8307add911714f92021d25b919fa3ec9b6b2e046bb29cf', - 'https://github.com/ast-grep/ast-grep/releases/download/0.44.1/app-x86_64-apple-darwin.zip': + ], + [ + 'https://github.com/ast-grep/ast-grep/releases/download/0.44.1/app-x86_64-apple-darwin.zip', '46584f3e4f67e9ae482de69e71e4e4aa88e68da322316fdd25ad73f2621ddbc5', - 'https://github.com/ast-grep/ast-grep/releases/download/0.44.1/app-x86_64-unknown-linux-gnu.zip': + ], + [ + 'https://github.com/ast-grep/ast-grep/releases/download/0.44.1/app-x86_64-unknown-linux-gnu.zip', '611f9e5e76f2611ecea1a35dd3468ceedf600641a11224b80341d79c6ee7b9dd', - 'https://github.com/ast-grep/ast-grep/releases/download/0.44.1/app-aarch64-unknown-linux-gnu.zip': + ], + [ + 'https://github.com/ast-grep/ast-grep/releases/download/0.44.1/app-aarch64-unknown-linux-gnu.zip', '077a4ab0c628154ef3cb79fecaf11dabca7f8a41f2c7260c022f263a52c1b021', - 'https://github.com/ast-grep/ast-grep/releases/download/0.44.1/app-x86_64-pc-windows-msvc.zip': + ], + [ + 'https://github.com/ast-grep/ast-grep/releases/download/0.44.1/app-x86_64-pc-windows-msvc.zip', 'f122d5507e5206c3a9f030fc3a0d137070eb6c4f708e1b496df56795bc9858b9', -} + ], +]) diff --git a/packages/fold-agent/src/Bin/ManagedBinaries.ts b/packages/fold-agent/src/Bin/ManagedBinaries.ts index c842e54..10fb587 100644 --- a/packages/fold-agent/src/Bin/ManagedBinaries.ts +++ b/packages/fold-agent/src/Bin/ManagedBinaries.ts @@ -7,24 +7,25 @@ * installed binaries reachable from agent prompts. * * The ensure NEVER fails: each binary independently degrades to an `unavailable` status with the - * raw failure logged as a warning, because a missing binary is a capability downgrade, not a launch - * failure. `FOLD_DISABLE_BINARY_DOWNLOADS` (env seam) or the `disableDownloads` option (used by - * `fold bin status`) skip the download step while still reporting system/managed hits. Results are - * memoized per process, keyed by foldHome + download mode, so the CLI's background ensure and any - * later call share one resolution pass. + * failure logged as a warning, because a missing binary is a capability downgrade, not a launch + * failure. `FOLD_DISABLE_BINARY_DOWNLOADS` (read through `Config`) or the `disableDownloads` option + * (used by `fold bin status`) skip the download step while still reporting system/managed hits. * - * Seams follow the fold-agent options convention (Catalog/LoadCatalog.ts): `fileSystem`, `env`, - * `which`, `download`, and `exec` all carry real defaults and swap wholesale in tests. One known - * tradeoff, inherited from pi: a system ALIAS hit (`fdfind`, `sg`) short-circuits the managed - * install even though the canonical name stays absent from PATH. + * The {@link ManagedBinaries} layer yields everything once when it builds: `FileSystem` (PATH scan, + * install), `ChildProcessSpawner` (version checks, extraction), `HttpClient` (downloads), the + * {@link HostPlatform} reference (defaults to the running process), and PATH / PATHEXT / the kill + * switch through `Config`. Tests swap those layers and provide a `ConfigProvider`. The layer owns a + * `Cache` keyed by foldHome + download mode, so every caller in one process shares one pass per key. + * + * One known tradeoff, inherited from pi: a system ALIAS hit (`fdfind`, `sg`) short-circuits the + * managed install even though the canonical name stays absent from PATH. */ -import { execFile } from 'node:child_process' -import { createHash, randomBytes } from 'node:crypto' -import { accessSync, constants, statSync } from 'node:fs' -import { delimiter, join } from 'node:path' -import { promisify } from 'node:util' +import { createHash } from 'node:crypto' +import { join } from 'node:path' -import { Cause, Effect, FileSystem, Schema } from 'effect' +import { Cache, Cause, Config, Context, Data, Duration, Effect, FileSystem, Layer, Option, Stream } from 'effect' +import { HttpClient, HttpClientResponse } from 'effect/unstable/http' +import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process' import { managedBinaryRegistry, type ManagedBinaryAsset, type ManagedBinaryDefinition } from './Registry' @@ -38,19 +39,19 @@ const execTimeoutMillis = 60_000 export const managedBinDir = (foldHome: string): string => join(foldHome, 'bin') /** A release-asset download failed: network error, HTTP error status, or timeout. */ -export class BinaryDownloadError extends Schema.TaggedError()('BinaryDownloadError', { - message: Schema.String, -}) {} +export class BinaryDownloadError extends Data.TaggedError('BinaryDownloadError')<{ + readonly message: string +}> {} /** A helper command (`tar`, `unzip`, ` --version`) could not run or exited non-zero. */ -export class BinaryExecError extends Schema.TaggedError()('BinaryExecError', { - message: Schema.String, -}) {} +export class BinaryExecError extends Data.TaggedError('BinaryExecError')<{ + readonly message: string +}> {} /** A downloaded asset failed verification or did not contain the expected binary. */ -export class BinaryInstallError extends Schema.TaggedError()('BinaryInstallError', { - message: Schema.String, -}) {} +export class BinaryInstallError extends Data.TaggedError('BinaryInstallError')<{ + readonly message: string +}> {} /** * How one managed binary resolved: `system` (a usable binary already on PATH), `managed` (already @@ -71,143 +72,50 @@ export type ManagedBinaryStatus = { readonly detail: string | null } -/** Locate one command on PATH: the absolute executable path, or null when absent. */ -export type WhichSeam = (name: string) => Effect.Effect - -/** Fetch one release asset's bytes. */ -export type DownloadSeam = (url: string) => Effect.Effect +/** The host platform and architecture assets are selected for (`process.platform` / `process.arch` shapes). */ +export type HostPlatformInfo = { + readonly platform: string + readonly arch: string +} -/** Run one helper command to completion, failing on non-zero exit. */ -export type ExecSeam = ( - command: string, - args: ReadonlyArray, -) => Effect.Effect<{ stdout: string }, BinaryExecError> +/** + * The running host's platform and architecture. A `Context.Reference` because the value has a + * natural default (the current process) and only tests override it, with + * `Layer.succeed(HostPlatform, ...)` under the {@link ManagedBinaries} layer. + */ +export const HostPlatform = Context.Reference('fold-agent/Bin/HostPlatform', { + defaultValue: () => ({ platform: process.platform, arch: process.arch }), +}) /** Options for {@link ensureManagedBinaries}. */ export type EnsureManagedBinariesOptions = { /** The fold home directory; binaries install into `/bin`. */ readonly foldHome: string - /** @deprecated FileSystem is now provided through the Effect R channel. */ - readonly fileSystem?: FileSystem.FileSystem - /** Environment lookup for {@link FOLD_DISABLE_BINARY_DOWNLOADS} and PATH. Defaults to `process.env`. */ - readonly env?: (name: string) => string | undefined - /** PATH lookup seam. Defaults to scanning the env seam's PATH for an executable file. */ - readonly which?: WhichSeam - /** Asset download seam. Defaults to global `fetch` with a 30s timeout. */ - readonly download?: DownloadSeam - /** Helper-command seam (version checks, extraction). Defaults to `node:child_process` execFile. */ - readonly exec?: ExecSeam - /** Platform override for asset selection and file naming. Defaults to `process.platform`. */ - readonly platform?: string - /** Architecture override for asset selection. Defaults to `process.arch`. */ - readonly arch?: string /** Skip the download step (used by `fold bin status`); system/managed hits still resolve. */ readonly disableDownloads?: boolean /** Install/check the canonical managed copy in `/bin` even when a system binary exists. */ readonly requireManagedInstall?: boolean - /** Do not log per-binary resolution failures; callers that run in the background can fail silently. */ - readonly suppressWarnings?: boolean - /** Registry override for tests. Defaults to {@link managedBinaryRegistry}. */ - readonly registry?: ReadonlyArray - /** Set false to bypass the per-process memoization (tests). Defaults to true. */ + /** Set false to run a fresh pass instead of sharing the per-process cached one. Defaults to true. */ readonly memoize?: boolean } -/** Everything a resolution pass needs, with every seam already defaulted. */ -type ResolveContext = { +/** What one resolution pass is keyed by: the fold home plus the download mode. */ +type EnsureKey = { readonly foldHome: string - readonly fs: FileSystem.FileSystem - readonly env: (name: string) => string | undefined - readonly which: WhichSeam - readonly download: DownloadSeam - readonly exec: ExecSeam - readonly platform: string - readonly arch: string - readonly downloadsDisabled: boolean + readonly disableDownloads: boolean readonly requireManagedInstall: boolean - readonly suppressWarnings: boolean -} - -/** The ONE mapper from thrown download failures to the typed download error. */ -const downloadErrorFrom = (url: string, cause: unknown): BinaryDownloadError => - new BinaryDownloadError({ message: `GET ${url}: ${cause instanceof Error ? cause.message : String(cause)}` }) - -const defaultDownload: DownloadSeam = (url) => - Effect.tryPromise({ - try: async (signal): Promise => { - const response = await fetch(url, { signal }) - if (!response.ok) throw new Error(`responded ${response.status}`) - return new Uint8Array(await response.arrayBuffer()) - }, - catch: (cause) => downloadErrorFrom(url, cause), - }).pipe( - Effect.timeout(downloadTimeoutMillis), - Effect.catchTag('TimeoutError', () => - Effect.fail(new BinaryDownloadError({ message: `GET ${url} timed out after ${downloadTimeoutMillis}ms` })), - ), - ) - -/** 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) - return new BinaryExecError({ - message: `${command} ${args.join(' ')}: ${reason}${stderr === '' ? '' : ` (${stderr.slice(0, 400)})`}`, - }) } -const execFileAsync = promisify(execFile) - -const defaultExec: ExecSeam = (command, args) => - Effect.tryPromise({ - try: async (): Promise<{ stdout: string }> => { - const result = await execFileAsync(command, [...args], { - timeout: execTimeoutMillis, - maxBuffer: 4 * 1024 * 1024, - windowsHide: true, - }) - return { stdout: result.stdout } - }, - catch: (cause) => execErrorFrom(command, args, cause), - }) - -/** Whether one candidate path is an executable regular file (real-filesystem check, default seam only). */ -const isExecutableFile = (path: string): boolean => { - try { - accessSync(path, constants.X_OK) - return statSync(path).isFile() - } catch { - return false - } +/** What the resolver reads once from the host and environment when its layer builds. */ +type HostEnvironment = HostPlatformInfo & { + readonly pathEntries: ReadonlyArray + readonly executableExtensions: ReadonlyArray + /** Whether {@link FOLD_DISABLE_BINARY_DOWNLOADS} is set (non-empty). */ + readonly downloadsKillSwitch: boolean } -/** - * The default which: scan the env seam's PATH for an executable file, honoring PATHEXT on Windows. - * A manual scan beats shelling out to `command -v`, which also matches shell builtins and functions. - */ -const defaultWhich = - (env: (name: string) => string | undefined, platform: string): WhichSeam => - (name) => - Effect.sync(() => { - const pathValue = env('PATH') ?? '' - const extensions = - platform === 'win32' - ? (env('PATHEXT') ?? '.EXE;.CMD;.BAT;.COM').split(';').map((extension) => extension.toLowerCase()) - : [''] - - for (const directory of pathValue.split(delimiter)) { - if (directory === '') continue - for (const extension of extensions) { - const candidate = join(directory, `${name}${extension}`) - if (isExecutableFile(candidate)) return candidate - } - } - - return null - }) +/** Everything one resolution pass needs. */ +type ResolveContext = EnsureKey & HostEnvironment /** Parse the first `major.minor.patch` triple out of arbitrary `--version` output; null when absent. */ export const parseBinaryVersion = (text: string): readonly [number, number, number] | null => { @@ -229,23 +137,6 @@ const versionAtLeast = (left: readonly [number, number, number], right: readonly return true } -/** - * Whether a resolved system binary satisfies the definition's version floor. Unparseable output or a - * failing `--version` counts as NOT satisfying it: falling through to the managed install is safe - * (the managed copy shadows nothing - `/bin` is PREPENDED to PATH) and self-healing. - */ -const satisfiesMinVersion = (context: ResolveContext, binaryPath: string, minVersion: string): Effect.Effect => - Effect.gen(function* () { - const floor = parseBinaryVersion(minVersion) - if (floor === null) return true - - const output = yield* context.exec(binaryPath, ['--version']).pipe(Effect.catch(() => Effect.succeed(null))) - if (output === null) return false - const version = parseBinaryVersion(output.stdout) - - return version !== null && versionAtLeast(version, floor) - }) - /** The installed file name for one definition: `.exe` on Windows, `` elsewhere. */ const installedFileName = (definition: ManagedBinaryDefinition, platform: string): string => platform === 'win32' ? `${definition.name}.exe` : definition.name @@ -258,238 +149,372 @@ const assetFileName = (url: string): string => { return lastSlash === -1 ? url : url.slice(lastSlash + 1) } -/** - * Extract one archive into a directory through the exec seam. tar.gz goes straight to the system - * `tar`; zip tries `unzip` first and falls back to `tar xf` (bsdtar on macOS and Windows System32 - * reads zip archives; GNU tar does not, hence the unzip-first order). - */ -const extractArchive = ( - context: ResolveContext, - asset: ManagedBinaryAsset, - archivePath: string, - extractDir: string, -): Effect.Effect => { - if (asset.archive === 'tar.gz') { - return context.exec('tar', ['xzf', archivePath, '-C', extractDir]).pipe(Effect.asVoid) - } - - return context.exec('unzip', ['-q', '-o', archivePath, '-d', extractDir]).pipe( - Effect.catch((unzipError) => - context - .exec('tar', ['xf', archivePath, '-C', extractDir]) - .pipe( - Effect.mapError( - (tarError) => new BinaryExecError({ message: `${unzipError.message}; ${tarError.message}` }), - ), - ), +/** Read one optional environment value through `Config`; absent or empty reads as null. */ +const readEnv = (name: string): Effect.Effect => + Config.option(Config.string(name)).pipe( + Effect.map((value) => + Option.getOrElse( + Option.filter(value, (text) => text !== ''), + () => null, + ), + ), + Effect.catchTag('ConfigError', (error) => + Effect.logDebug(`could not read ${name}: ${error.message}`).pipe(Effect.as(null)), ), - Effect.asVoid, ) -} -/** - * Download, verify, extract, and install one binary. Verification happens on the in-memory bytes - * BEFORE anything is written, so a bad digest never leaves a file behind; the rename out of a temp - * directory under `/bin` keeps the final write atomic on one filesystem. - */ -// Return type inferred: the typed union is the three Binary* errors plus the FileSystem operations' -// platform errors, and the only consumer (resolveOneNeverFailing) catches the whole cause anyway. -const installFromAsset = ( - context: ResolveContext, - definition: ManagedBinaryDefinition, - asset: ManagedBinaryAsset, - installPath: string, -) => +/** Read the host platform, PATH, PATHEXT, and the download kill switch. */ +const readHostEnvironment: Effect.Effect = Effect.gen(function* () { + const host = yield* HostPlatform + const windows = host.platform === 'win32' + const pathValue = (yield* readEnv('PATH')) ?? '' + const pathExt = windows ? ((yield* readEnv('PATHEXT')) ?? '.EXE;.CMD;.BAT;.COM') : null + const killSwitch = yield* readEnv(FOLD_DISABLE_BINARY_DOWNLOADS) + + return { + ...host, + downloadsKillSwitch: killSwitch !== null, + pathEntries: pathValue.split(windows ? ';' : ':').filter((entry) => entry !== ''), + executableExtensions: pathExt === null ? [''] : pathExt.split(';').map((extension) => extension.toLowerCase()), + } +}) + +const makeManagedBinaries = (registry: ReadonlyArray) => Effect.gen(function* () { - const bytes = yield* context.download(asset.url) + const fs = yield* FileSystem.FileSystem + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const http = yield* HttpClient.HttpClient + const environment = yield* readHostEnvironment + + /** Fetch one release asset's bytes, with a 30s timeout. */ + const downloadAsset = (url: string): Effect.Effect => + http.get(url).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.arrayBuffer), + Effect.map((buffer) => new Uint8Array(buffer)), + Effect.catchTag('HttpClientError', (error) => + Effect.fail(new BinaryDownloadError({ message: `GET ${url}: ${error.message}` })), + ), + Effect.timeoutOrElse({ + duration: Duration.millis(downloadTimeoutMillis), + orElse: () => + Effect.fail( + new BinaryDownloadError({ + message: `GET ${url} timed out after ${downloadTimeoutMillis}ms`, + }), + ), + }), + ) - if (asset.sha256 !== null) { - const digest = sha256Hex(bytes) - if (digest !== asset.sha256) { - return yield* new BinaryInstallError({ - message: `sha256 mismatch for ${asset.url}: expected ${asset.sha256}, downloaded ${digest}`, - }) - } + /** Run one helper command to completion; a non-zero exit fails with its stderr. */ + const exec = (command: string, args: ReadonlyArray): Effect.Effect => { + const label = `${command} ${args.join(' ')}` + return Effect.gen(function* () { + const handle = yield* spawner.spawn(ChildProcess.make(command, args)) + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + handle.stdout.pipe(Stream.decodeText(), Stream.mkString), + handle.stderr.pipe(Stream.decodeText(), Stream.mkString), + handle.exitCode, + ], + { concurrency: 'unbounded' }, + ) + if (exitCode !== ChildProcessSpawner.ExitCode(0)) { + const trimmed = stderr.trim() + return yield* new BinaryExecError({ + message: `${label}: exited with code ${exitCode}${trimmed === '' ? '' : ` (${trimmed.slice(0, 400)})`}`, + }) + } + return stdout + }).pipe( + Effect.scoped, + Effect.catchTag('PlatformError', (error) => + Effect.fail(new BinaryExecError({ message: `${label}: ${error.message}` })), + ), + Effect.timeoutOrElse({ + duration: Duration.millis(execTimeoutMillis), + orElse: () => + Effect.fail( + new BinaryExecError({ message: `${label}: timed out after ${execTimeoutMillis}ms` }), + ), + }), + ) } - const binDir = managedBinDir(context.foldHome) - const extractDir = join(binDir, `.tmp-${definition.name}-${randomBytes(6).toString('hex')}`) + /** + * Whether one candidate path is an executable regular file. Off Windows, any execute bit + * counts (the FileSystem service has no X_OK check); on Windows, PATHEXT already decides. + */ + const isExecutableFile = (path: string, platform: string): Effect.Effect => + fs.stat(path).pipe( + Effect.map((info) => info.type === 'File' && (platform === 'win32' || (info.mode & 0o111) !== 0)), + Effect.catchTag('PlatformError', () => Effect.succeed(false)), + ) + + /** + * Locate one command on PATH. A manual scan beats shelling out to `command -v`, which also + * matches shell builtins and functions. + */ + const which = (context: ResolveContext, name: string): Effect.Effect => + Effect.gen(function* () { + for (const directory of context.pathEntries) { + for (const extension of context.executableExtensions) { + const candidate = join(directory, `${name}${extension}`) + if (yield* isExecutableFile(candidate, context.platform)) return candidate + } + } + return null + }) - yield* Effect.gen(function* () { - yield* context.fs.makeDirectory(extractDir, { recursive: true }) - const archivePath = join(extractDir, assetFileName(asset.url)) - yield* context.fs.writeFile(archivePath, bytes) - yield* extractArchive(context, asset, archivePath, extractDir) + /** + * Whether a resolved system binary satisfies the definition's version floor. Unparseable output + * or a failing `--version` counts as NOT satisfying it: falling through to the managed install is + * safe (the managed copy shadows nothing - `/bin` is PREPENDED to PATH) and self-healing. + */ + const satisfiesMinVersion = (binaryPath: string, minVersion: string): Effect.Effect => + Effect.gen(function* () { + const floor = parseBinaryVersion(minVersion) + if (floor === null) return true + + const output = yield* exec(binaryPath, ['--version']).pipe(Effect.option) + if (Option.isNone(output)) return false + const version = parseBinaryVersion(output.value) + + return version !== null && versionAtLeast(version, floor) + }) - const extractedPath = join(extractDir, asset.pathInArchive) - const present = yield* context.fs.exists(extractedPath).pipe(Effect.catch(() => Effect.succeed(false))) - if (!present) { - return yield* new BinaryInstallError({ - message: `${assetFileName(asset.url)} did not contain ${asset.pathInArchive}`, - }) + /** + * Extract one archive into a directory. tar.gz goes straight to the system `tar`; zip tries + * `unzip` first and falls back to `tar xf` (bsdtar on macOS and Windows System32 reads zip + * archives; GNU tar does not, hence the unzip-first order). + */ + const extractArchive = ( + asset: ManagedBinaryAsset, + archivePath: string, + extractDir: string, + ): Effect.Effect => { + if (asset.archive === 'tar.gz') { + return exec('tar', ['xzf', archivePath, '-C', extractDir]).pipe(Effect.asVoid) } - yield* context.fs.rename(extractedPath, installPath) - yield* context.fs.chmod(installPath, 0o755) - }).pipe(Effect.ensuring(context.fs.remove(extractDir, { recursive: true, force: true }).pipe(Effect.ignore))) - }) - -/** Resolve one binary through the system -> managed -> download ladder. Failures propagate typed (inferred). */ -const resolveOne = (context: ResolveContext, definition: ManagedBinaryDefinition) => - Effect.gen(function* () { - const resolveSystem = Effect.gen(function* () { - for (const systemName of definition.systemNames) { - const found = yield* context.which(systemName) - if (found === null) continue - if (definition.minVersion !== null) { - const usable = yield* satisfiesMinVersion(context, found, definition.minVersion) - if (!usable) continue - } + return exec('unzip', ['-q', '-o', archivePath, '-d', extractDir]).pipe( + Effect.catchTag('BinaryExecError', (unzipError) => + exec('tar', ['xf', archivePath, '-C', extractDir]).pipe( + Effect.mapError( + (tarError) => + new BinaryExecError({ message: `${unzipError.message}; ${tarError.message}` }), + ), + ), + ), + Effect.asVoid, + ) + } - return { - name: definition.name, - resolution: 'system' as const, - path: found, - detail: `system binary "${systemName}" on PATH`, + /** + * Download, verify, extract, and install one binary. Verification happens on the in-memory + * bytes BEFORE anything is written, so a bad digest never leaves a file behind; the rename out + * of a temp directory under `/bin` keeps the final write atomic on one filesystem. + */ + const installFromAsset = ( + context: ResolveContext, + definition: ManagedBinaryDefinition, + asset: ManagedBinaryAsset, + installPath: string, + ) => + Effect.gen(function* () { + const bytes = yield* downloadAsset(asset.url) + + if (asset.sha256 !== null) { + const digest = sha256Hex(bytes) + if (digest !== asset.sha256) { + return yield* new BinaryInstallError({ + message: `sha256 mismatch for ${asset.url}: expected ${asset.sha256}, downloaded ${digest}`, + }) + } } - } - - return null - }) - if (!context.requireManagedInstall) { - const system = yield* resolveSystem - if (system !== null) return system - } + const binDir = managedBinDir(context.foldHome) + yield* fs.makeDirectory(binDir, { recursive: true }) + yield* Effect.gen(function* () { + const extractDir = yield* fs.makeTempDirectoryScoped({ + directory: binDir, + prefix: `.tmp-${definition.name}-`, + }) + const archivePath = join(extractDir, assetFileName(asset.url)) + yield* fs.writeFile(archivePath, bytes) + yield* extractArchive(asset, archivePath, extractDir) + + const extractedPath = join(extractDir, asset.pathInArchive) + const present = yield* fs.exists(extractedPath).pipe(Effect.orElseSucceed(() => false)) + if (!present) { + return yield* new BinaryInstallError({ + message: `${assetFileName(asset.url)} did not contain ${asset.pathInArchive}`, + }) + } + + yield* fs.rename(extractedPath, installPath) + yield* fs.chmod(installPath, 0o755) + }).pipe(Effect.scoped) + }) - const binDir = managedBinDir(context.foldHome) - const installPath = join(binDir, installedFileName(definition, context.platform)) - const installed = yield* context.fs.exists(installPath).pipe(Effect.catch(() => Effect.succeed(false))) - if (installed) { - return { - name: definition.name, - resolution: 'managed' as const, - path: installPath, - detail: `already installed in ${binDir}`, - } - } + /** Resolve one binary through the system -> managed -> download ladder. Failures propagate typed. */ + const resolveOne = (context: ResolveContext, definition: ManagedBinaryDefinition) => + Effect.gen(function* () { + const resolveSystem = Effect.gen(function* () { + for (const systemName of definition.systemNames) { + const found = yield* which(context, systemName) + if (found === null) continue + if (definition.minVersion !== null) { + const usable = yield* satisfiesMinVersion(found, definition.minVersion) + if (!usable) continue + } + + return { + name: definition.name, + resolution: 'system' as const, + path: found, + detail: `system binary "${systemName}" on PATH`, + } + } + + return null + }) - if (context.requireManagedInstall) { - const system = yield* resolveSystem - if (system !== null && context.downloadsDisabled) return system - } + if (!context.requireManagedInstall) { + const system = yield* resolveSystem + if (system !== null) return system + } - if (context.downloadsDisabled) { - return { - name: definition.name, - resolution: 'unavailable' as const, - path: null, - detail: `binary downloads disabled; not found on PATH or in ${binDir}`, - } - } + const binDir = managedBinDir(context.foldHome) + const installPath = join(binDir, installedFileName(definition, context.platform)) + const installed = yield* fs.exists(installPath).pipe(Effect.orElseSucceed(() => false)) + if (installed) { + return { + name: definition.name, + resolution: 'managed' as const, + path: installPath, + detail: `already installed in ${binDir}`, + } + } - const asset = definition.assetFor(context.platform, context.arch) - if (asset === null) { - return { - name: definition.name, - resolution: 'unavailable' as const, - path: null, - detail: `no pinned ${definition.name} asset for ${context.platform}-${context.arch}`, - } - } + if (context.requireManagedInstall) { + const system = yield* resolveSystem + if (system !== null && context.disableDownloads) return system + } - yield* installFromAsset(context, definition, asset, installPath) + if (context.disableDownloads) { + return { + name: definition.name, + resolution: 'unavailable' as const, + path: null, + detail: `binary downloads disabled; not found on PATH or in ${binDir}`, + } + } - return { - name: definition.name, - resolution: 'installed-now' as const, - path: installPath, - detail: `downloaded ${definition.name} ${definition.version} from ${definition.repo}`, - } - }) + const asset = definition.assetFor(context.platform, context.arch) + if (asset === null) { + return { + name: definition.name, + resolution: 'unavailable' as const, + path: null, + detail: `no pinned ${definition.name} asset for ${context.platform}-${context.arch}`, + } + } -/** 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) - -/** One binary's resolution, degraded to `unavailable` on ANY failure or defect (capture, then keep going). */ -const resolveOneNeverFailing = ( - context: ResolveContext, - definition: ManagedBinaryDefinition, -): Effect.Effect => - resolveOne(context, definition).pipe( - Effect.catchCause((cause) => { - const message = failureMessageOf(Cause.squash(cause)) - const unavailable = { - name: definition.name, - resolution: 'unavailable' as const, - path: null, - detail: message, - } + yield* installFromAsset(context, definition, asset, installPath) - if (context.suppressWarnings) return Effect.succeed(unavailable) + return { + name: definition.name, + resolution: 'installed-now' as const, + path: installPath, + detail: `downloaded ${definition.name} ${definition.version} from ${definition.repo}`, + } + }) - return Effect.logWarning(`could not resolve managed binary ${definition.name}: ${message}`).pipe( - Effect.as(unavailable), + /** + * One binary's resolution, degraded to `unavailable` on ANY failure or defect. This is the + * best-effort boundary, so it deliberately catches the whole cause and keeps the message. + */ + const resolveOneNeverFailing = ( + context: ResolveContext, + definition: ManagedBinaryDefinition, + ): Effect.Effect => + resolveOne(context, definition).pipe( + Effect.catchCause((cause) => + // Debug level: this runs in the background under the TUI, where console warnings would + // corrupt the screen. `fold bin status|install` print each status's detail instead. + Effect.logDebug(`could not resolve managed binary ${definition.name}`, cause).pipe( + Effect.as({ + name: definition.name, + resolution: 'unavailable' as const, + path: null, + detail: Cause.prettyErrors(cause)[0]?.message ?? 'unknown failure', + }), + ), + ), ) - }), - ) -const ensureOnce = ( - options: EnsureManagedBinariesOptions, -): Effect.Effect, never, FileSystem.FileSystem> => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const env = options.env ?? ((name: string) => process.env[name]) - const platform = options.platform ?? process.platform - const disableFlag = env(FOLD_DISABLE_BINARY_DOWNLOADS) - const context: ResolveContext = { - foldHome: options.foldHome, - fs, - env, - which: options.which ?? defaultWhich(env, platform), - download: options.download ?? defaultDownload, - exec: options.exec ?? defaultExec, - platform, - arch: options.arch ?? process.arch, - downloadsDisabled: options.disableDownloads === true || (disableFlag !== undefined && disableFlag !== ''), - requireManagedInstall: options.requireManagedInstall === true, - suppressWarnings: options.suppressWarnings === true, - } - const registry = options.registry ?? managedBinaryRegistry + /** One full pass over the registry, every binary resolved concurrently. */ + const resolveAll = (key: EnsureKey): Effect.Effect> => + Effect.gen(function* () { + const context: ResolveContext = { + ...environment, + ...key, + disableDownloads: key.disableDownloads || environment.downloadsKillSwitch, + } + return yield* Effect.forEach(registry, (definition) => resolveOneNeverFailing(context, definition), { + concurrency: Math.max(registry.length, 1), + }) + }) - return yield* Effect.forEach(registry, (definition) => resolveOneNeverFailing(context, definition), { - concurrency: registry.length === 0 ? 1 : registry.length, + // Keys are (foldHome, mode) pairs: a handful per process. + const passes = yield* Cache.make>({ + capacity: 64, + lookup: resolveAll, }) + + const ensure = Effect.fn('ManagedBinaries.ensure')(function* (options: EnsureManagedBinariesOptions) { + const key: EnsureKey = { + foldHome: options.foldHome, + disableDownloads: options.disableDownloads === true, + requireManagedInstall: options.requireManagedInstall === true, + } + const resolved = options.memoize === false ? yield* resolveAll(key) : yield* Cache.get(passes, key) + return resolved + }) + + return ManagedBinaries.of({ ensure }) }) -const memoizedResults = new Map>() +/** + * The per-process managed-binary resolver. `ensure` never fails; see the file header for the + * resolution ladder. + */ +export class ManagedBinaries extends Context.Service< + ManagedBinaries, + { + readonly ensure: (options: EnsureManagedBinariesOptions) => Effect.Effect> + } +>()('fold-agent/Bin/ManagedBinaries') { + /** A resolver over a given registry (tests pass a small one). */ + static readonly layerWith = ( + registry: ReadonlyArray, + ): Layer.Layer< + ManagedBinaries, + never, + FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient + > => Layer.effect(ManagedBinaries, makeManagedBinaries(registry)) + + /** The resolver over fold's pinned {@link managedBinaryRegistry}. */ + static readonly layer = ManagedBinaries.layerWith(managedBinaryRegistry) +} /** * Ensure every managed binary is resolvable, returning one status per registry entry (in registry - * order). NEVER fails - unavailable binaries degrade with a logged warning. Memoized per process - * and (foldHome, download mode), so the second call is free. + * order). NEVER fails - unavailable binaries degrade with a logged warning. Shares one cached pass + * per (foldHome, download mode) unless `memoize: false`. */ export const ensureManagedBinaries = ( options: EnsureManagedBinariesOptions, -): Effect.Effect, never, FileSystem.FileSystem> => - Effect.suspend(() => { - if (options.memoize === false) return ensureOnce(options) - - const key = `${options.foldHome}${options.disableDownloads === true}${options.requireManagedInstall === true}` - const existing = memoizedResults.get(key) - if (existing !== undefined) return Effect.succeed(existing) - - // Run once and cache the result. The suspend boundary is synchronous so concurrent - // callers cannot race past the get into two resolution passes; the second caller's - // ensureOnce is idempotent in the unlikely event of an async interleave. - return ensureOnce(options).pipe( - Effect.tap((result) => - Effect.sync(() => { - memoizedResults.set(key, result) - }), - ), - ) - }) +): Effect.Effect, never, ManagedBinaries> => + ManagedBinaries.use((service) => service.ensure(options)) diff --git a/packages/fold-agent/src/Bin/Registry.ts b/packages/fold-agent/src/Bin/Registry.ts index 1c16ecd..31d654d 100644 --- a/packages/fold-agent/src/Bin/Registry.ts +++ b/packages/fold-agent/src/Bin/Registry.ts @@ -68,7 +68,7 @@ const releaseAsset = (input: { url, archive: input.archive, pathInArchive: input.pathInArchive, - sha256: managedBinaryChecksums[url] ?? null, + sha256: managedBinaryChecksums.get(url) ?? null, } } diff --git a/packages/fold-agent/src/Catalog/LoadCatalog.ts b/packages/fold-agent/src/Catalog/LoadCatalog.ts index 5f0ed9c..f957628 100644 --- a/packages/fold-agent/src/Catalog/LoadCatalog.ts +++ b/packages/fold-agent/src/Catalog/LoadCatalog.ts @@ -8,16 +8,17 @@ * a catalog is a capability upgrade, not a launch prerequisite - and there are no background refresh * loops (CLI processes are short-lived). * - * Seams follow the fold-agent options convention (AgentFiles/Load.ts): `fileSystem` for hermetic - * tests, `env` for the disable flag, `fetchJson` for the network, `now`/`ttlMs` for freshness. + * Seams follow the fold-agent options convention (AgentFiles/Load.ts): `env` for the disable flag and + * `now`/`ttlMs` for freshness. The filesystem and the network (`HttpClient`) come from the environment. */ import { dirname, join } from 'node:path' import { ModelCatalogEntry } from '@humanlayer/fold-core' -import { Clock, Effect, FileSystem, Predicate, Schema } from 'effect' +import { Array as Arr, Clock, Duration, Effect, FileSystem, Schema } from 'effect' +import { HttpClient, HttpClientResponse } from 'effect/unstable/http' import { bakedModelCatalog } from './BakedCatalog' -import { decodeModelsDevModels, ModelsDevDecodeError } from './ModelsDevSchema' +import { decodeModelsDevModels, ModelsDevDecodeError, ModelsDevPayload } from './ModelsDevSchema' import { modelCatalogEntriesFromModelsDev } from './Normalize' /** The models.dev endpoint fetched when the local cache is stale. */ @@ -36,11 +37,6 @@ export class CatalogFetchError extends Schema.TaggedError()(' message: Schema.String, }) {} -/** The cache file exists but is not valid JSON. */ -class CatalogCacheParseError extends Schema.TaggedError()('CatalogCacheParseError', { - message: Schema.String, -}) {} - /** * On-disk cache payload: entries are normalized at write time so every launch decodes a small, * already-shaped file instead of the 3MB models.dev payload. A wrong version or corrupt file is @@ -48,7 +44,7 @@ class CatalogCacheParseError extends Schema.TaggedError( */ export const ModelCatalogCache = Schema.Struct({ version: Schema.Literal(1), - fetchedAt: Schema.Number, + fetchedAt: Schema.Finite, entries: Schema.Array(ModelCatalogEntry), }).annotate({ identifier: 'ModelCatalogCache' }) export type ModelCatalogCache = typeof ModelCatalogCache.Type @@ -59,8 +55,6 @@ export type LoadModelCatalogOptions = { readonly foldHome: string /** Environment lookup for {@link FOLD_DISABLE_MODELS_FETCH}. Defaults to reading `process.env`. */ readonly env?: (name: string) => string | undefined - /** Fetch seam returning the parsed JSON payload. Defaults to global `fetch` with a 10s timeout. */ - readonly fetchJson?: (url: string) => Effect.Effect /** Clock seam for cache freshness. Defaults to `Clock.currentTimeMillis`. */ readonly now?: Effect.Effect /** Cache freshness window in milliseconds. Defaults to {@link defaultCatalogTtlMs}. */ @@ -70,40 +64,41 @@ export type LoadModelCatalogOptions = { /** The cache file path for a fold home directory. */ export const modelCatalogCachePath = (foldHome: string): string => join(foldHome, 'cache', 'models-dev.json') -/** The ONE mapper from thrown fetch failures to the typed catalog fetch error. */ -const catalogFetchErrorFrom = (cause: unknown): CatalogFetchError => - new CatalogFetchError({ message: Predicate.isError(cause) ? cause.message : String(cause) }) - -const defaultFetchJson = (url: string): Effect.Effect => - Effect.tryPromise({ - try: async (signal): Promise => { - const response = await fetch(url, { signal }) - if (!response.ok) throw new Error(`GET ${url} responded ${response.status}`) - const body: unknown = await response.json() - return body - }, - catch: catalogFetchErrorFrom, - }).pipe( - Effect.timeout(fetchTimeoutMillis), - Effect.catchTag('TimeoutError', () => - Effect.fail(new CatalogFetchError({ message: `GET ${url} timed out after ${fetchTimeoutMillis}ms` })), - ), - ) +/** GET the models.dev payload and decode its provider map through the ambient `HttpClient`, with a 10s timeout. */ +const fetchModelsDevPayload: Effect.Effect< + ModelsDevPayload, + CatalogFetchError | ModelsDevDecodeError, + HttpClient.HttpClient +> = HttpClient.get(MODELS_DEV_URL).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(ModelsDevPayload)), + Effect.catchTags({ + HttpClientError: (error) => Effect.fail(new CatalogFetchError({ message: error.message })), + SchemaError: (error) => + Effect.fail( + new ModelsDevDecodeError({ message: `models.dev payload is not a provider map: ${error.message}` }), + ), + }), + Effect.timeoutOrElse({ + duration: Duration.millis(fetchTimeoutMillis), + orElse: () => + Effect.fail( + new CatalogFetchError({ message: `GET ${MODELS_DEV_URL} timed out after ${fetchTimeoutMillis}ms` }), + ), + }), +) -const decodeCache = Schema.decodeUnknownEffect(ModelCatalogCache) +const ModelCatalogCacheText = Schema.fromJsonString(ModelCatalogCache) +const decodeCacheText = Schema.decodeEffect(ModelCatalogCacheText) +const encodeCacheText = Schema.encodeEffect(ModelCatalogCacheText) /** Read the cache: absent, unreadable, corrupt, or wrong-version files all read as null. */ const readCache = (fs: FileSystem.FileSystem, path: string): Effect.Effect => Effect.gen(function* () { - const text = yield* fs.readFileString(path).pipe(Effect.catch(() => Effect.succeed(null))) + const text = yield* fs.readFileString(path).pipe(Effect.orElseSucceed(() => null)) if (text === null) return null - return yield* Effect.try({ - try: (): unknown => JSON.parse(text), - catch: (cause) => - new CatalogCacheParseError({ message: Predicate.isError(cause) ? cause.message : String(cause) }), - }).pipe( - Effect.flatMap((parsed) => decodeCache(parsed)), + return yield* decodeCacheText(text).pipe( Effect.catch((error) => // Corrupt or wrong-version caches are absent caches: warn, then rebuild from fetch/baked. Effect.logWarning(`ignoring corrupt model catalog cache at ${path}: ${error.message}`).pipe( @@ -116,7 +111,7 @@ const readCache = (fs: FileSystem.FileSystem, path: string): Effect.Effect => Effect.gen(function* () { - const json = JSON.stringify(cache) + const json = yield* encodeCacheText(cache) const tmpPath = `${path}.tmp` yield* fs.makeDirectory(dirname(path), { recursive: true }) @@ -132,20 +127,21 @@ const writeCache = (fs: FileSystem.FileSystem, path: string, cache: ModelCatalog ) /** Fetch and normalize the live catalog across ALL providers. Zero usable entries is a failure. */ -const fetchCatalogEntries = ( - fetchJson: (url: string) => Effect.Effect, -): Effect.Effect, CatalogFetchError | ModelsDevDecodeError> => - Effect.gen(function* () { - const payload = yield* fetchJson(MODELS_DEV_URL) - const models = yield* decodeModelsDevModels(payload) - const entries = modelCatalogEntriesFromModelsDev(models) - if (entries.length === 0) { - // An empty catalog would poison the cache for a full TTL; treat it as a decode failure. - return yield* new ModelsDevDecodeError({ message: 'the models.dev payload contained no usable models' }) - } - - return entries - }) +const fetchCatalogEntries: Effect.Effect< + ReadonlyArray, + CatalogFetchError | ModelsDevDecodeError, + HttpClient.HttpClient +> = Effect.gen(function* () { + const payload = yield* fetchModelsDevPayload + const models = yield* decodeModelsDevModels(payload) + const entries = modelCatalogEntriesFromModelsDev(models) + if (Arr.isReadonlyArrayEmpty(entries)) { + // An empty catalog would poison the cache for a full TTL; treat it as a decode failure. + return yield* new ModelsDevDecodeError({ message: 'the models.dev payload contained no usable models' }) + } + + return entries +}) /** * Load the model catalog entries for a launch. Never fails: fresh cache, else fetch-and-cache, else @@ -153,13 +149,12 @@ const fetchCatalogEntries = ( */ export const loadModelCatalog = ( options: LoadModelCatalogOptions, -): Effect.Effect, never, FileSystem.FileSystem> => +): Effect.Effect, never, FileSystem.FileSystem | HttpClient.HttpClient> => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const env = options.env ?? ((name: string) => process.env[name]) const now = yield* options.now ?? Clock.currentTimeMillis const ttlMs = options.ttlMs ?? defaultCatalogTtlMs - const fetchJson = options.fetchJson ?? defaultFetchJson const cachePath = modelCatalogCachePath(options.foldHome) const cache = yield* readCache(fs, cachePath) @@ -171,7 +166,7 @@ export const loadModelCatalog = ( const disableFlag = env(FOLD_DISABLE_MODELS_FETCH) if (disableFlag !== undefined && disableFlag !== '') return fallbackEntries - return yield* fetchCatalogEntries(fetchJson).pipe( + return yield* fetchCatalogEntries.pipe( Effect.flatMap((entries) => writeCache(fs, cachePath, { version: 1, fetchedAt: now, entries }).pipe(Effect.as(entries)), ), diff --git a/packages/fold-agent/src/Catalog/ModelsDevSchema.ts b/packages/fold-agent/src/Catalog/ModelsDevSchema.ts index 2990098..e6b3219 100644 --- a/packages/fold-agent/src/Catalog/ModelsDevSchema.ts +++ b/packages/fold-agent/src/Catalog/ModelsDevSchema.ts @@ -2,7 +2,7 @@ * This file decodes the subset of the models.dev `api.json` payload fold consumes (D15). The payload * is a flat `Record` with `Provider.models: Record`; optional * model fields are OMITTED KEYS, never null, so every optional is `Schema.optionalKey`. Decoding is - * deliberately permissive and per-entry: the top level decodes as records of unknown, then each + * deliberately permissive and per-entry: the top level decodes as a record of raw JSON, then each * provider and each model decodes individually, so one malformed entry is skipped with a warning * instead of killing the whole catalog. Unknown fields are tolerated everywhere (default excess- * property behavior), and no value enum (families, modalities, effort names) is hardcoded. @@ -21,18 +21,18 @@ export type ModelsDevReasoningOption = typeof ModelsDevReasoningOption.Type /** Token limits of one models.dev model. Fields are optional here; normalization requires context+output. */ export const ModelsDevLimit = Schema.Struct({ - context: Schema.optionalKey(Schema.Number), - input: Schema.optionalKey(Schema.Number), - output: Schema.optionalKey(Schema.Number), + context: Schema.optionalKey(Schema.Finite), + input: Schema.optionalKey(Schema.Finite), + output: Schema.optionalKey(Schema.Finite), }).annotate({ identifier: 'ModelsDevLimit' }) export type ModelsDevLimit = typeof ModelsDevLimit.Type /** Base USD-per-million-token rates of one models.dev model (tiers/audio/over-200k rates ignored, v1). */ export const ModelsDevCost = Schema.Struct({ - input: Schema.optionalKey(Schema.Number), - output: Schema.optionalKey(Schema.Number), - cache_read: Schema.optionalKey(Schema.Number), - cache_write: Schema.optionalKey(Schema.Number), + input: Schema.optionalKey(Schema.Finite), + output: Schema.optionalKey(Schema.Finite), + cache_read: Schema.optionalKey(Schema.Finite), + cache_write: Schema.optionalKey(Schema.Finite), }).annotate({ identifier: 'ModelsDevCost' }) export type ModelsDevCost = typeof ModelsDevCost.Type @@ -56,7 +56,7 @@ export const ModelsDevModel = Schema.Struct({ }).annotate({ identifier: 'ModelsDevModel' }) export type ModelsDevModel = typeof ModelsDevModel.Type -/** The models.dev payload is not a provider map at all (wrong endpoint, HTML error page, ...). */ +/** The models.dev payload is unusable: not a provider map (wrong endpoint, HTML error page, ...) or no usable models. */ export class ModelsDevDecodeError extends Schema.TaggedError()('ModelsDevDecodeError', { message: Schema.String, }) {} @@ -68,28 +68,24 @@ export type ModelsDevNamedModel = { readonly model: ModelsDevModel } -const decodeProviderMap = Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Unknown)) +/** + * The top level of `api.json`: a provider map whose entries stay raw JSON so each provider and model + * decodes on its own. Decode the payload with this schema at the fetch/read boundary. + */ +export const ModelsDevPayload = Schema.Record(Schema.String, Schema.Json).annotate({ identifier: 'ModelsDevPayload' }) +export type ModelsDevPayload = typeof ModelsDevPayload.Type + const decodeProviderModels = Schema.decodeUnknownEffect( - Schema.Struct({ models: Schema.Record(Schema.String, Schema.Unknown) }), + Schema.Struct({ models: Schema.Record(Schema.String, Schema.Json) }), ) const decodeModel = Schema.decodeUnknownEffect(ModelsDevModel) /** - * Decode a models.dev `api.json` payload into named models, permissively: providers or models that - * fail to decode are skipped with a warning while the rest of the catalog survives. Fails only when - * the top level is not a provider map at all. + * Decode a models.dev provider map into named models, permissively: providers or models that fail to + * decode are skipped with a warning while the rest of the catalog survives. */ -export const decodeModelsDevModels = ( - data: unknown, -): Effect.Effect, ModelsDevDecodeError> => +export const decodeModelsDevModels = (providers: ModelsDevPayload): Effect.Effect> => Effect.gen(function* () { - const providers = yield* decodeProviderMap(data).pipe( - Effect.mapError( - (error) => - new ModelsDevDecodeError({ message: `models.dev payload is not a provider map: ${error.message}` }), - ), - ) - const models: Array = [] for (const [providerId, rawProvider] of Object.entries(providers)) { const provider = yield* decodeProviderModels(rawProvider).pipe( diff --git a/packages/fold-agent/src/Catalog/Normalize.ts b/packages/fold-agent/src/Catalog/Normalize.ts index 563da2c..eb137e6 100644 --- a/packages/fold-agent/src/Catalog/Normalize.ts +++ b/packages/fold-agent/src/Catalog/Normalize.ts @@ -7,6 +7,7 @@ * no effort-list constraint (toggle/budget-style reasoning, or no reasoning at all). */ import type { ModelCatalogEntry, ModelPricing } from '@humanlayer/fold-core' +import { Array as Arr } from 'effect' import type { ModelsDevModel, ModelsDevNamedModel } from './ModelsDevSchema' @@ -28,7 +29,7 @@ const reasoningEffortsFrom = (model: ModelsDevModel): ReadonlyArray | nu const efforts = effortOption.values.filter((value): value is string => value !== null) - return efforts.length === 0 ? null : efforts + return Arr.isArrayEmpty(efforts) ? null : efforts } /** diff --git a/packages/fold-agent/src/Compatibility/CodexCompatibility.ts b/packages/fold-agent/src/Compatibility/CodexCompatibility.ts index 43b7053..e4ccb41 100644 --- a/packages/fold-agent/src/Compatibility/CodexCompatibility.ts +++ b/packages/fold-agent/src/Compatibility/CodexCompatibility.ts @@ -1,18 +1,18 @@ import { homedir } from 'node:os' -import type { SkillSourceService } from '@humanlayer/fold-core' -import { Effect, Path } from 'effect' +import type { FoldSkills } from '@humanlayer/fold-core' +import { Effect, type FileSystem, Path } from 'effect' import { loadCodexInstructions, renderCodexInstructions, type CodexInstructionSource } from './CodexInstructions' import { discoverCodexPluginSkillRoots, type CodexPluginDiagnostic } from './CodexPlugins' -import { makeCodexSkillSource, type CodexSkillOptions } from './CodexSkills' +import { codexSkills, type CodexSkillOptions } from './CodexSkills' export type CodexCompatibilityOptions = CodexSkillOptions export type CodexCompatibility = { readonly instructions: ReadonlyArray readonly instructionBlock: string | null - readonly skills: SkillSourceService + readonly skills: FoldSkills readonly diagnostics: ReadonlyArray } @@ -23,10 +23,8 @@ export const loadCodexCompatibility = (options: CodexCompatibilityOptions) => const codexHome = path.resolve(options.codexHome ?? path.join(homeValue, '.codex')) const plugins = yield* discoverCodexPluginSkillRoots({ codexHome }) const instructions = yield* loadCodexInstructions(options) - const skills = yield* makeCodexSkillSource({ - ...options, - pluginPaths: [...(options.pluginPaths ?? []), ...plugins.roots], - }) + const { pluginPaths = [] } = options + const skills = codexSkills({ ...options, pluginPaths: [...pluginPaths, ...plugins.roots] }) return { instructions, instructionBlock: renderCodexInstructions(instructions), diff --git a/packages/fold-agent/src/Compatibility/CodexInstructions.ts b/packages/fold-agent/src/Compatibility/CodexInstructions.ts index 261968e..6651984 100644 --- a/packages/fold-agent/src/Compatibility/CodexInstructions.ts +++ b/packages/fold-agent/src/Compatibility/CodexInstructions.ts @@ -1,6 +1,6 @@ import { homedir } from 'node:os' -import { Effect, FileSystem, Path, Schema } from 'effect' +import { Array as Arr, Effect, FileSystem, Path, Schema } from 'effect' export const CodexInstructionSource = Schema.Struct({ path: Schema.String, @@ -22,7 +22,7 @@ const readNonEmpty = (path: string): Effect.Effect content.trim().length === 0 ? null : { path, content, scope: 'ancestor' as const }, ), - Effect.catch(() => Effect.succeed(null)), + Effect.orElseSucceed(() => null), ) }) @@ -101,7 +101,7 @@ const escapeXmlAttribute = (text: string): string => .replace(/'/g, ''') export const renderCodexInstructions = (sources: ReadonlyArray): string | null => { - if (sources.length === 0) return null + if (Arr.isReadonlyArrayEmpty(sources)) return null return `\n${sources .map( (source) => diff --git a/packages/fold-agent/src/Compatibility/CodexPlugins.ts b/packages/fold-agent/src/Compatibility/CodexPlugins.ts index b6c5b3a..cb2b91f 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 { Array as Arr, Effect, FileSystem, Option, Path, Schema } from 'effect' export const CodexPluginDiagnostic = Schema.Struct({ stage: Schema.Literals(['config', 'cache', 'manifest']), @@ -53,8 +53,12 @@ type SemanticVersion = { readonly prerelease: Array } -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value) +const CodexPluginManifest = Schema.Struct({ + name: Schema.optional(Schema.String), + skills: Schema.optional(Schema.Union([Schema.String, Schema.Array(Schema.String)])), +}) +type CodexPluginManifest = typeof CodexPluginManifest.Type +const decodeCodexPluginManifest = Schema.decodeOption(Schema.fromJsonString(CodexPluginManifest)) 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) @@ -73,10 +77,10 @@ const compareVersions = (left: string, right: string): number => { const compared = compareNumeric(leftVersion[field], rightVersion[field]) if (compared !== 0) return compared } - if (leftVersion.prerelease.length === 0 || rightVersion.prerelease.length === 0) { + if (Arr.isArrayEmpty(leftVersion.prerelease) || Arr.isArrayEmpty(rightVersion.prerelease)) { return leftVersion.prerelease.length === rightVersion.prerelease.length ? 0 - : leftVersion.prerelease.length === 0 + : Arr.isArrayEmpty(leftVersion.prerelease) ? 1 : -1 } @@ -91,15 +95,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 +const safeRelativeSkillRoot = (value: string): string | null => { + if (!value.startsWith('./') || value === './' || value.includes('\\') || value.includes('\0')) return null if (value.split('/').includes('..')) return null return value } @@ -110,46 +107,38 @@ export const discoverCodexPluginSkillRoots = (options: CodexPluginOptions) => const path = yield* Path.Path const diagnostics: Array = [] const configPath = path.join(options.codexHome, 'config.toml') - const config = yield* fs.readFileString(configPath).pipe(Effect.catch(() => Effect.succeed(''))) + const config = yield* fs.readFileString(configPath).pipe(Effect.orElseSucceed(() => '')) const roots: Array = [] for (const plugin of parseEnabledPlugins(config)) { const identity = `${plugin.name}@${plugin.marketplace}` const cachePath = path.join(options.codexHome, 'plugins', 'cache', plugin.marketplace, plugin.name) - const entries = yield* fs.readDirectory(cachePath).pipe(Effect.catch(() => Effect.succeed([]))) + const entries = yield* fs.readDirectory(cachePath).pipe(Effect.orElseSucceed(() => [])) const directories: Array = [] for (const entry of entries) { - const info = yield* fs.stat(path.join(cachePath, entry)).pipe(Effect.catch(() => Effect.succeed(null))) + const info = yield* fs.stat(path.join(cachePath, entry)).pipe(Effect.orElseSucceed(() => null)) if (info?.type === 'Directory') directories.push(entry) } const version = selectedVersion(directories) if (version === null) continue const bundle = path.resolve(cachePath, version) - let manifest: unknown = null + let manifest: CodexPluginManifest | 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))) + const contents = yield* fs.readFileString(candidate).pipe(Effect.orElseSucceed(() => null)) if (contents === null) continue manifestPath = candidate - try { - manifest = JSON.parse(contents) - } catch { + manifest = Option.getOrNull(decodeCodexPluginManifest(contents)) + if (manifest === null) diagnostics.push({ stage: 'manifest', code: 'manifest_parse_failed', path: candidate }) - } break } - if (!isRecord(manifest)) continue - const record = manifest - if (record.name !== plugin.name) { + if (manifest === null) continue + if (manifest.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 = Arr.ensure(manifest.skills ?? './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..5171f15 100644 --- a/packages/fold-agent/src/Compatibility/CodexSkills.ts +++ b/packages/fold-agent/src/Compatibility/CodexSkills.ts @@ -1,8 +1,16 @@ import { homedir } from 'node:os' -import { SkillNotFoundError, type Skill, type SkillMeta, type SkillSourceService } from '@humanlayer/fold-core' -import { Effect, FileSystem, Path } from 'effect' -import { parse as parseYaml } from 'yaml' +import { + skillSource, + SkillNotFoundError, + type FoldSkills, + type Skill, + type SkillMeta, + type SkillSourceService, +} from '@humanlayer/fold-core' +import { Effect, FileSystem, Option, Path } from 'effect' + +import { parseSkillFile, skillNameOr } from '../Skills/SkillFrontmatter' export type CodexSkillOptions = { readonly cwd: string @@ -13,15 +21,20 @@ export type CodexSkillOptions = { readonly pluginPaths?: ReadonlyArray<{ readonly name: string; readonly path: string }> } -const exists = (path: string): Effect.Effect => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - return yield* fs.exists(path).pipe(Effect.catch(() => Effect.succeed(false))) - }) +/** + * Build the Codex-compatible skill source. FileSystem and Path are captured here, once; `list` and + * `load` rescan the skill roots on every call so newly added skills appear without a restart. + */ +export const makeCodexSkillSource = Effect.fn('fold.codex_compatibility.make_skill_source')(function* ( + options: CodexSkillOptions, +) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path -const isAncestor = (ancestor: string, candidate: string): Effect.Effect => - Effect.gen(function* () { - const path = yield* Path.Path + const exists = (candidate: string): Effect.Effect => + fs.exists(candidate).pipe(Effect.orElseSucceed(() => false)) + + const isAncestor = (ancestor: string, candidate: string): boolean => { let current = candidate while (true) { if (current === ancestor) return true @@ -29,13 +42,11 @@ const isAncestor = (ancestor: string, candidate: string): Effect.Effect, never, Path.Path> => - Effect.gen(function* () { - const path = yield* Path.Path + const ancestorSkillRoots = (cwd: string, home: Option.Option): ReadonlyArray => { const roots: Array = [] - const boundary = home !== null && (yield* isAncestor(home, cwd)) ? home : null + const boundary = Option.getOrNull(Option.filter(home, (value) => isAncestor(value, cwd))) let current = cwd while (true) { roots.push(path.join(current, '.agents', 'skills')) @@ -45,117 +56,94 @@ const ancestorSkillRoots = (cwd: string, home: string | null): Effect.Effect => - typeof value === 'object' && value !== null && !Array.isArray(value) + } -const loadSkill = ( - skillPath: string, - namespace?: string, -): Effect.Effect => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - return yield* fs.readFileString(skillPath).pipe( - Effect.map((raw) => { - const normalized = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n') - 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 + /** Codex requires frontmatter with a non-blank description; a read or parse failure skips the skill. */ + const loadSkill = (skillPath: string, namespace?: string): Effect.Effect => + fs.readFileString(skillPath).pipe( + Effect.flatMap(parseSkillFile), + Effect.map(({ frontmatter, body }) => { + const description = (frontmatter.description ?? '').trim() + if (description.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 = skillNameOr(frontmatter, path.basename(directory)) const name = namespace === undefined ? rawName : `${namespace}:${rawName}` - return { - name, - description: parsed.description.trim(), - content: normalized.slice(end + 4).trim(), - baseDir: directory, - } + return { name, description, content: body, baseDir: directory } }), - Effect.catch(() => Effect.succeed(null)), + Effect.catch((error) => Effect.as(Effect.logWarning(`skill skipped: ${skillPath}`, error), null)), ) - }) -const scanRoot = ( - root: string, - namespace?: string, -): Effect.Effect, never, FileSystem.FileSystem | Path.Path> => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - if (!(yield* exists(root))) return [] - const found: Array = [] - const scan = (directory: string): Effect.Effect => - Effect.gen(function* () { - const skillPath = path.join(directory, 'SKILL.md') - if (yield* exists(skillPath)) { - const skill = yield* loadSkill(skillPath, namespace) - if (skill !== null) found.push(skill) - return - } - const entries = yield* fs.readDirectory(directory).pipe(Effect.catch(() => Effect.succeed([]))) - for (const entry of [...entries].sort()) { - if (entry.startsWith('.') || entry === 'node_modules') continue - const child = path.join(directory, entry) - const info = yield* fs.stat(child).pipe(Effect.catch(() => Effect.succeed(null))) - if (info?.type === 'Directory') yield* scan(child) - } - }) - yield* scan(root) - return found - }) + const scanRoot = (root: string, namespace?: string): Effect.Effect> => + Effect.gen(function* () { + if (!(yield* exists(root))) return [] + const found: Array = [] + const scan = (directory: string): Effect.Effect => + Effect.gen(function* () { + const skillPath = path.join(directory, 'SKILL.md') + if (yield* exists(skillPath)) { + const skill = yield* loadSkill(skillPath, namespace) + if (skill !== null) found.push(skill) + return + } + const entries = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])) + for (const entry of [...entries].sort()) { + if (entry.startsWith('.') || entry === 'node_modules') continue + const child = path.join(directory, entry) + const info = yield* fs.stat(child).pipe(Effect.orElseSucceed(() => null)) + if (info?.type === 'Directory') yield* scan(child) + } + }) + yield* scan(root) + return found + }) -export const makeCodexSkillSource = ( - options: CodexSkillOptions, -): Effect.Effect => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - const cwd = path.resolve(options.cwd) - const homeValue = options.home === undefined ? homedir() : options.home - const home = homeValue.length === 0 ? null : path.resolve(homeValue) - const codexHome = path.resolve(options.codexHome ?? path.join(home ?? homedir(), '.codex')) - const roots = [ - ...(yield* ancestorSkillRoots(cwd, home)), - ...(options.configuredPaths ?? []), - path.join(codexHome, 'skills'), - ...(home === null ? [] : [path.join(home, '.agents', 'skills')]), - ...(options.bundledPaths ?? []), - ] - const scan = Effect.gen(function* () { - const byName = new Map() - for (const root of roots) { - for (const skill of yield* scanRoot(root)) if (!byName.has(skill.name)) byName.set(skill.name, skill) - } - for (const plugin of options.pluginPaths ?? []) { - for (const skill of yield* scanRoot(plugin.path, plugin.name)) - if (!byName.has(skill.name)) byName.set(skill.name, skill) - } - return byName - }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path)) - return { - list: scan.pipe( - Effect.map((skills) => - [...skills.values()].map(({ name, description }): SkillMeta => ({ name, description })), - ), - ), - load: (name) => - scan.pipe( - Effect.flatMap((skills) => { - const skill = skills.get(name) - return skill === undefined - ? Effect.fail(new SkillNotFoundError({ name, availableSkills: [...skills.keys()] })) - : Effect.succeed(skill) - }), - ), + const cwd = path.resolve(options.cwd) + const { configuredPaths = [], bundledPaths = [], pluginPaths = [] } = options + // An empty home disables every home-relative root. + const home = Option.liftPredicate(options.home ?? homedir(), (value) => value.length > 0).pipe( + Option.map((value) => path.resolve(value)), + ) + const codexHome = path.resolve(options.codexHome ?? path.join(Option.getOrElse(home, homedir), '.codex')) + const agentsSkillRoot = Option.map(home, (value) => path.join(value, '.agents', 'skills')) + const roots = [ + ...ancestorSkillRoots(cwd, home), + ...configuredPaths, + path.join(codexHome, 'skills'), + ...Option.toArray(agentsSkillRoot), + ...bundledPaths, + ] + + const scanSkillCatalog: Effect.Effect> = Effect.gen(function* () { + const byName = new Map() + for (const root of roots) { + for (const skill of yield* scanRoot(root)) if (!byName.has(skill.name)) byName.set(skill.name, skill) + } + for (const plugin of pluginPaths) { + for (const skill of yield* scanRoot(plugin.path, plugin.name)) + if (!byName.has(skill.name)) byName.set(skill.name, skill) } + return byName }) + + const source: SkillSourceService = { + list: scanSkillCatalog.pipe( + Effect.map((skills) => + [...skills.values()].map(({ name, description }): SkillMeta => ({ name, description })), + ), + ), + load: (name) => + scanSkillCatalog.pipe( + Effect.flatMap((skills) => { + const skill = skills.get(name) + return skill === undefined + ? Effect.fail(new SkillNotFoundError({ name, availableSkills: [...skills.keys()] })) + : Effect.succeed(skill) + }), + ), + } + return source +}) + +/** Configure an agent's skills from Codex's skill directories (see {@link makeCodexSkillSource}). */ +export const codexSkills = (options: CodexSkillOptions): FoldSkills => + skillSource(makeCodexSkillSource(options)) diff --git a/packages/fold-agent/src/Compatibility/GrokCompatibility.ts b/packages/fold-agent/src/Compatibility/GrokCompatibility.ts index 7a82c92..86ec57c 100644 --- a/packages/fold-agent/src/Compatibility/GrokCompatibility.ts +++ b/packages/fold-agent/src/Compatibility/GrokCompatibility.ts @@ -1,9 +1,9 @@ -import type { SkillSourceService } from '@humanlayer/fold-core' -import { Effect } from 'effect' +import type { FoldSkills } from '@humanlayer/fold-core' +import { Effect, type FileSystem, type Path, Struct } from 'effect' import { loadGrokInstructions, renderGrokInstructions, type GrokInstructionSource } from './GrokInstructions' import { discoverGrokPluginSkillRoots, type GrokPluginDiagnostic } from './GrokPlugins' -import { makeGrokSkillSource, type GrokSkillOptions } from './GrokSkills' +import { grokSkills, type GrokSkillOptions } from './GrokSkills' export type GrokCompatibilityOptions = GrokSkillOptions & { readonly configuredPluginPaths?: ReadonlyArray @@ -12,32 +12,21 @@ export type GrokCompatibilityOptions = GrokSkillOptions & { export type GrokCompatibility = { readonly instructions: ReadonlyArray readonly instructionBlock: string | null - readonly skills: SkillSourceService + readonly skills: FoldSkills readonly diagnostics: ReadonlyArray } export const loadGrokCompatibility = Effect.fn('fold.grok_compatibility.load')(function* ( options: GrokCompatibilityOptions, ) { - const pluginOptions: { - cwd: string - home?: string - grokHome?: string - projectRoot?: string - configuredPaths?: ReadonlyArray - } = { - cwd: options.cwd, - } - if (options.home !== undefined) pluginOptions.home = options.home - if (options.grokHome !== undefined) pluginOptions.grokHome = options.grokHome - if (options.projectRoot !== undefined) pluginOptions.projectRoot = options.projectRoot - if (options.configuredPluginPaths !== undefined) pluginOptions.configuredPaths = options.configuredPluginPaths - const plugins = yield* discoverGrokPluginSkillRoots(pluginOptions) - const instructions = yield* loadGrokInstructions(options) - const skills = yield* makeGrokSkillSource({ - ...options, - pluginPaths: [...(options.pluginPaths ?? []), ...plugins.roots], + const { configuredPluginPaths = [] } = options + const plugins = yield* discoverGrokPluginSkillRoots({ + ...Struct.pick(options, ['cwd', 'home', 'grokHome', 'projectRoot']), + configuredPaths: configuredPluginPaths, }) + const instructions = yield* loadGrokInstructions(options) + const { pluginPaths = [] } = options + const skills = grokSkills({ ...options, pluginPaths: [...pluginPaths, ...plugins.roots] }) return { instructions, instructionBlock: renderGrokInstructions(instructions), diff --git a/packages/fold-agent/src/Compatibility/GrokInstructions.ts b/packages/fold-agent/src/Compatibility/GrokInstructions.ts index 92419e5..5226291 100644 --- a/packages/fold-agent/src/Compatibility/GrokInstructions.ts +++ b/packages/fold-agent/src/Compatibility/GrokInstructions.ts @@ -1,6 +1,6 @@ import { homedir } from 'node:os' -import { Effect, FileSystem, Path, Schema } from 'effect' +import { Array as Arr, Effect, FileSystem, Path, Schema } from 'effect' export const GrokInstructionSource = Schema.Struct({ path: Schema.String, @@ -127,21 +127,39 @@ const markdownRules = ( ) }) +const resolveInstructionRoots = ( + options: GrokInstructionOptions, +): Effect.Effect< + { + readonly home: string | null + readonly grokHome: string + readonly directories: ReadonlyArray + readonly ignoreRoot: string | undefined + }, + never, + Path.Path +> => + Effect.gen(function* () { + const path = yield* Path.Path + const cwd = path.resolve(options.cwd) + const homeValue = options.home === undefined ? homedir() : options.home + const home = homeValue.length === 0 ? null : path.resolve(homeValue) + const grokHome = path.resolve(options.grokHome ?? path.join(home ?? homedir(), '.grok')) + const explicitRoot = options.projectRoot === undefined ? null : path.resolve(options.projectRoot) + const explicitRootIsAncestor = explicitRoot !== null && (yield* isAncestor(explicitRoot, cwd)) + const homeIsAncestor = home !== null && (yield* isAncestor(home, cwd)) + const boundary = explicitRootIsAncestor ? explicitRoot : homeIsAncestor ? home : null + const directories = yield* directoriesToBoundary(cwd, boundary) + const ignoreRoot = explicitRootIsAncestor ? explicitRoot : directories[0] + return { home, grokHome, directories, ignoreRoot } + }) + export const loadGrokInstructions = Effect.fn('fold.grok_compatibility.load_instructions')(function* ( options: GrokInstructionOptions, ) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path - const cwd = path.resolve(options.cwd) - const homeValue = options.home === undefined ? homedir() : options.home - const home = homeValue.length === 0 ? null : path.resolve(homeValue) - const grokHome = path.resolve(options.grokHome ?? path.join(home ?? homedir(), '.grok')) - const explicitRoot = options.projectRoot === undefined ? null : path.resolve(options.projectRoot) - const explicitRootIsAncestor = explicitRoot !== null && (yield* isAncestor(explicitRoot, cwd)) - const homeIsAncestor = home !== null && (yield* isAncestor(home, cwd)) - const boundary = explicitRootIsAncestor ? explicitRoot : homeIsAncestor ? home : null - const directories = yield* directoriesToBoundary(cwd, boundary) - const ignoreRoot = explicitRootIsAncestor ? explicitRoot : directories[0] + const { home, grokHome, directories, ignoreRoot } = yield* resolveInstructionRoots(options) const gitignore = ignoreRoot === undefined ? '' @@ -192,7 +210,7 @@ const escapeXmlAttribute = (text: string): string => .replace(/'/g, ''') export const renderGrokInstructions = (sources: ReadonlyArray): string | null => { - if (sources.length === 0) return null + if (Arr.isReadonlyArrayEmpty(sources)) return null return `\n${sources .map( (source) => diff --git a/packages/fold-agent/src/Compatibility/GrokPlugins.ts b/packages/fold-agent/src/Compatibility/GrokPlugins.ts index a467cae..2ce6421 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 { Array as Arr, Effect, FileSystem, Option, Path, Schema } from 'effect' export const GrokPluginDiagnostic = Schema.Struct({ stage: Schema.Literals(['manifest', 'discovery']), @@ -19,8 +19,15 @@ export type GrokPluginOptions = { readonly configuredPaths?: ReadonlyArray } -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value) +const GrokPluginManifest = Schema.Struct({ + name: Schema.optional(Schema.String), + skills: Schema.optional(Schema.Union([Schema.String, Schema.Array(Schema.String)])), +}) +type GrokPluginManifest = typeof GrokPluginManifest.Type +const decodeGrokPluginManifest = Schema.decodeOption(Schema.fromJsonString(GrokPluginManifest)) + +/** A manifest file found on disk; `value` is null when it could not be parsed. */ +type ManifestFile = { readonly path: string; readonly value: GrokPluginManifest | null } const isAncestor = (ancestor: string, candidate: string): Effect.Effect => Effect.gen(function* () { @@ -52,8 +59,8 @@ const ancestorDirectories = ( return directories }) -const safeRelativePath = (value: unknown): string | null => { - if (typeof value !== 'string' || value.length === 0 || value.includes('\\') || value.includes('\0')) return null +const safeRelativePath = (value: string): string | null => { + if (value.length === 0 || value.includes('\\') || value.includes('\0')) return null const normalized = value.replace(/^\.\//, '') if ( normalized.length === 0 || @@ -65,9 +72,7 @@ const safeRelativePath = (value: unknown): string | null => { return normalized } -const readManifest = ( - root: string, -): Effect.Effect<{ path: string; value: unknown } | null, never, FileSystem.FileSystem | Path.Path> => +const readManifest = (root: string): Effect.Effect => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path @@ -75,34 +80,83 @@ 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(decodeGrokPluginManifest(contents)) } } return null }) +const resolvePluginParents = (options: GrokPluginOptions): Effect.Effect, never, Path.Path> => + Effect.gen(function* () { + const path = yield* Path.Path + const cwd = path.resolve(options.cwd) + const { configuredPaths = [] } = options + // An empty home disables every home-relative root. + const home = Option.liftPredicate(options.home ?? homedir(), (value) => value.length > 0).pipe( + Option.map((value) => path.resolve(value)), + ) + const grokHome = path.resolve(options.grokHome ?? path.join(Option.getOrElse(home, homedir), '.grok')) + const projectRoot = options.projectRoot === undefined ? null : path.resolve(options.projectRoot) + const projectRootIsAncestor = projectRoot !== null && (yield* isAncestor(projectRoot, cwd)) + const homeIsAncestor = Option.isSome(home) && (yield* isAncestor(home.value, cwd)) + const boundary = projectRootIsAncestor ? projectRoot : homeIsAncestor ? Option.getOrNull(home) : null + const homePluginRoot = Option.map(home, (value) => path.join(value, '.claude', 'plugins')) + return [ + ...configuredPaths.map((configuredPath) => path.resolve(configuredPath)), + ...(yield* ancestorDirectories(cwd, boundary)).flatMap((directory) => [ + path.join(directory, '.grok', 'plugins'), + path.join(directory, '.claude', 'plugins'), + ]), + path.join(grokHome, 'plugins'), + ...Option.toArray(homePluginRoot), + ] + }) + +const listPluginCandidates = ( + parent: string, + parentManifest: ManifestFile | null, +): Effect.Effect, never, FileSystem.FileSystem | Path.Path> => + Effect.gen(function* () { + if (parentManifest !== null) return [parent] + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + return (yield* fs.readDirectory(parent).pipe(Effect.orElseSucceed(() => []))) + .sort((left, right) => left.localeCompare(right)) + .map((entry) => path.join(parent, entry)) + }) + +const pluginName = (manifestValue: GrokPluginManifest | null, fallback: string): string => + manifestValue?.name ?? fallback + +const declaredSkillPaths = (manifestValue: GrokPluginManifest | null): ReadonlyArray => + Arr.ensure(manifestValue?.skills ?? 'skills') + +const resolveSkillRoots = ( + candidate: string, + declared: ReadonlyArray, + manifestPath: string, + diagnostics: Array, +): Effect.Effect, never, FileSystem.FileSystem | Path.Path> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const skillRoots: Array = [] + for (const value of declared) { + const relativePath = safeRelativePath(value) + if (relativePath === null) { + diagnostics.push({ stage: 'manifest', code: 'invalid_skill_root', path: manifestPath }) + continue + } + const skillRoot = path.resolve(candidate, relativePath) + if (yield* fs.exists(skillRoot).pipe(Effect.orElseSucceed(() => false))) skillRoots.push(skillRoot) + } + return skillRoots + }) + export const discoverGrokPluginSkillRoots = Effect.fn('fold.grok_compatibility.discover_plugin_skills')(function* ( options: GrokPluginOptions, ) { - const fs = yield* FileSystem.FileSystem const path = yield* Path.Path - const cwd = path.resolve(options.cwd) - const homeValue = options.home === undefined ? homedir() : options.home - const home = homeValue.length === 0 ? null : path.resolve(homeValue) - const grokHome = path.resolve(options.grokHome ?? path.join(home ?? homedir(), '.grok')) - const projectRoot = options.projectRoot === undefined ? null : path.resolve(options.projectRoot) - const projectRootIsAncestor = projectRoot !== null && (yield* isAncestor(projectRoot, cwd)) - const homeIsAncestor = home !== null && (yield* isAncestor(home, cwd)) - const boundary = projectRootIsAncestor ? projectRoot : homeIsAncestor ? home : null - const pluginParents = [ - ...(options.configuredPaths ?? []).map((configuredPath) => path.resolve(configuredPath)), - ...(yield* ancestorDirectories(cwd, boundary)).flatMap((directory) => [ - path.join(directory, '.grok', 'plugins'), - path.join(directory, '.claude', 'plugins'), - ]), - path.join(grokHome, 'plugins'), - ...(home === null ? [] : [path.join(home, '.claude', 'plugins')]), - ] + const pluginParents = yield* resolvePluginParents(options) const diagnostics: Array = [] const roots: Array = [] const seenPaths = new Set() @@ -110,49 +164,27 @@ export const discoverGrokPluginSkillRoots = Effect.fn('fold.grok_compatibility.d for (const parent of pluginParents) { const parentManifest = yield* readManifest(parent) - const candidates = - parentManifest === null - ? (yield* fs.readDirectory(parent).pipe(Effect.orElseSucceed(() => []))) - .sort((left, right) => left.localeCompare(right)) - .map((entry) => path.join(parent, entry)) - : [parent] + const candidates = yield* listPluginCandidates(parent, parentManifest) for (const candidate of candidates) { const normalized = path.resolve(candidate) if (seenPaths.has(normalized)) continue 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 = pluginName(manifestValue, path.basename(candidate)) if (name.length === 0 || seenNames.has(name)) continue - const declared = - manifestValue === null || manifestValue.skills === undefined - ? ['skills'] - : Array.isArray(manifestValue.skills) - ? manifestValue.skills - : [manifestValue.skills] - const skillRoots: Array = [] - for (const value of declared) { - const relativePath = safeRelativePath(value) - if (relativePath === null) { - diagnostics.push({ - stage: 'manifest', - code: 'invalid_skill_root', - path: manifest?.path ?? candidate, - }) - continue - } - const skillRoot = path.resolve(candidate, relativePath) - if (yield* fs.exists(skillRoot).pipe(Effect.orElseSucceed(() => false))) skillRoots.push(skillRoot) - } - if (skillRoots.length === 0) continue + const skillRoots = yield* resolveSkillRoots( + candidate, + declaredSkillPaths(manifestValue), + manifest?.path ?? candidate, + diagnostics, + ) + if (Arr.isReadonlyArrayEmpty(skillRoots)) continue seenNames.add(name) for (const skillRoot of skillRoots) roots.push({ name, path: skillRoot }) } diff --git a/packages/fold-agent/src/Compatibility/GrokSkills.ts b/packages/fold-agent/src/Compatibility/GrokSkills.ts index 994cf0c..438bd82 100644 --- a/packages/fold-agent/src/Compatibility/GrokSkills.ts +++ b/packages/fold-agent/src/Compatibility/GrokSkills.ts @@ -1,8 +1,23 @@ import { homedir } from 'node:os' -import { SkillNotFoundError, type Skill, type SkillMeta, type SkillSourceService } from '@humanlayer/fold-core' -import { Effect, FileSystem, Path } from 'effect' -import { parse as parseYaml } from 'yaml' +import { + skillSource, + SkillNotFoundError, + type FoldSkills, + type Skill, + type SkillMeta, + type SkillSourceService, +} from '@humanlayer/fold-core' +import { Effect, FileSystem, Option, Path } from 'effect' + +import { + decodeSkillFrontmatter, + skillNameOr, + splitSkillFile, + type SkillFrontmatter, + type SkillFrontmatterInvalidFields, + type SkillFrontmatterInvalidYaml, +} from '../Skills/SkillFrontmatter' export type GrokSkillOptions = { readonly cwd: string @@ -15,18 +30,22 @@ export type GrokSkillOptions = { readonly ignoredPaths?: ReadonlyArray } -const exists = (path: string): Effect.Effect => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)) - }) +const noFrontmatter: SkillFrontmatter = {} + +/** + * Build the Grok-compatible skill source. FileSystem and Path are captured here, once; `list` and + * `load` rescan the skill roots on every call so newly added skills appear without a restart. + */ +export const makeGrokSkillSource = Effect.fn('fold.grok_compatibility.make_skill_source')(function* ( + options: GrokSkillOptions, +) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value) + const exists = (candidate: string): Effect.Effect => + fs.exists(candidate).pipe(Effect.orElseSucceed(() => false)) -const isAncestor = (ancestor: string, candidate: string): Effect.Effect => - Effect.gen(function* () { - const path = yield* Path.Path + const isAncestor = (ancestor: string, candidate: string): boolean => { let current = candidate while (true) { if (current === ancestor) return true @@ -34,14 +53,9 @@ const isAncestor = (ancestor: string, candidate: string): Effect.Effect, never, Path.Path> => - Effect.gen(function* () { - const path = yield* Path.Path + const ancestorSkillRoots = (cwd: string, boundary: string | null): ReadonlyArray => { const roots: Array = [] let current = cwd while (true) { @@ -53,132 +67,126 @@ const ancestorSkillRoots = ( current = parent } return roots - }) + } -const loadSkill = ( - skillPath: string, - namespace?: string, -): Effect.Effect => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - return yield* fs.readFileString(skillPath).pipe( - Effect.flatMap((raw) => - Effect.try(() => { - const normalized = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n') - const directory = path.dirname(skillPath) - let parsed: unknown = null - let content = normalized.trim() - if (normalized.startsWith('---\n')) { - const end = normalized.indexOf('\n---', 4) - if (end >= 0) { - parsed = parseYaml(normalized.slice(4, end)) - content = normalized.slice(end + 4).trim() - } - } - const record = isRecord(parsed) ? parsed : {} - const rawName = - typeof record.name === 'string' && 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.trim() - : content - .split(/\n\s*\n/)[0] - ?.replace(/^#+\s*/, '') - .trim() || rawName - return { name, description, content, baseDir: directory } - }), - ), - Effect.orElseSucceed(() => null), + const toSkill = (skillPath: string, frontmatter: SkillFrontmatter, content: string, namespace?: string): Skill => { + const directory = path.dirname(skillPath) + const rawName = skillNameOr(frontmatter, path.basename(directory)) + const name = namespace === undefined ? rawName : `${namespace}:${rawName}` + const declared = (frontmatter.description ?? '').trim() + const description = + declared.length > 0 + ? declared + : content + .split(/\n\s*\n/)[0] + ?.replace(/^#+\s*/, '') + .trim() || rawName + return { name, description, content, baseDir: directory } + } + + /** + * Grok frontmatter is optional: without it, or with fields of the wrong type, the name falls back to + * the directory and the description to the body's first paragraph. Unparseable YAML skips the skill. + */ + const loadSkill = (skillPath: string, namespace?: string): Effect.Effect => + fs.readFileString(skillPath).pipe( + Effect.flatMap((raw) => { + const { frontmatter, body } = splitSkillFile(raw) + const fields: Effect.Effect< + SkillFrontmatter, + SkillFrontmatterInvalidYaml | SkillFrontmatterInvalidFields + > = Option.match(frontmatter, { + onNone: () => Effect.succeed(noFrontmatter), + onSome: decodeSkillFrontmatter, + }) + return fields.pipe( + Effect.catchTag('SkillFrontmatterInvalidFields', (error) => + Effect.as( + Effect.logWarning(`skill frontmatter ignored (invalid fields): ${skillPath}`, error), + noFrontmatter, + ), + ), + Effect.map((decoded) => toSkill(skillPath, decoded, body, namespace)), + ) + }), + Effect.catch((error) => Effect.as(Effect.logWarning(`skill skipped: ${skillPath}`, error), null)), ) - }) -const scanRoot = ( - root: string, - ignoredPaths: ReadonlyArray, - namespace?: string, -): Effect.Effect, never, FileSystem.FileSystem | Path.Path> => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - if (!(yield* exists(root))) return [] - const found: Array = [] - const scan = (directory: string): Effect.Effect => - Effect.gen(function* () { - const resolvedDirectory = path.resolve(directory) - for (const ignoredPath of ignoredPaths) - if (resolvedDirectory === ignoredPath || (yield* isAncestor(ignoredPath, resolvedDirectory))) return - const skillPath = path.join(directory, 'SKILL.md') - if (yield* exists(skillPath)) { - const skill = yield* loadSkill(skillPath, namespace) - if (skill !== null) found.push(skill) - return - } - const entries = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])) - for (const entry of [...entries].sort()) { - if (entry.startsWith('.') || entry === 'node_modules') continue - const child = path.join(directory, entry) - const info = yield* fs.stat(child).pipe(Effect.orElseSucceed(() => null)) - if (info?.type === 'Directory') yield* scan(child) - } - }) - yield* scan(root) - return found - }) + const scanRoot = ( + root: string, + ignoredPaths: ReadonlyArray, + namespace?: string, + ): Effect.Effect> => + Effect.gen(function* () { + if (!(yield* exists(root))) return [] + const found: Array = [] + const scan = (directory: string): Effect.Effect => + Effect.gen(function* () { + const resolvedDirectory = path.resolve(directory) + for (const ignoredPath of ignoredPaths) + if (resolvedDirectory === ignoredPath || isAncestor(ignoredPath, resolvedDirectory)) return + const skillPath = path.join(directory, 'SKILL.md') + if (yield* exists(skillPath)) { + const skill = yield* loadSkill(skillPath, namespace) + if (skill !== null) found.push(skill) + return + } + const entries = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])) + for (const entry of [...entries].sort()) { + if (entry.startsWith('.') || entry === 'node_modules') continue + const child = path.join(directory, entry) + const info = yield* fs.stat(child).pipe(Effect.orElseSucceed(() => null)) + if (info?.type === 'Directory') yield* scan(child) + } + }) + yield* scan(root) + return found + }) -export const makeGrokSkillSource = Effect.fn('fold.grok_compatibility.make_skill_source')(function* ( - options: GrokSkillOptions, -) { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path const cwd = path.resolve(options.cwd) - const homeValue = options.home === undefined ? homedir() : options.home - const home = homeValue.length === 0 ? null : path.resolve(homeValue) - const grokHome = path.resolve(options.grokHome ?? path.join(home ?? homedir(), '.grok')) - const projectRoot = options.projectRoot === undefined ? null : path.resolve(options.projectRoot) - const projectRootIsAncestor = projectRoot !== null && (yield* isAncestor(projectRoot, cwd)) - const homeIsAncestor = home !== null && (yield* isAncestor(home, cwd)) - const boundary = projectRootIsAncestor ? projectRoot : homeIsAncestor ? home : null + const { configuredPaths = [], bundledPaths = [], pluginPaths = [], ignoredPaths = [] } = options + // An empty home disables every home-relative root. + const home = Option.liftPredicate(options.home ?? homedir(), (value) => value.length > 0).pipe( + Option.map((value) => path.resolve(value)), + ) + const grokHome = path.resolve(options.grokHome ?? path.join(Option.getOrElse(home, homedir), '.grok')) + const projectRoot = Option.map(Option.fromUndefinedOr(options.projectRoot), (value) => path.resolve(value)) + // The ancestor walk stops at the project root when it contains cwd, otherwise at home. + const boundary = Option.filter(projectRoot, (root) => isAncestor(root, cwd)).pipe( + Option.orElse(() => Option.filter(home, (value) => isAncestor(value, cwd))), + Option.getOrNull, + ) + const homeSkillRoots = Option.toArray(home).flatMap((value) => + ['.agents', '.claude', '.cursor'].map((vendor) => path.join(value, vendor, 'skills')), + ) const roots = [ - ...(yield* ancestorSkillRoots(cwd, boundary)), - ...(options.configuredPaths ?? []), + ...ancestorSkillRoots(cwd, boundary), + ...configuredPaths, path.join(grokHome, 'skills'), - ...(home === null - ? [] - : [ - path.join(home, '.agents', 'skills'), - path.join(home, '.claude', 'skills'), - path.join(home, '.cursor', 'skills'), - ]), - ...(options.bundledPaths ?? []), + ...homeSkillRoots, + ...bundledPaths, ] - const ignoredPaths = (options.ignoredPaths ?? []).map((ignoredPath) => path.resolve(ignoredPath)) - const scanSkillCatalog = Effect.fn('fold.grok_compatibility.scan_skill_catalog')(function* () { + const resolvedIgnoredPaths = ignoredPaths.map((ignoredPath) => path.resolve(ignoredPath)) + + const scanSkillCatalog: Effect.Effect> = Effect.gen(function* () { const byName = new Map() for (const root of roots) - for (const skill of yield* scanRoot(path.resolve(root), ignoredPaths)) + for (const skill of yield* scanRoot(path.resolve(root), resolvedIgnoredPaths)) if (!byName.has(skill.name)) byName.set(skill.name, skill) - for (const plugin of options.pluginPaths ?? []) - for (const skill of yield* scanRoot(path.resolve(plugin.path), ignoredPaths, plugin.name)) + for (const plugin of pluginPaths) + for (const skill of yield* scanRoot(path.resolve(plugin.path), resolvedIgnoredPaths, plugin.name)) if (!byName.has(skill.name)) byName.set(skill.name, skill) return byName }) - const scanSkillCatalogWithPlatformServices = () => - scanSkillCatalog().pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - ) - return { - list: scanSkillCatalogWithPlatformServices().pipe( + + const source: SkillSourceService = { + list: scanSkillCatalog.pipe( Effect.map((skills) => [...skills.values()].map(({ name, description }): SkillMeta => ({ name, description })), ), ), - load: (name: string) => - scanSkillCatalogWithPlatformServices().pipe( + load: (name) => + scanSkillCatalog.pipe( Effect.flatMap((skills) => { const skill = skills.get(name) return skill === undefined @@ -186,5 +194,10 @@ export const makeGrokSkillSource = Effect.fn('fold.grok_compatibility.make_skill : Effect.succeed(skill) }), ), - } satisfies SkillSourceService + } + return source }) + +/** Configure an agent's skills from Grok's skill directories (see {@link makeGrokSkillSource}). */ +export const grokSkills = (options: GrokSkillOptions): FoldSkills => + skillSource(makeGrokSkillSource(options)) diff --git a/packages/fold-agent/src/Config/AgentModels.ts b/packages/fold-agent/src/Config/AgentModels.ts index 59d8007..5a339eb 100644 --- a/packages/fold-agent/src/Config/AgentModels.ts +++ b/packages/fold-agent/src/Config/AgentModels.ts @@ -18,7 +18,8 @@ import { anthropicModel, DEFAULT_ANTHROPIC_MODEL_ID, lookupCatalogEntry, openaiM import type { ActiveModel, ModelCatalogEntry, ReasoningLevel, FoldModel } from '@humanlayer/fold-core' import { DEFAULT_OPENCODE_MODEL_ID, openCodeModel } from '@humanlayer/fold-opencode' import { DEFAULT_XAI_MODEL_ID, xaiModel } from '@humanlayer/fold-xai' -import { Effect, Match, Redacted, Schema } from 'effect' +import { Effect, type FileSystem, Match, Redacted, Schema } from 'effect' +import type { HttpClient } from 'effect/unstable/http' import { ConfigRole, type ProviderConnection, type RoleBinding, type FoldConfig } from './ConfigSchema' @@ -41,17 +42,24 @@ export type AgentModelsOptions = { * not know pass through permissively. Omitted means no validation. */ readonly catalog?: ReadonlyArray + /** + * The auth document OAuth providers (codex, opencode, xai) read their credentials from, each under + * its own provider name. Defaults to `~/.fold/auth.json`. + */ + readonly authStorePath?: string } /** Resolves config roles to runnable model descriptors (D25). */ export type AgentModels = { - readonly resolve: (role: ConfigRole) => Effect.Effect + readonly resolve: ( + role: ConfigRole, + ) => Effect.Effect, RoleResolutionError> } const defaultEnv: EnvLookup = (name) => process.env[name] /** Stamp the requested role onto the resolved model's snapshot (provenance in the durable log). */ -const withRole = (model: FoldModel, role: ConfigRole): FoldModel => { +const withRole = (model: FoldModel, role: ConfigRole): FoldModel => { const activeModel: ActiveModel = { ...model.activeModel, role } return { ...model, activeModel } } @@ -107,17 +115,13 @@ export const agentModelsFromConfig = (config: FoldConfig, options?: AgentModelsO ): Effect.Effect => { if (binding.model !== undefined) return Effect.succeed(binding.model) - switch (provider.kind) { - case 'codex': - return Effect.succeed(DEFAULT_CODEX_MODEL_ID) - case 'opencode': - return Effect.succeed(DEFAULT_OPENCODE_MODEL_ID) - case 'xai': - return Effect.succeed(DEFAULT_XAI_MODEL_ID) - case 'anthropic': - return Effect.succeed(DEFAULT_ANTHROPIC_MODEL_ID) - case 'openai-compat': - return Effect.fail( + return Match.value(provider.kind).pipe( + Match.when('codex', () => Effect.succeed(DEFAULT_CODEX_MODEL_ID)), + Match.when('opencode', () => Effect.succeed(DEFAULT_OPENCODE_MODEL_ID)), + Match.when('xai', () => Effect.succeed(DEFAULT_XAI_MODEL_ID)), + Match.when('anthropic', () => Effect.succeed(DEFAULT_ANTHROPIC_MODEL_ID)), + Match.when('openai-compat', () => + Effect.fail( new RoleResolutionError({ role, message: @@ -125,12 +129,17 @@ export const agentModelsFromConfig = (config: FoldConfig, options?: AgentModelsO `models default per provider kind (codex → ${DEFAULT_CODEX_MODEL_ID}, anthropic → ` + `${DEFAULT_ANTHROPIC_MODEL_ID}) but a model is required for openai-compat`, }), - ) - } + ), + ), + Match.exhaustive, + ) } /** Resolve one role's binding to its provider-specific model descriptor. */ - const resolveBinding = (role: ConfigRole, binding: RoleBinding): Effect.Effect => + const resolveBinding = ( + role: ConfigRole, + binding: RoleBinding, + ): Effect.Effect, RoleResolutionError> => Effect.gen(function* () { const providerName = binding.provider const provider = config.providers[providerName] @@ -146,25 +155,44 @@ export const agentModelsFromConfig = (config: FoldConfig, options?: AgentModelsO const reasoning: ReasoningLevel | undefined = binding.reasoning const reasoningOption = reasoning === undefined ? {} : { reasoning } const baseUrlOption = provider.baseUrl === undefined ? {} : { baseUrl: provider.baseUrl } + const authStoreOption = options?.authStorePath === undefined ? {} : { authStorePath: options.authStorePath } if (provider.kind === 'codex') { const apiUrlOption = provider.baseUrl === undefined ? {} : { apiUrl: provider.baseUrl } return withRole( - codexModel({ model, providerId: providerName, ...reasoningOption, ...apiUrlOption }), + codexModel({ + model, + providerId: providerName, + ...reasoningOption, + ...apiUrlOption, + ...authStoreOption, + }), role, ) } if (provider.kind === 'opencode') { const apiUrlOption = provider.baseUrl === undefined ? {} : { apiUrl: provider.baseUrl } return withRole( - openCodeModel({ model, providerId: providerName, ...reasoningOption, ...apiUrlOption }), + openCodeModel({ + model, + providerId: providerName, + ...reasoningOption, + ...apiUrlOption, + ...authStoreOption, + }), role, ) } if (provider.kind === 'xai') { const apiUrlOption = provider.baseUrl === undefined ? {} : { apiUrl: provider.baseUrl } return withRole( - xaiModel({ model, providerId: providerName, ...reasoningOption, ...apiUrlOption }), + xaiModel({ + model, + providerId: providerName, + ...reasoningOption, + ...apiUrlOption, + ...authStoreOption, + }), role, ) } @@ -194,7 +222,7 @@ export const agentModelsFromConfig = (config: FoldConfig, options?: AgentModelsO const validateReasoningSupport = ( role: ConfigRole, binding: RoleBinding, - model: FoldModel, + model: FoldModel, ): Effect.Effect => { const requested = binding.reasoning if (catalog === undefined || requested === undefined || requested === 'off') return Effect.void @@ -226,7 +254,9 @@ export const agentModelsFromConfig = (config: FoldConfig, options?: AgentModelsO return Effect.void } - const resolve = (role: ConfigRole): Effect.Effect => + const resolve = ( + role: ConfigRole, + ): Effect.Effect, RoleResolutionError> => Effect.gen(function* () { const binding = bindingFor(role) const model = yield* resolveBinding(role, binding) diff --git a/packages/fold-agent/src/Config/ConfigSchema.ts b/packages/fold-agent/src/Config/ConfigSchema.ts index 69dca21..7824e7b 100644 --- a/packages/fold-agent/src/Config/ConfigSchema.ts +++ b/packages/fold-agent/src/Config/ConfigSchema.ts @@ -14,7 +14,7 @@ */ import { ReasoningLevel } from '@humanlayer/fold-core' import type { AutoCompactConfig as CoreAutoCompactConfig, StopConditionConfig } from '@humanlayer/fold-core' -import { Schema } from 'effect' +import { Array as Arr, Predicate, Schema } from 'effect' /** How a configured provider profile is reached. */ export const ProviderKind = Schema.Literals(['anthropic', 'openai-compat', 'codex', 'opencode', 'xai']).annotate({ @@ -167,28 +167,25 @@ type RolesRef = { readonly fast: RoleBindingRef readonly orchestrator?: RoleBindingRef } -type CrossRefShape = { - readonly providers: Record +type ConfigCrossReferences = { + readonly providers: Readonly> readonly roles: RolesRef readonly profiles?: Record } -const bindingsOf = (roles: RolesRef): ReadonlyArray => [ - roles.smart, - roles.fast, - ...(roles.orchestrator === undefined ? [] : [roles.orchestrator]), -] +const bindingsOf = (roles: RolesRef): ReadonlyArray => + [roles.smart, roles.fast, roles.orchestrator].filter(Predicate.isNotUndefined) /** * Every provider a role binds to - in the default `roles` map AND in every named profile - must be * declared in `providers` (decode-time typo/reference safety). */ -const providersReferencedByRolesExist = Schema.makeFilter( +const providersReferencedByRolesExist = Schema.makeFilter( ({ providers, roles, profiles }) => { const declared = new Set(Object.keys(providers)) const bindings = [...bindingsOf(roles), ...Object.values(profiles ?? {}).flatMap(bindingsOf)] const missing = [...new Set(bindings.map((binding) => binding.provider).filter((name) => !declared.has(name)))] - return missing.length === 0 + return Arr.isArrayEmpty(missing) ? undefined : `roles reference undeclared providers: ${missing.join(', ')} (declared: ${[...declared].join(', ') || '(none)'})` }, diff --git a/packages/fold-agent/src/Config/ConfigSchemaJson.ts b/packages/fold-agent/src/Config/ConfigSchemaJson.ts index 384170c..3d22a69 100644 --- a/packages/fold-agent/src/Config/ConfigSchemaJson.ts +++ b/packages/fold-agent/src/Config/ConfigSchemaJson.ts @@ -10,7 +10,7 @@ */ import { join } from 'node:path' -import { JsonSchema, Effect, FileSystem, Schema } from 'effect' +import { Array as Arr, JsonSchema, Effect, FileSystem, Schema } from 'effect' import { FoldConfig } from './ConfigSchema' import { writeFoldInfo } from './FoldInfo' @@ -19,20 +19,26 @@ import { defaultConfigPath, defaultFoldHome } from './Load' /** The generated schema file path for a home directory. */ export const configSchemaPath = (foldHome?: string): string => join(foldHome ?? defaultFoldHome(), 'config.schema.json') +/** A self-contained draft-07 JSON Schema document: the root schema plus its `$schema` URI and inlined definitions. */ +export type FoldConfigJsonSchemaDocument = JsonSchema.JsonSchema & { + readonly $schema: string + readonly definitions?: JsonSchema.Definitions +} + /** The `FoldConfig` JSON Schema as a self-contained draft-07 document object (ready to serialize). */ -export const foldConfigJsonSchema = (): Record => { +export const foldConfigJsonSchema = (): FoldConfigJsonSchemaDocument => { const document = JsonSchema.toDocumentDraft07(Schema.toJsonSchemaDocument(FoldConfig)) - const hasDefinitions = Object.keys(document.definitions).length > 0 - const schema: Record = { - $schema: JsonSchema.META_SCHEMA_URI_DRAFT_07, - ...document.schema, - } - if (hasDefinitions) schema.definitions = document.definitions - return schema + const root = { $schema: JsonSchema.META_SCHEMA_URI_DRAFT_07, ...document.schema } + return Arr.isArrayNonEmpty(Object.keys(document.definitions)) + ? { ...root, definitions: document.definitions } + : root } +const JsonSchemaDocumentText = Schema.fromJsonString(Schema.Unknown, { space: '\t' }) +const encodeJsonSchemaDocumentText = Schema.encodeSync(JsonSchemaDocumentText) + /** The generated schema serialized as JSON text (tab-indented, trailing newline). */ -export const foldConfigJsonSchemaText = (): string => `${JSON.stringify(foldConfigJsonSchema(), null, '\t')}\n` +export const foldConfigJsonSchemaText = (): string => `${encodeJsonSchemaDocumentText(foldConfigJsonSchema())}\n` /** A commented starter `config.jsonc` referencing the generated schema. */ export const starterConfigJsonc = (): string => @@ -168,13 +174,13 @@ export const bootstrapFoldHome = ( const infoPath = yield* writeFoldInfo(options) const configPath = defaultConfigPath(options?.foldHome) - const configExists = yield* fs.exists(configPath).pipe(Effect.catch(() => Effect.succeed(false))) + const configExists = yield* fs.exists(configPath).pipe(Effect.orElseSucceed(() => false)) if (!configExists) { yield* fs.writeFileString(configPath, starterConfigJsonc()).pipe(Effect.orDie) } const authPath = join(options?.foldHome ?? defaultFoldHome(), 'auth.json') - const authExists = yield* fs.exists(authPath).pipe(Effect.catch(() => Effect.succeed(false))) + const authExists = yield* fs.exists(authPath).pipe(Effect.orElseSucceed(() => false)) if (!authExists) { // Same document shape and permissions the codex auth store maintains (provider-keyed JSON). yield* fs.writeFileString(authPath, '{}\n', { mode: 0o600 }).pipe(Effect.orDie) diff --git a/packages/fold-agent/src/Config/Load.ts b/packages/fold-agent/src/Config/Load.ts index 7ac3361..b876dae 100644 --- a/packages/fold-agent/src/Config/Load.ts +++ b/packages/fold-agent/src/Config/Load.ts @@ -12,7 +12,7 @@ import { homedir } from 'node:os' import { join } from 'node:path' -import { Effect, FileSystem, Predicate, Schema } from 'effect' +import { Effect, FileSystem, Schema } from 'effect' import { FoldConfig } from './ConfigSchema' @@ -120,6 +120,7 @@ export const stripJsonc = (input: string): string => { return out.join('') } +const decodeJsonText = Schema.decodeEffect(Schema.fromJsonString(Schema.Json)) const decodeConfig = Schema.decodeUnknownEffect(FoldConfig, { onExcessProperty: 'error' }) /** Parse and decode JSONC text into a {@link FoldConfig}. Pure: no filesystem access. */ @@ -129,11 +130,9 @@ export const parseFoldConfig = ( ): Effect.Effect => Effect.gen(function* () { const path = options?.path ?? null - const parsed = yield* Effect.try({ - try: (): unknown => JSON.parse(stripJsonc(text)), - catch: (cause) => - new ConfigParseError({ path, message: Predicate.isError(cause) ? cause.message : String(cause) }), - }) + const parsed = yield* decodeJsonText(stripJsonc(text)).pipe( + Effect.mapError((error) => new ConfigParseError({ path, message: error.message })), + ) return yield* decodeConfig(parsed).pipe( Effect.mapError((error) => new ConfigDecodeError({ path, message: error.message })), @@ -151,7 +150,7 @@ export const loadFoldConfig = ( const fs = yield* FileSystem.FileSystem const path = configPathFor(options) - const exists = yield* fs.exists(path).pipe(Effect.catch(() => Effect.succeed(false))) + const exists = yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)) if (!exists) return yield* new ConfigFileNotFoundError({ path }) const text = yield* fs.readFileString(path).pipe(Effect.orDie) diff --git a/packages/fold-agent/src/Config/ModelSelections.ts b/packages/fold-agent/src/Config/ModelSelections.ts index e0173c0..84e9117 100644 --- a/packages/fold-agent/src/Config/ModelSelections.ts +++ b/packages/fold-agent/src/Config/ModelSelections.ts @@ -2,7 +2,8 @@ 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, XAI_FRONTIER_MODELS } from '@humanlayer/fold-xai' -import { Data, Effect, Match, Predicate } from 'effect' +import { Data, Effect, type FileSystem, Match, Predicate } from 'effect' +import type { HttpClient } from 'effect/unstable/http' import { agentModelsFromConfig, type AgentModelsOptions, RoleResolutionError } from './AgentModels' import type { ConfigRole, ProfileConfig, ProfileModeName, RoleBinding, FoldConfig } from './ConfigSchema' @@ -19,14 +20,16 @@ type RolesBuilder = { orchestrator?: RoleBinding } -export type ProfileModelSelection = { readonly _tag: 'profile'; readonly profile: string } -export type DirectModelSelection = { - readonly _tag: 'direct' - readonly provider: string - readonly model: string - readonly reasoning?: RoleBinding['reasoning'] -} -export type ConfiguredModelSelection = ProfileModelSelection | DirectModelSelection +export type ConfiguredModelSelection = Data.TaggedEnum<{ + profile: { readonly profile: string } + direct: { + readonly provider: string + readonly model: string + readonly reasoning?: RoleBinding['reasoning'] + } +}> +export type ProfileModelSelection = Data.TaggedEnum.Value +export type DirectModelSelection = Data.TaggedEnum.Value export const ConfiguredModelSelection = Data.taggedEnum() export type ModelConfiguration = { @@ -42,21 +45,18 @@ export type ModelConfiguration = { } export type FoldModels = { - readonly root: FoldModel - readonly smart: FoldModel - readonly fast: FoldModel - readonly orchestrator: FoldModel + readonly root: FoldModel + readonly smart: FoldModel + readonly fast: FoldModel + readonly orchestrator: FoldModel } +const roleBindings = (roles: FoldConfig['roles']): ReadonlyArray => + [roles.smart, roles.fast, roles.orchestrator].filter(Predicate.isNotUndefined) + const bindings = (config: FoldConfig): ReadonlyArray => [ - config.roles.smart, - config.roles.fast, - ...(config.roles.orchestrator === undefined ? [] : [config.roles.orchestrator]), - ...Object.values(config.profiles ?? {}).flatMap((profile) => [ - profile.smart, - profile.fast, - ...(profile.orchestrator === undefined ? [] : [profile.orchestrator]), - ]), + ...roleBindings(config.roles), + ...Object.values(config.profiles ?? {}).flatMap(roleBindings), ] /** A secret-free view of selectable config. This is the shared CLI/TUI configuration boundary. */ @@ -70,14 +70,12 @@ export const describeModelConfiguration = ( ...Object.entries(config.profiles ?? {}).map(([name, profile]) => ({ name, mode: profile.mode ?? null })), ], providers: Object.entries(config.providers).map(([name, provider]) => { - const catalogProviderIds = - provider.kind === 'anthropic' - ? [name, 'anthropic'] - : provider.kind === 'codex' || provider.kind === 'opencode' - ? [name, 'openai'] - : provider.kind === 'xai' - ? [name, 'xai'] - : [name, 'openai'] + const catalogProviderIds = Match.value(provider.kind).pipe( + Match.when('anthropic', () => [name, 'anthropic']), + Match.when('xai', () => [name, 'xai']), + Match.when(Match.is('codex', 'opencode', 'openai-compat'), () => [name, 'openai']), + Match.exhaustive, + ) const configured = bindings(config) .filter( (binding): binding is typeof binding & { readonly model: string } => @@ -93,9 +91,8 @@ export const describeModelConfiguration = ( Match.when('xai', () => XAI_FRONTIER_MODELS.map(({ modelId }) => modelId)), Match.orElse((): ReadonlyArray => []), ) - const models = [ - ...new Set([...defaultModels, ...(provider.configuredModels ?? []), ...configured, ...catalogModels]), - ].sort() + const { configuredModels = [] } = provider + const models = [...new Set([...defaultModels, ...configuredModels, ...configured, ...catalogModels])].sort() return { name, kind: provider.kind, @@ -128,10 +125,7 @@ type DirectProviderSelection = { readonly reasoning?: RoleBinding['reasoning'] } -const defaultModelsForProvider = ( - config: FoldConfig, - selection: DirectProviderSelection, -): Record => { +const defaultModelsForProvider = (config: FoldConfig, selection: DirectProviderSelection) => { const kind = config.providers[selection.provider]?.kind if (kind === 'codex') return { orchestrator: DEFAULT_CODEX_MODEL_ID, smart: 'gpt-5.6-terra', fast: 'gpt-5.6-luna' } if (kind === 'anthropic') diff --git a/packages/fold-agent/src/Config/ProviderConfig.ts b/packages/fold-agent/src/Config/ProviderConfig.ts index 1d31c72..fb482c2 100644 --- a/packages/fold-agent/src/Config/ProviderConfig.ts +++ b/packages/fold-agent/src/Config/ProviderConfig.ts @@ -8,9 +8,9 @@ import { dirname } from 'node:path' import { DEFAULT_CODEX_MODEL_ID } from '@humanlayer/fold-codex' import { DEFAULT_OPENCODE_MODEL_ID } from '@humanlayer/fold-opencode' import { DEFAULT_XAI_MODEL_ID } from '@humanlayer/fold-xai' -import { Clock, Effect, FileSystem, Match, Random, Schema } from 'effect' +import { Array as Arr, Clock, Effect, FileSystem, Match, Random, Schema } from 'effect' -import type { FoldConfig, ProviderKind } from './ConfigSchema' +import { FoldConfig, type ProviderKind } from './ConfigSchema' import { configPathFor, loadFoldConfig, @@ -84,6 +84,30 @@ const validBaseUrl = (value: string): Effect.Effect + Effect.gen(function* () { + const oauth = input.kind === 'codex' || input.kind === 'opencode' || input.kind === 'xai' + const hasApiKey = input.apiKey !== undefined && input.apiKey.trim() !== '' + const hasApiKeyEnv = input.apiKeyEnv !== undefined && input.apiKeyEnv.trim() !== '' + if (oauth && (hasApiKey || hasApiKeyEnv)) + return yield* new ProviderConfigurationKindError({ + kind: input.kind, + message: `${input.kind} credentials are OAuth-managed; API key options must not be supplied`, + }) + if (!oauth && hasApiKey === hasApiKeyEnv) + return yield* new ProviderConfigurationValidationError({ + field: 'apiKey', + message: 'supply exactly one of apiKey or apiKeyEnv', + }) + if (oauth) return {} + return hasApiKey + ? { apiKey: yield* required(input.apiKey ?? '', 'apiKey') } + : { apiKeyEnv: yield* required(input.apiKeyEnv ?? '', 'apiKeyEnv') } + }) + +const encodeConfigText = Schema.encodeEffect(Schema.fromJsonString(FoldConfig, { space: '\t' })) + const writeConfig = ( config: FoldConfig, options: LoadConfigOptions | undefined, @@ -96,7 +120,12 @@ const writeConfig = ( const now = yield* Clock.currentTimeMillis const salt = (yield* Random.next).toString(36).slice(2) const temporaryPath = `${path}.tmp-${process.pid}-${now}-${salt}` - const text = `${JSON.stringify(config, null, '\t')}\n` + const text = `${yield* encodeConfigText(config).pipe( + Effect.mapError( + (error) => + new ProviderConfigurationWriteError({ path, message: `could not encode config: ${error.message}` }), + ), + )}\n` const writeDirect = fs.writeFileString(path, text, { mode: 0o600 }).pipe(Effect.andThen(fs.chmod(path, 0o600))) return yield* fs.makeDirectory(dirname(path), { recursive: true }).pipe( @@ -125,21 +154,7 @@ export const configureProvider = ( Effect.gen(function* () { const name = yield* required(input.name, 'name') const baseUrl = yield* validBaseUrl(input.baseUrl) - const oauth = input.kind === 'codex' || input.kind === 'opencode' || input.kind === 'xai' - const hasApiKey = input.apiKey !== undefined && input.apiKey.trim() !== '' - const hasApiKeyEnv = input.apiKeyEnv !== undefined && input.apiKeyEnv.trim() !== '' - if (oauth && (hasApiKey || hasApiKeyEnv)) - return yield* new ProviderConfigurationKindError({ - kind: input.kind, - message: `${input.kind} credentials are OAuth-managed; API key options must not be supplied`, - }) - if (!oauth && hasApiKey === hasApiKeyEnv) - return yield* new ProviderConfigurationValidationError({ - field: 'apiKey', - message: 'supply exactly one of apiKey or apiKeyEnv', - }) - const apiKey = !oauth && hasApiKey ? yield* required(input.apiKey ?? '', 'apiKey') : undefined - const apiKeyEnv = !oauth && hasApiKeyEnv ? yield* required(input.apiKeyEnv ?? '', 'apiKeyEnv') : undefined + const credential = yield* resolveCredentials(input) const defaultModel = Match.value(input.kind).pipe( Match.when('codex', () => DEFAULT_CODEX_MODEL_ID), Match.when('opencode', () => DEFAULT_OPENCODE_MODEL_ID), @@ -150,19 +165,10 @@ export const configureProvider = ( const config = yield* loadFoldConfig(options) const previousModels = config.providers[name]?.configuredModels ?? [] const configuredModels = model === undefined ? previousModels : [...new Set([...previousModels, model])] - const provider: { - kind: ProviderKind - baseUrl: string - apiKey?: string - apiKeyEnv?: string - configuredModels?: ReadonlyArray - } = { - kind: input.kind, - baseUrl, - } - if (apiKey !== undefined) provider.apiKey = apiKey - if (apiKeyEnv !== undefined) provider.apiKeyEnv = apiKeyEnv - if (configuredModels.length > 0) provider.configuredModels = configuredModels + const connection = { kind: input.kind, baseUrl, ...credential } + const provider = Arr.isReadonlyArrayNonEmpty(configuredModels) + ? { ...connection, configuredModels } + : connection const updated: FoldConfig = { ...config, providers: { ...config.providers, [name]: provider } } yield* writeConfig(updated, options) diff --git a/packages/fold-agent/src/EventLog/JsonlDescriptor.ts b/packages/fold-agent/src/EventLog/JsonlDescriptor.ts index 5bae9c5..1fd2243 100644 --- a/packages/fold-agent/src/EventLog/JsonlDescriptor.ts +++ b/packages/fold-agent/src/EventLog/JsonlDescriptor.ts @@ -4,18 +4,10 @@ * `memoryEventLog` does. One file is one session; pointing at an existing file resumes its entries. */ import { eventLogSource, EventLog, type FoldEventLog } from '@humanlayer/fold-core' -import { Context, Effect, FileSystem, Layer } from 'effect' +import { Context, Effect, type FileSystem, Layer } from 'effect' import { layerJsonl } from './JsonlLayer' /** Back a session's durable log with one JSONL file. Existing entries replay on start (resume). */ -export const jsonlEventLog = (filePath: string): FoldEventLog => - eventLogSource( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const fsLayer = Layer.succeed(FileSystem.FileSystem, fs) - const context = yield* Layer.build(layerJsonl(filePath).pipe(Layer.provide(fsLayer))) - - return Context.get(context, EventLog) - }), - ) +export const jsonlEventLog = (filePath: string): FoldEventLog => + eventLogSource(Layer.build(layerJsonl(filePath)).pipe(Effect.map((context) => Context.get(context, EventLog)))) diff --git a/packages/fold-agent/src/EventLog/JsonlLayer.ts b/packages/fold-agent/src/EventLog/JsonlLayer.ts index 742c218..c599cf2 100644 --- a/packages/fold-agent/src/EventLog/JsonlLayer.ts +++ b/packages/fold-agent/src/EventLog/JsonlLayer.ts @@ -10,7 +10,7 @@ import { LogEntry as LogEntrySchema, decodeStoredLogEntry, layerLiveIdFactory, - makeStoredLogEntry, + storedLogEntry, type EventLogError, type EventLogService, type EventLogUnsupportedVersionError, @@ -38,18 +38,8 @@ const unavailableError = ( cause, }) -const corruptEntryError = (line: number, message: string, cause?: unknown, seq?: number) => { - const input: { - operation: 'entries' - message: string - line: number - seq?: number - cause?: unknown - } = { operation: 'entries', message, line } - if (seq !== undefined) input.seq = seq - if (cause !== undefined) input.cause = cause - return new EventLogCorruptEntryError(input) -} +const corruptEntryError = (line: number, message: string, cause?: unknown, seq?: number) => + new EventLogCorruptEntryError({ operation: 'entries', message, line, seq, cause }) const invalidEntryError = (message: string, cause: unknown) => new EventLogInvalidEntryError({ @@ -64,6 +54,9 @@ const jsonlLines = (contents: string): ReadonlyArray => { return contents.split('\n') } +const decodeJsonText = Schema.decodeEffect(Schema.fromJsonString(Schema.Json)) +const encodeLogEntryLine = Schema.encodeEffect(Schema.fromJsonString(LogEntrySchema)) + const decodeJsonlLine = ( line: string, lineNumber: number, @@ -73,10 +66,9 @@ const decodeJsonlLine = ( return yield* corruptEntryError(lineNumber, `Empty JSONL line at line ${lineNumber}`) } - const parsed = yield* Effect.try({ - try: (): unknown => JSON.parse(line), - catch: (cause) => corruptEntryError(lineNumber, `Invalid JSON at line ${lineNumber}`, cause), - }) + const parsed = yield* decodeJsonText(line).pipe( + Effect.mapError((cause) => corruptEntryError(lineNumber, `Invalid JSON at line ${lineNumber}`, cause)), + ) const entry = yield* decodeStoredLogEntry(parsed).pipe( Effect.catchTag('EventLogCorruptEntryError', (error) => corruptEntryError( @@ -108,14 +100,11 @@ const decodeJsonl = ( const encodeJsonlLine = (entry: LogEntry): Effect.Effect => Effect.gen(function* () { - const encoded = yield* Schema.encodeUnknownEffect(LogEntrySchema)(entry).pipe( + const line = yield* encodeLogEntryLine(entry).pipe( Effect.mapError((cause) => invalidEntryError('Unable to encode EventLog entry', cause)), ) - return yield* Effect.try({ - try: () => `${JSON.stringify(encoded)}\n`, - catch: (cause) => invalidEntryError('Unable to serialize EventLog entry as JSON', cause), - }) + return `${line}\n` }) const loadEntries = ( @@ -202,7 +191,7 @@ export const layerJsonlWithIds = ( appendLock.withPermit( Effect.gen(function* () { const current = yield* Ref.get(entriesRef) - const stored = yield* makeStoredLogEntry(input, current.length, ids) + const stored = yield* storedLogEntry(input, current.length, yield* ids.makeEventId) const line = yield* encodeJsonlLine(stored) yield* appendJsonlLine(fs, filePath, line) diff --git a/packages/fold-agent/src/Fs/MutationQueue.ts b/packages/fold-agent/src/Fs/MutationQueue.ts index 87c0e0a..005a5c4 100644 --- a/packages/fold-agent/src/Fs/MutationQueue.ts +++ b/packages/fold-agent/src/Fs/MutationQueue.ts @@ -16,7 +16,7 @@ type QueueEntry = { const queues = new Map() // Serializes map registration so two concurrent callers cannot race a key into existence twice. -const registration = Effect.runSync(Semaphore.make(1)) +const registration = Semaphore.makeUnsafe(1) /** * Compute the queue key: realpath when the file exists, resolved absolute path when it does not exist diff --git a/packages/fold-agent/src/Fs/PathResolve.ts b/packages/fold-agent/src/Fs/PathResolve.ts index e4dc618..91e6c3c 100644 --- a/packages/fold-agent/src/Fs/PathResolve.ts +++ b/packages/fold-agent/src/Fs/PathResolve.ts @@ -42,7 +42,7 @@ const nfdVariant = (path: string): string => path.normalize('NFD') const curlyQuoteVariant = (path: string): string => path.replace(/'/g, '’') const exists = (fs: FileSystem.FileSystem, path: string): Effect.Effect => - fs.exists(path).pipe(Effect.catch(() => Effect.succeed(false))) + fs.exists(path).pipe(Effect.orElseSucceed(() => false)) /** * Resolve a path for reading, trying pi's macOS filename variants in order when the resolved path does diff --git a/packages/fold-agent/src/Memory/AgentFiles.ts b/packages/fold-agent/src/Memory/AgentFiles.ts index 20879ed..ffcd5a1 100644 --- a/packages/fold-agent/src/Memory/AgentFiles.ts +++ b/packages/fold-agent/src/Memory/AgentFiles.ts @@ -18,7 +18,7 @@ import { homedir } from 'node:os' import { dirname, join } from 'node:path' -import { Effect, FileSystem, Schema } from 'effect' +import { Array as Arr, Effect, FileSystem, Schema } from 'effect' /** One loaded agentfile. */ export const MemoryFile = Schema.Struct({ @@ -63,7 +63,7 @@ const ancestorDirectories = (start: string): ReadonlyArray => { } const fileExists = (fs: FileSystem.FileSystem, path: string): Effect.Effect => - fs.exists(path).pipe(Effect.catch(() => Effect.succeed(false))) + fs.exists(path).pipe(Effect.orElseSucceed(() => false)) /** * Load agentfiles for a working directory in render order (global, then root..cwd; base then local @@ -83,7 +83,7 @@ export const loadMemoryFiles = ( const addFile = (path: string): Effect.Effect => Effect.gen(function* () { if (seen.has(path)) return - const content = yield* fs.readFileString(path).pipe(Effect.catch(() => Effect.succeed(null))) + const content = yield* fs.readFileString(path).pipe(Effect.orElseSucceed(() => null)) if (content === null) return seen.add(path) collected.push({ path, content }) @@ -124,7 +124,7 @@ const escapeXmlAttribute = (text: string): string => /** Render loaded agentfiles as one `` prompt block (pi shape). Null when empty. */ export const renderMemoryFiles = (files: ReadonlyArray): string | null => { - if (files.length === 0) return null + if (Arr.isReadonlyArrayEmpty(files)) return null const blocks = files.map( (file) => diff --git a/packages/fold-agent/src/Mode/CompactionArchiveAccess.ts b/packages/fold-agent/src/Mode/CompactionArchiveAccess.ts index 8ef0901..3daec99 100644 --- a/packages/fold-agent/src/Mode/CompactionArchiveAccess.ts +++ b/packages/fold-agent/src/Mode/CompactionArchiveAccess.ts @@ -20,7 +20,7 @@ export type CompactionArchiveAccessOptions = { readonly modeName: string } -const logShapeCheatSheet = `Log shape cheat sheet: +const logFormatCheatSheet = `Log shape cheat sheet: - This is JSONL: one dense JSON event per physical line, ordered by seq. - Top-level fields: _tag, seq, ts, agentId, parentAgentId, toolCallId. - session_started: sessionId, cwd, rootAgentId, meta.agentName. @@ -55,7 +55,7 @@ If you are searching by a short agent id prefix, omit the trailing quote because rg -n -F '"agentId":"${shortAgentId(input.agentId)}' "${input.logPath}" | rg -F 'EXACT_TERM' -${logShapeCheatSheet} +${logFormatCheatSheet} ` const rlmRootInstructions = (input: FormatCompactionArchiveInstructionsInput): string => ` @@ -82,7 +82,7 @@ rg -n -F '"agentId":"agent_abcd1234' "${input.logPath}" | rg -F 'EXACT_TERM' If the subagent was forked, inherited context may be referenced by agent_started.fork.fromAgentId and fork.atSeq rather than copied into the subagent's own rows. -${logShapeCheatSheet} +${logFormatCheatSheet} ` /** Format the post-compaction archive access block for one agent. */ diff --git a/packages/fold-agent/src/Mode/Launch.ts b/packages/fold-agent/src/Mode/Launch.ts index 3c9f9d9..55a7cdd 100644 --- a/packages/fold-agent/src/Mode/Launch.ts +++ b/packages/fold-agent/src/Mode/Launch.ts @@ -24,9 +24,6 @@ import { SessionId, startSession, type AgentDefinition, - type ResumeSessionOptions, - type StartSessionOptions, - type SwitchModelOptions, type AutoCompactConfig, type ModelCatalogEntry, type ReasoningLevel, @@ -37,17 +34,26 @@ import { type FoldSession, type FoldTool, type Ids, - type LogSeq, } from '@humanlayer/fold-core' -import { Predicate, Effect, FileSystem, Layer, Match, Schema, Semaphore, type Scope } from 'effect' - -import { loadModelCatalog, type LoadModelCatalogOptions } from '../Catalog/LoadCatalog' import { - agentModelsFromConfig, - type AgentModelsOptions, - type EnvLookup, - type RoleResolutionError, -} from '../Config/AgentModels' + Array as Arr, + Predicate, + Effect, + type FileSystem, + Layer, + Match, + Option, + Schema, + Semaphore, + Struct, + type Scope, + type Path, +} from 'effect' +import type { HttpClient } from 'effect/unstable/http' +import type { ChildProcessSpawner } from 'effect/unstable/process' + +import { loadModelCatalog } from '../Catalog/LoadCatalog' +import { agentModelsFromConfig, type EnvLookup, type RoleResolutionError } from '../Config/AgentModels' import type { ConfigRole, ProfileModeName, RoleBinding, FoldConfig } from '../Config/ConfigSchema' import { defaultFoldHome, @@ -55,29 +61,31 @@ import { type ConfigDecodeError, type ConfigFileNotFoundError, type ConfigParseError, - type LoadConfigOptions, } from '../Config/Load' import { rolesForDirectProviderSelection } from '../Config/ModelSelections' import { jsonlEventLog } from '../EventLog/JsonlDescriptor' -import { memoryPromptBlock, type AgentFilesOptions } from '../Memory/AgentFiles' -import { makeOutputStore, type MakeOutputStoreOptions, type OutputStoreService } from '../OutputStore/OutputStore' +import { memoryPromptBlock } from '../Memory/AgentFiles' +import { + layerOutputStore, + sweepToolOutput, + toolOutputSessionDirFor, + type OutputStore, +} from '../OutputStore/OutputStore' import { latestSessionLog, prepareSessionLog, refreshSessionSummaryIndex, sessionLogById, type SessionLogRef, - type SessionLayoutOptions, } from '../Session/SessionLayout' import { generateSessionTitle } from '../Session/TitleGenerator' +import type { Photon } from '../Tools/Image/Photon' import { compactionArchiveAccessFor } from './CompactionArchiveAccess' import { defaultCodingMode, type FoldMode } from './Mode' import { modeForName } from './ModeName' import { RPI_HINT_PROMPT } from './Rpi' import type { ModeModels } from './Subagents' -type Mutable = { -readonly [Key in keyof Value]: Value[Key] } - type RolesBuilder = { smart: RoleBinding fast: RoleBinding @@ -148,7 +156,7 @@ export type LaunchSessionOptions = { /** An already-decoded config. When omitted, the config is loaded from `/config.jsonc`. */ readonly config?: FoldConfig /** An explicit model, bypassing config/role resolution entirely (no config file needed). */ - readonly model?: FoldModel + readonly model?: FoldModel /** Config-backed model selection/override used when `model` is omitted. */ readonly modelSelection?: ModelSelection /** The project working directory. Defaults to `process.cwd()`. */ @@ -172,7 +180,16 @@ export type LaunchSessionOptions = { /** Steering drain mode (D8). Defaults to one-at-a-time. */ readonly steering?: SteeringMode /** Extra tools appended after the mode's roster (e.g. subagents). */ - readonly extraTools?: ReadonlyArray + readonly extraTools?: ReadonlyArray< + FoldTool< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + > + > /** Agent display name recorded in `session_started`. Defaults to the mode name. */ readonly name?: string } @@ -226,9 +243,7 @@ const resolveProfileSelection = ( let config: FoldConfig if (opts.config === undefined) { - const configOptions: Mutable = {} - if (opts.foldHome !== undefined) configOptions.foldHome = opts.foldHome - config = yield* loadFoldConfig(configOptions) + config = yield* loadFoldConfig(Struct.pick(opts, ['foldHome'])) } else { config = opts.config } @@ -273,12 +288,8 @@ export const mergeModelSelection = (config: FoldConfig, base: RoleBinding, selec const model = selection.model ?? (providerKindChanged ? undefined : base.model) const reasoning = selection.reasoning ?? base.reasoning - const binding: { provider: string; model?: string; reasoning?: NonNullable } = { - provider, - } - if (model !== undefined) binding.model = model - if (reasoning !== undefined) binding.reasoning = reasoning - return binding + const withModel = model === undefined ? { provider } : { provider, model } + return reasoning === undefined ? withModel : { ...withModel, reasoning } } const withSelectedRoleBinding = (config: FoldConfig, role: ConfigRole, binding: RoleBinding): FoldConfig => ({ @@ -315,9 +326,7 @@ const resolveModeModels = ( const role = selection.role ?? mode.role let config: FoldConfig if (options.config === undefined) { - const configOptions: Mutable = {} - if (options.foldHome !== undefined) configOptions.foldHome = options.foldHome - config = yield* loadFoldConfig(configOptions) + config = yield* loadFoldConfig(Struct.pick(options, ['foldHome'])) } else { config = options.config } @@ -337,9 +346,14 @@ const resolveModeModels = ( mergeModelSelection(config, roleBindingFor(config, role), selection), ) } - const modelOptions: Mutable = { catalog } - if (options.env !== undefined) modelOptions.env = options.env - const models = agentModelsFromConfig(selectedConfig, modelOptions) + const modelOptions = { catalog, ...Struct.pick(options, ['env']) } + // OAuth providers read their credentials from the same fold home the CLI's login commands write to. + const models = agentModelsFromConfig( + selectedConfig, + options.foldHome === undefined + ? modelOptions + : { ...modelOptions, authStorePath: join(options.foldHome, 'auth.json') }, + ) return { primary: yield* models.resolve(role), @@ -370,32 +384,48 @@ const buildAgentDefinition = ( models: ModeModels, cwd: string, config: FoldConfig | null, - outputStore: OutputStoreService, -): Effect.Effect => +): Effect.Effect< + AgentDefinition< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + >, + never, + FileSystem.FileSystem +> => Effect.gen(function* () { - const memoryOptions: Mutable = { cwd } - if (options.home !== undefined) memoryOptions.home = options.home - const memoryBlock = yield* memoryPromptBlock(memoryOptions) + const memoryBlock = yield* memoryPromptBlock({ cwd, ...Struct.pick(options, ['home']) }) // Effective RPI: the flag, or the mode's own default (RLM always carries the specialists). const rpi = options.rpi === true || mode.rpiByDefault === true - const tools = [...mode.buildTools({ cwd, models, rpi, outputStore }), ...(options.extraTools ?? [])] - const blocks = [ - ...(mode.systemPrompt === undefined ? [] : [mode.systemPrompt]), - modelStyleBlock, - ...(rpi ? [RPI_HINT_PROMPT] : []), - ...(memoryBlock === null ? [] : [memoryBlock]), - foldInfoBlock(options.foldHome ?? defaultFoldHome()), - ] + const { extraTools = [] } = options + const tools = [...mode.buildTools({ cwd, models, rpi }), ...extraTools] + const blocks = Arr.getSomes([ + Option.fromUndefinedOr(mode.systemPrompt), + Option.some(modelStyleBlock), + Option.liftPredicate(RPI_HINT_PROMPT, () => rpi), + Option.fromNullOr(memoryBlock), + Option.some(foldInfoBlock(options.foldHome ?? defaultFoldHome())), + ]) const autoCompact = options.autoCompact ?? config?.compaction ?? defaultAutoCompact - const agentOptions: Mutable = { + const agent = { name: options.name ?? mode.name, model: models.primary, tools, autoCompact, stopConditions: options.stopConditions ?? config?.stopConditions ?? defaultStopConditions, } - if (blocks.length > 0) agentOptions.systemPrompt = blocks - return defineAgent(agentOptions) + return defineAgent(Arr.isArrayNonEmpty(blocks) ? { ...agent, systemPrompt: blocks } : agent) + }) + +/** The session's own output store, under `/tool-output/`. */ +const outputStoreFor = (sessionId: SessionId, options: LaunchSessionOptions) => + layerOutputStore({ + directory: toolOutputSessionDirFor( + options.foldHome === undefined ? { sessionId } : { sessionId, foldHome: options.foldHome }, + ), }) /** @@ -404,7 +434,7 @@ const buildAgentDefinition = ( * `agentModelsFromConfig` stamped each model's `activeModel.role`, so role provenance flows into the * durable `agent_started.model` of every role-bound child for free. */ -const sessionProfilesFor = (models: ModeModels): SessionProfiles => ({ +const sessionProfilesFor = (models: ModeModels): SessionProfiles => ({ smart: models.smart, fast: models.fast, orchestrator: models.orchestrator, @@ -417,9 +447,16 @@ const sessionProfilesFor = (models: ModeModels): SessionProfiles => ({ * epoch switch so newly introduced role-bound subagent types are covered at the switch boundary. */ export const switchSessionMode = ( - session: FoldSession, + session: FoldSession< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + >, options: SwitchSessionModeOptions, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const { options: profiled } = yield* resolveProfileSelection(options) const mode = options.mode @@ -427,33 +464,27 @@ export const switchSessionMode = ( const catalog = yield* catalogFor(profiled) const models = yield* resolveModeModels(profiled, mode, catalog) const config = yield* runtimeConfigFor(profiled) - const outputStoreOptions: Mutable = { sessionId: session.sessionId } - if (profiled.foldHome !== undefined) outputStoreOptions.foldHome = profiled.foldHome - const outputStore = yield* makeOutputStore(outputStoreOptions) - yield* outputStore.sweep - const agent = yield* buildAgentDefinition(profiled, mode, models, cwd, config, outputStore) + const agent = yield* buildAgentDefinition(profiled, mode, models, cwd, config) - const switchOptions: Mutable = { + yield* session.switchModel(models.primary, { reason: options.reason ?? `switch mode to ${mode.name}`, profiles: sessionProfilesFor(models), - } - if (agent.systemPrompt !== undefined) switchOptions.systemPrompt = agent.systemPrompt - if (agent.tools !== undefined) switchOptions.tools = agent.tools - yield* session.switchModel(models.primary, switchOptions) + ...Struct.pick(agent, ['systemPrompt', 'tools']), + }) }) -const withGeneratedTitles = ( - session: FoldSession, - model: FoldModel, +const withGeneratedTitles = ( + session: FoldSession, + model: FoldModel, options: { readonly cwd: string; readonly foldHome?: string }, -): Effect.Effect => +): Effect.Effect, never, FileSystem.FileSystem | HttpClient.HttpClient> => Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const fsLayer = Layer.succeed(FileSystem.FileSystem, fs) + // Titles are generated after later sends, which run without these services, so take them now. + const titleServices = yield* Effect.context() return yield* Semaphore.make(1).pipe( Effect.map((titleLock) => ({ ...session, - send: (text: string, target?: Parameters[1]) => + send: (text: string, target?: Parameters['send']>[1]) => session.send(text, target).pipe( Effect.tap(() => { if (target?.agentId !== undefined && target.agentId !== session.rootAgentId) @@ -473,19 +504,20 @@ const withGeneratedTitles = ( const generatedTurns = lastTitle?.rootUserTurns ?? 0 if (rootUsers.length <= generatedTurns) return Effect.void return generateSessionTitle(entries, session.rootAgentId, model).pipe( + Effect.provideContext(titleServices), Effect.flatMap((title) => { const generatedThroughSeq = entries.at(-1)?.seq - const provenance: { - rootUserTurns: number - generatedThroughSeq?: LogSeq - } = { rootUserTurns: rootUsers.length } - if (generatedThroughSeq !== undefined) - provenance.generatedThroughSeq = generatedThroughSeq - const setTitle = session.setTitle(title, provenance) + const rootUserTurns = rootUsers.length + const setTitle = session.setTitle( + title, + generatedThroughSeq === undefined + ? { rootUserTurns } + : { rootUserTurns, generatedThroughSeq }, + ) return setTitle.pipe( Effect.andThen( refreshSessionSummaryIndex(session.sessionId, options).pipe( - Effect.provide(fsLayer), + Effect.provideContext(titleServices), ), ), ) @@ -507,20 +539,16 @@ const runtimeConfigFor = ( if (options.config !== undefined) return Effect.succeed(options.config) if (options.model !== undefined) return Effect.succeed(null) - const configOptions: Mutable = {} - if (options.foldHome !== undefined) configOptions.foldHome = options.foldHome - return loadFoldConfig(configOptions) + return loadFoldConfig(Struct.pick(options, ['foldHome'])) } /** The catalog for a launch: the caller's (the CLI loads once), else a fresh load (never fails). */ const catalogFor = ( options: LaunchSessionOptions, -): Effect.Effect, never, FileSystem.FileSystem> => { +): Effect.Effect, never, FileSystem.FileSystem | HttpClient.HttpClient> => { if (options.catalog !== undefined) return Effect.succeed(options.catalog) - const catalogOptions: Mutable = { foldHome: options.foldHome ?? defaultFoldHome() } - if (options.env !== undefined) catalogOptions.env = options.env - return loadModelCatalog(catalogOptions) + return loadModelCatalog({ foldHome: options.foldHome ?? defaultFoldHome(), ...Struct.pick(options, ['env']) }) } /** @@ -529,7 +557,24 @@ const catalogFor = ( */ export const launchSession = ( options?: LaunchSessionOptions, -): Effect.Effect => +): Effect.Effect< + FoldSession< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + >, + LaunchModelError, + | Scope.Scope + | Ids + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | Photon + | HttpClient.HttpClient +> => Effect.gen(function* () { const { options: opts, profileMode } = yield* resolveProfileSelection(options ?? {}) const mode = modeFor(opts, profileMode) @@ -539,16 +584,13 @@ export const launchSession = ( const catalog = yield* catalogFor(opts) const models = yield* resolveModeModels(opts, mode, catalog) const config = yield* runtimeConfigFor(opts) - const prepareOptions: Mutable = { cwd } - if (opts.foldHome !== undefined) prepareOptions.foldHome = opts.foldHome - const prepared = yield* prepareSessionLog(prepareOptions) - const outputStoreOptions: Mutable = { sessionId: prepared.sessionId } - if (opts.foldHome !== undefined) outputStoreOptions.foldHome = opts.foldHome - const outputStore = yield* makeOutputStore(outputStoreOptions) - yield* outputStore.sweep - const agent = yield* buildAgentDefinition(opts, mode, models, cwd, config, outputStore) - - const startOptions: Mutable = { + const prepared = yield* prepareSessionLog({ cwd, ...Struct.pick(opts, ['foldHome']) }) + yield* sweepToolOutput(opts.foldHome === undefined ? {} : { foldHome: opts.foldHome }) + const agent = yield* buildAgentDefinition(opts, mode, models, cwd, config) + + // The output store lives as long as the session, in the caller's scope. + const outputStore = yield* Layer.build(outputStoreFor(prepared.sessionId, opts)) + const session = yield* startSession({ agent, log: prepared.log, cwd, @@ -561,12 +603,9 @@ export const launchSession = ( profiles: sessionProfilesFor(models), catalog, compactionArchiveAccess: compactionArchiveAccessFor({ logPath: prepared.path, modeName: mode.name }), - } - if (opts.steering !== undefined) startOptions.steering = opts.steering - const session = yield* startSession(startOptions) - const titleOptions: { cwd: string; foldHome?: string } = { cwd } - if (opts.foldHome !== undefined) titleOptions.foldHome = opts.foldHome - return yield* withGeneratedTitles(session, models.fast, titleOptions) + ...Struct.pick(opts, ['steering']), + }).pipe(Effect.provideContext(outputStore)) + return yield* withGeneratedTitles(session, models.fast, { cwd, ...Struct.pick(opts, ['foldHome']) }) }) const resumeFromLog = ( @@ -574,30 +613,42 @@ const resumeFromLog = ( options: LaunchSessionOptions, mode: FoldMode, cwd: string, -): Effect.Effect => +): Effect.Effect< + FoldSession< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + >, + LaunchModelError, + | Scope.Scope + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | Photon + | HttpClient.HttpClient +> => Effect.gen(function* () { // Same order as launchSession: the catalog loads before model resolution (D23 validation). const catalog = yield* catalogFor(options) const models = yield* resolveModeModels(options, mode, catalog) const config = yield* runtimeConfigFor(options) - const outputStoreOptions: Mutable = { sessionId: log.sessionId } - if (options.foldHome !== undefined) outputStoreOptions.foldHome = options.foldHome - const outputStore = yield* makeOutputStore(outputStoreOptions) - yield* outputStore.sweep - const agent = yield* buildAgentDefinition(options, mode, models, cwd, config, outputStore) + yield* sweepToolOutput(options.foldHome === undefined ? {} : { foldHome: options.foldHome }) + const agent = yield* buildAgentDefinition(options, mode, models, cwd, config) - const resumeOptions: Mutable = { + // The output store lives as long as the session, in the caller's scope. + const outputStore = yield* Layer.build(outputStoreFor(log.sessionId, options)) + const session = yield* resumeSession({ agent, log: jsonlEventLog(log.path), profiles: sessionProfilesFor(models), catalog, compactionArchiveAccess: compactionArchiveAccessFor({ logPath: log.path, modeName: mode.name }), - } - if (options.steering !== undefined) resumeOptions.steering = options.steering - const session = yield* resumeSession(resumeOptions) - const sessionLayoutOptions: { cwd: string; foldHome?: string } = { cwd } - if (options.foldHome !== undefined) sessionLayoutOptions.foldHome = options.foldHome - return yield* withGeneratedTitles(session, models.fast, sessionLayoutOptions) + ...Struct.pick(options, ['steering']), + }).pipe(Effect.provideContext(outputStore)) + return yield* withGeneratedTitles(session, models.fast, { cwd, ...Struct.pick(options, ['foldHome']) }) }) /** @@ -607,15 +658,29 @@ const resumeFromLog = ( */ export const resumeLatestSession = ( options?: LaunchSessionOptions, -): Effect.Effect => +): Effect.Effect< + FoldSession< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + >, + LaunchModelError | NoSessionToResumeError, + | Scope.Scope + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | Photon + | HttpClient.HttpClient +> => Effect.gen(function* () { const { options: opts, profileMode } = yield* resolveProfileSelection(options ?? {}) const mode = modeFor(opts, profileMode) const cwd = opts.cwd ?? process.cwd() - const sessionLayoutOptions: Mutable = { cwd } - if (opts.foldHome !== undefined) sessionLayoutOptions.foldHome = opts.foldHome - const latest = yield* latestSessionLog(sessionLayoutOptions) + const latest = yield* latestSessionLog({ cwd, ...Struct.pick(opts, ['foldHome']) }) if (latest === null) return yield* new NoSessionToResumeError({ cwd }) return yield* resumeFromLog(latest, opts, mode, cwd) @@ -629,15 +694,29 @@ export const resumeLatestSession = ( export const resumeSessionById = ( sessionId: SessionId, options?: LaunchSessionOptions, -): Effect.Effect => +): Effect.Effect< + FoldSession< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + >, + LaunchModelError | SessionToResumeNotFoundError, + | Scope.Scope + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | Photon + | HttpClient.HttpClient +> => Effect.gen(function* () { const { options: opts, profileMode } = yield* resolveProfileSelection(options ?? {}) const mode = modeFor(opts, profileMode) const cwd = opts.cwd ?? process.cwd() - const sessionLayoutOptions: Mutable = { cwd } - if (opts.foldHome !== undefined) sessionLayoutOptions.foldHome = opts.foldHome - const log = yield* sessionLogById(sessionId, sessionLayoutOptions) + const log = yield* sessionLogById(sessionId, { cwd, ...Struct.pick(opts, ['foldHome']) }) if (log === null) return yield* new SessionToResumeNotFoundError({ cwd, sessionId }) return yield* resumeFromLog(log, opts, mode, cwd) diff --git a/packages/fold-agent/src/Mode/Mode.ts b/packages/fold-agent/src/Mode/Mode.ts index 8bbe129..dfa9204 100644 --- a/packages/fold-agent/src/Mode/Mode.ts +++ b/packages/fold-agent/src/Mode/Mode.ts @@ -12,11 +12,15 @@ * roster to ANY mode's subagents (Mode/Rpi). */ import { skillTool, subagentTool, type FoldTool } from '@humanlayer/fold-core' +import type { FileSystem, Path } from 'effect' +import type { HttpClient } from 'effect/unstable/http' +import type { ChildProcessSpawner } from 'effect/unstable/process' import type { ConfigRole } from '../Config/ConfigSchema' -import type { OutputStoreService } from '../OutputStore/OutputStore' +import type { OutputStore } from '../OutputStore/OutputStore' import { skillsFromDisk } from '../Skills/DiskSkills' import { codingTools } from '../Tools/CodingTools' +import type { Photon } from '../Tools/Image/Photon' import { modeSubagents } from './Rpi' import type { ModeModels } from './Subagents' @@ -32,8 +36,6 @@ export type ModeToolContext = { readonly models: ModeModels /** Install the RPI specialist subagents alongside the default roster (composable with any mode). */ readonly rpi: boolean - /** Deterministic full-output store scoped to the current session. */ - readonly outputStore?: OutputStoreService } /** A pre-baked agent composition: primary model role, mode prompt, and tool roster. */ @@ -50,7 +52,18 @@ export type FoldMode = { */ readonly rpiByDefault?: boolean /** Build the mode's tool roster for a working directory. */ - readonly buildTools: (context: ModeToolContext) => ReadonlyArray + readonly buildTools: ( + context: ModeToolContext, + ) => ReadonlyArray< + FoldTool< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + > + > } /** The default coding-agent system prompt. */ @@ -68,15 +81,9 @@ export const defaultCodingMode: FoldMode = { name: 'coding', role: 'smart', systemPrompt: DEFAULT_CODING_PROMPT, - buildTools: ({ cwd, rpi, outputStore }) => { - const codingOptions: { cwd: string; outputStore?: OutputStoreService } = { cwd } - if (outputStore !== undefined) codingOptions.outputStore = outputStore - const subagentOptions: { cwd: string; rpi: boolean; outputStore?: OutputStoreService } = { cwd, rpi } - if (outputStore !== undefined) subagentOptions.outputStore = outputStore - return [ - ...codingTools(codingOptions), - skillTool(skillsFromDisk({ cwd })), - subagentTool(modeSubagents(subagentOptions)), - ] - }, + buildTools: ({ cwd, rpi }) => [ + ...codingTools({ cwd }), + skillTool(skillsFromDisk({ cwd })), + subagentTool(modeSubagents({ cwd, rpi })), + ], } diff --git a/packages/fold-agent/src/Mode/ModeName.ts b/packages/fold-agent/src/Mode/ModeName.ts index 0021483..d66e890 100644 --- a/packages/fold-agent/src/Mode/ModeName.ts +++ b/packages/fold-agent/src/Mode/ModeName.ts @@ -12,10 +12,10 @@ export const FOLD_MODE_NAMES = ['default', 'rlm'] as const /** A selectable mode name (`default` = the full coding toolset, `rlm` = the delegating orchestrator). */ export type FoldModeName = (typeof FOLD_MODE_NAMES)[number] -const modesByName: Record = { +const modesByName = { default: defaultCodingMode, rlm: rlmMode, -} +} as const satisfies Record /** Resolve a selectable mode name to its `FoldMode` value. */ export const modeForName = (name: FoldModeName): FoldMode => modesByName[name] diff --git a/packages/fold-agent/src/Mode/Rlm.ts b/packages/fold-agent/src/Mode/Rlm.ts index 85e39b9..70b8056 100644 --- a/packages/fold-agent/src/Mode/Rlm.ts +++ b/packages/fold-agent/src/Mode/Rlm.ts @@ -10,7 +10,6 @@ */ import { skillTool, subagentTool } from '@humanlayer/fold-core' -import type { OutputStoreService } from '../OutputStore/OutputStore' import { skillsFromDisk } from '../Skills/DiskSkills' import { applyPatchTool } from '../Tools/ApplyPatchTool' import { editTool } from '../Tools/EditTool' @@ -58,16 +57,12 @@ export const rlmMode: FoldMode = { // RLM always carries the RPI specialists (user ruling 2026-07-09): an orchestrator with no bash // lives and dies by the quality of its delegates, so the full specialist roster is the default. rpiByDefault: true, - buildTools: ({ cwd, rpi, outputStore }) => { - const subagentOptions: { cwd: string; rpi: boolean; outputStore?: OutputStoreService } = { cwd, rpi } - if (outputStore !== undefined) subagentOptions.outputStore = outputStore - return [ - readTool({ cwd }), - writeTool({ cwd }), - editTool({ cwd }), - applyPatchTool({ cwd }), - skillTool(skillsFromDisk({ cwd })), - subagentTool(modeSubagents(subagentOptions)), - ] - }, + buildTools: ({ cwd, rpi }) => [ + readTool({ cwd }), + writeTool({ cwd }), + editTool({ cwd }), + applyPatchTool({ cwd }), + skillTool(skillsFromDisk({ cwd })), + subagentTool(modeSubagents({ cwd, rpi })), + ], } diff --git a/packages/fold-agent/src/Mode/Rpi.ts b/packages/fold-agent/src/Mode/Rpi.ts index e12da47..805cd0a 100644 --- a/packages/fold-agent/src/Mode/Rpi.ts +++ b/packages/fold-agent/src/Mode/Rpi.ts @@ -24,10 +24,14 @@ * in v1 (deliberate). */ import { defineSubagent, subagentTool, type SubagentDefinition } from '@humanlayer/fold-core' +import type { FileSystem, Path } from 'effect' +import type { HttpClient } from 'effect/unstable/http' +import type { ChildProcessSpawner } from 'effect/unstable/process' -import type { OutputStoreService } from '../OutputStore/OutputStore' +import type { OutputStore } from '../OutputStore/OutputStore' import { bashTool } from '../Tools/BashTool' import { codingTools } from '../Tools/CodingTools' +import type { Photon } from '../Tools/Image/Photon' import { readTool } from '../Tools/ReadTool' import { AST_GREP_OUTLINE_GUIDANCE, defaultSubagents } from './Subagents' @@ -638,15 +642,28 @@ export const OUTLINE_IMPLEMENTER_AGENT_PROMPT: string = /** Inputs for building the RPI roster against one working directory. */ export type RpiSubagentOptions = { readonly cwd: string - readonly outputStore?: OutputStoreService /** * The default roster's own `bash` and `general-purpose` definitions, threaded in BY REFERENCE: the * implementer types dispatch these exact instances, so the session registry's identity dedup sees * one definition per name instead of dying on a same-name duplicate at session start. */ readonly delegates: { - readonly bash: SubagentDefinition - readonly generalPurpose: SubagentDefinition + readonly bash: SubagentDefinition< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + > + readonly generalPurpose: SubagentDefinition< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + > } } @@ -658,15 +675,21 @@ export type RpiSubagentOptions = { */ export const rpiSubagents = ({ cwd, - outputStore, delegates, -}: RpiSubagentOptions): ReadonlyArray => { +}: RpiSubagentOptions): ReadonlyArray< + SubagentDefinition< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + > +> => { const read = readTool({ cwd }) - const toolOptions: { cwd: string; outputStore?: OutputStoreService } = { cwd } - if (outputStore !== undefined) toolOptions.outputStore = outputStore - const bash = bashTool(toolOptions) + const bash = bashTool({ cwd }) const readAndBash = [read, bash] - const coding = codingTools(toolOptions) + const coding = codingTools({ cwd }) const implementerDelegates = subagentTool([delegates.bash, delegates.generalPurpose]) const codebaseLocator = defineSubagent({ @@ -746,14 +769,32 @@ export const rpiSubagents = ({ /** Inputs for assembling a mode's dispatchable roster. */ export type ModeSubagentOptions = { readonly cwd: string - readonly outputStore?: OutputStoreService /** When true, the six RPI specialist types are appended to the default roster. */ readonly rpi: boolean } // The default roster registers this name by construction; a miss is a programming defect, not a // recoverable state, so it dies (the throw becomes an Effect defect at the composition root). -const delegateByName = (roster: ReadonlyArray, name: string): SubagentDefinition => { +const delegateByName = ( + roster: ReadonlyArray< + SubagentDefinition< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + > + >, + name: string, +): SubagentDefinition< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient +> => { const found = roster.find((definition) => definition.name === name) if (found === undefined) throw new Error(`default subagent roster is missing the "${name}" delegate`) return found @@ -764,21 +805,25 @@ const delegateByName = (roster: ReadonlyArray, name: string) * the six specialists wired to delegate to the default roster's own bash/general-purpose instances. * Shared by `defaultCodingMode` and `rlmMode` so the roster composition never diverges between modes. */ -export const modeSubagents = ({ cwd, outputStore, rpi }: ModeSubagentOptions): ReadonlyArray => { - const rosterOptions: { cwd: string; outputStore?: OutputStoreService } = { cwd } - if (outputStore !== undefined) rosterOptions.outputStore = outputStore - const roster = defaultSubagents(rosterOptions) +export const modeSubagents = ({ + cwd, + rpi, +}: ModeSubagentOptions): ReadonlyArray< + SubagentDefinition< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + > +> => { + const roster = defaultSubagents({ cwd }) if (!rpi) return roster const delegates = { bash: delegateByName(roster, 'bash'), generalPurpose: delegateByName(roster, 'general-purpose'), } - const specialistOptions: { - cwd: string - outputStore?: OutputStoreService - delegates: RpiSubagentOptions['delegates'] - } = { cwd, delegates } - if (outputStore !== undefined) specialistOptions.outputStore = outputStore - return [...roster, ...rpiSubagents(specialistOptions)] + return [...roster, ...rpiSubagents({ cwd, delegates })] } diff --git a/packages/fold-agent/src/Mode/Subagents.ts b/packages/fold-agent/src/Mode/Subagents.ts index 36946de..52a6232 100644 --- a/packages/fold-agent/src/Mode/Subagents.ts +++ b/packages/fold-agent/src/Mode/Subagents.ts @@ -28,28 +28,31 @@ import { type FoldModel, type FoldTool, } from '@humanlayer/fold-core' +import type { FileSystem, Path } from 'effect' +import type { HttpClient } from 'effect/unstable/http' +import type { ChildProcessSpawner } from 'effect/unstable/process' -import type { OutputStoreService } from '../OutputStore/OutputStore' +import type { OutputStore } from '../OutputStore/OutputStore' import { skillsFromDisk } from '../Skills/DiskSkills' import { bashTool } from '../Tools/BashTool' import { codingTools } from '../Tools/CodingTools' +import type { Photon } from '../Tools/Image/Photon' import { readTool } from '../Tools/ReadTool' import { webTools } from '../Tools/WebTools' /** The models a mode binds its agents to, resolved from config roles (or a single explicit override). */ export type ModeModels = { /** The root agent's model: the mode's role, or whatever the caller selected/overrode. */ - readonly primary: FoldModel - readonly smart: FoldModel - readonly fast: FoldModel + readonly primary: FoldModel + readonly smart: FoldModel + readonly fast: FoldModel /** Falls back to `smart` when the config declares no orchestrator role (D25). */ - readonly orchestrator: FoldModel + readonly orchestrator: FoldModel } /** Inputs for building a subagent roster against one working directory. */ export type SubagentRosterOptions = { readonly cwd: string - readonly outputStore?: OutputStoreService } /** Leading prompt for the `bash` subagent. */ @@ -152,10 +155,19 @@ export const WEB_SEARCH_RESEARCHER_PROMPT: string = * types that get skills, so the session scans the skills directory exactly once (D20). Every type * binds its model by profile role, resolved through the session's profiles map at each dispatch. */ -export const defaultSubagents = ({ cwd, outputStore }: SubagentRosterOptions): ReadonlyArray => { - const toolOptions: { cwd: string; outputStore?: OutputStoreService } = { cwd } - if (outputStore !== undefined) toolOptions.outputStore = outputStore - const coding = codingTools(toolOptions) +export const defaultSubagents = ({ + cwd, +}: SubagentRosterOptions): ReadonlyArray< + SubagentDefinition< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + > +> => { + const coding = codingTools({ cwd }) const skills = skillTool(skillsFromDisk({ cwd })) const web = webTools() @@ -165,7 +177,7 @@ export const defaultSubagents = ({ cwd, outputStore }: SubagentRosterOptions): R 'Run shell commands (builds, tests, git, rg searches) and report the commands, exit status, and ' + 'the output that matters. Use it to execute something without spending your own context on raw output.', systemPrompt: BASH_SUBAGENT_PROMPT, - tools: [bashTool(toolOptions)], + tools: [bashTool({ cwd })], model: 'fast', }) @@ -177,7 +189,7 @@ export const defaultSubagents = ({ cwd, outputStore }: SubagentRosterOptions): R 'Locate code and explain how it works, returning a structured report with file:line references. ' + 'Use it for "where is X" and "how does Y work" questions that would otherwise require reading many files.', systemPrompt: [RESEARCHER_SUBAGENT_PROMPT, AST_GREP_OUTLINE_GUIDANCE], - tools: [readTool({ cwd }), bashTool(toolOptions), skills], + tools: [readTool({ cwd }), bashTool({ cwd }), skills], model: 'fast', }) @@ -195,7 +207,16 @@ export const defaultSubagents = ({ cwd, outputStore }: SubagentRosterOptions): R // array is built first, handed to the definition, then closed over its own subagentTool value. The // registry walk dedups by identity and carries a seen-set, so the resulting cycle is traversal-safe // (see fold-core `collectSubagentDefinitions`). - const generalPurposeTools: Array = [...coding, skills] + const generalPurposeTools: Array< + FoldTool< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + > + > = [...coding, skills] const generalPurpose = defineSubagent({ name: 'general-purpose', description: diff --git a/packages/fold-agent/src/OutputStore/OutputStore.ts b/packages/fold-agent/src/OutputStore/OutputStore.ts index 949d698..fd4389f 100644 --- a/packages/fold-agent/src/OutputStore/OutputStore.ts +++ b/packages/fold-agent/src/OutputStore/OutputStore.ts @@ -1,12 +1,13 @@ /** - * File-backed OutputStore for large tool output (D19): one deterministic text file per tool call under - * `/tool-output//.txt`. The durable log keeps the truncated result - * the model saw; this store is retrieval-only supporting data, streamed from the first byte so - * interrupted commands still leave partial output behind. + * File-backed OutputStore for large tool output (D19): one deterministic text file per tool call, + * `/.txt`. A launched session uses `/tool-output/`; the host + * provides the store where it starts the session and the bash tool asks for it. The durable log keeps + * the truncated result the model saw; this store is retrieval-only supporting data, streamed from the + * first byte so interrupted commands still leave partial output behind. */ import { join } from 'node:path' -import { SessionId, ToolCallId } from '@humanlayer/fold-core' +import { ToolCallId, type SessionId } from '@humanlayer/fold-core' import { Cause, Clock, Context, Effect, FileSystem, Layer, Option, Schema } from 'effect' import { defaultFoldHome } from '../Config/Load' @@ -15,7 +16,6 @@ const dayMs = 24 * 60 * 60 * 1000 /** Reference to one stored tool-output file. */ export class OutputStoreRef extends Schema.Class('fold-agent/OutputStoreRef')({ - sessionId: SessionId, toolCallId: ToolCallId, path: Schema.String, }) {} @@ -38,9 +38,7 @@ export type OutputStoreReadOptions = { /** Deep service surface for deterministic tool-output storage. */ export type OutputStoreService = { - /** Current session this store is scoped to. */ - readonly sessionId: SessionId - /** Directory containing this session's tool-output files. */ + /** Directory containing the tool-output files. */ readonly directory: string /** Compute the deterministic reference for one tool call without touching disk. */ readonly refFor: (toolCallId: ToolCallId) => OutputStoreRef @@ -50,22 +48,11 @@ export type OutputStoreService = { readonly append: (toolCallId: ToolCallId, chunk: string) => Effect.Effect /** Read output back for retrieval/debug surfaces. */ readonly read: (ref: OutputStoreRef, options?: OutputStoreReadOptions) => Effect.Effect - /** Best-effort retention sweep. It logs and swallows failures. */ - readonly sweep: Effect.Effect } -/** OutputStore service tag for composition roots that want to provide it as an Effect service. */ +/** Where tools store their full output. The host provides it; the bash tool asks for it. */ export class OutputStore extends Context.Service()('fold-agent/OutputStore') {} -/** Options for constructing a file-backed OutputStore. */ -export type MakeOutputStoreOptions = { - readonly sessionId: SessionId - /** Defaults to `~/.fold`. */ - readonly foldHome?: string - /** Files older than this are deleted by `sweep`. Defaults to 7 days. */ - readonly retentionMs?: number -} - /** Root directory for all stored tool output. */ export const toolOutputRootFor = (options?: { readonly foldHome?: string }): string => join(options?.foldHome ?? defaultFoldHome(), 'tool-output') @@ -108,91 +95,90 @@ const lineSlice = (content: string, options?: OutputStoreReadOptions): string => .join('\n') } -/** Construct a file-backed OutputStore service for one session. */ -export const makeOutputStore = ( - options: MakeOutputStoreOptions, -): Effect.Effect => - Effect.map(FileSystem.FileSystem, (fs) => { - const foldHome = options.foldHome ?? defaultFoldHome() - const sessionId = options.sessionId - const directory = toolOutputSessionDirFor({ sessionId, foldHome }) - const retentionMs = options.retentionMs ?? 7 * dayMs - - const refFor = (toolCallId: ToolCallId): OutputStoreRef => - new OutputStoreRef({ - sessionId, - toolCallId, - path: toolOutputPathFor({ sessionId, toolCallId, foldHome }), - }) - - const prepare = (toolCallId: ToolCallId): Effect.Effect => { - const ref = refFor(toolCallId) - return fs.makeDirectory(directory, { recursive: true }).pipe( - Effect.andThen(fs.writeFileString(ref.path, '', { flag: 'a' })), - Effect.as(ref), - Effect.mapError((cause) => fileOperationError({ operation: 'prepare', path: ref.path, cause })), - Effect.tapError(logStoreError), - Effect.withSpan('output_store.prepare', { - attributes: { sessionId, toolCallId, path: ref.path }, - }), - ) - } - - const append = (toolCallId: ToolCallId, chunk: string): Effect.Effect => { - const ref = refFor(toolCallId) - return fs.makeDirectory(directory, { recursive: true }).pipe( - Effect.andThen(fs.writeFileString(ref.path, chunk, { flag: 'a' })), - Effect.as(ref), - Effect.mapError((cause) => fileOperationError({ operation: 'append', path: ref.path, cause })), - Effect.tapError(logStoreError), - Effect.withSpan('output_store.append', { - attributes: { sessionId, toolCallId, path: ref.path, bytes: chunk.length }, - }), - ) - } +/** A file-backed OutputStore writing one file per tool call into `directory`. */ +export const layerOutputStore = (options: { + readonly directory: string +}): Layer.Layer => + Layer.effect( + OutputStore, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const directory = options.directory + + const refFor = (toolCallId: ToolCallId): OutputStoreRef => + new OutputStoreRef({ toolCallId, path: join(directory, `${toolCallId}.txt`) }) + + const prepare = (toolCallId: ToolCallId): Effect.Effect => { + const ref = refFor(toolCallId) + return fs.makeDirectory(directory, { recursive: true }).pipe( + Effect.andThen(fs.writeFileString(ref.path, '', { flag: 'a' })), + Effect.as(ref), + Effect.mapError((cause) => fileOperationError({ operation: 'prepare', path: ref.path, cause })), + Effect.tapError(logStoreError), + Effect.withSpan('output_store.prepare', { attributes: { toolCallId, path: ref.path } }), + ) + } - const read = (ref: OutputStoreRef, options?: OutputStoreReadOptions): Effect.Effect => - fs.readFileString(ref.path).pipe( - Effect.map((content) => lineSlice(content, options)), - Effect.mapError((cause) => fileOperationError({ operation: 'read', path: ref.path, cause })), - Effect.tapError(logStoreError), - Effect.withSpan('output_store.read', { - attributes: { sessionId: ref.sessionId, toolCallId: ref.toolCallId, path: ref.path }, - }), - ) - - const sweep = Effect.gen(function* () { - const root = toolOutputRootFor({ foldHome }) - const now = yield* Clock.currentTimeMillis - const sessions = yield* fs - .readDirectory(root) - .pipe(Effect.catch(() => Effect.succeed>([]))) - - for (const sessionName of sessions) { - const sessionDir = join(root, sessionName) - const files = yield* fs - .readDirectory(sessionDir) - .pipe(Effect.catch(() => Effect.succeed>([]))) - - for (const file of files) { - if (!file.endsWith('.txt')) continue - const path = join(sessionDir, file) - const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.succeed(null))) - if (info === null || info.type !== 'File') continue - - const mtime = Option.match(info.mtime, { onNone: () => 0, onSome: (date) => date.getTime() }) - if (now - mtime > retentionMs) yield* fs.remove(path).pipe(Effect.ignore) - } + const append = (toolCallId: ToolCallId, chunk: string): Effect.Effect => { + const ref = refFor(toolCallId) + return fs.makeDirectory(directory, { recursive: true }).pipe( + Effect.andThen(fs.writeFileString(ref.path, chunk, { flag: 'a' })), + Effect.as(ref), + Effect.mapError((cause) => fileOperationError({ operation: 'append', path: ref.path, cause })), + Effect.tapError(logStoreError), + Effect.withSpan('output_store.append', { + attributes: { toolCallId, path: ref.path, bytes: chunk.length }, + }), + ) } - }).pipe( - Effect.catchCause((cause) => Effect.logWarning(`OutputStore sweep failed: ${Cause.pretty(cause)}`)), - Effect.withSpan('output_store.sweep', { attributes: { sessionId, directory } }), - ) - return { sessionId, directory, refFor, prepare, append, read, sweep } - }) + const read = ( + ref: OutputStoreRef, + readOptions?: OutputStoreReadOptions, + ): Effect.Effect => + fs.readFileString(ref.path).pipe( + Effect.map((content) => lineSlice(content, readOptions)), + Effect.mapError((cause) => fileOperationError({ operation: 'read', path: ref.path, cause })), + Effect.tapError(logStoreError), + Effect.withSpan('output_store.read', { + attributes: { toolCallId: ref.toolCallId, path: ref.path }, + }), + ) + + return { directory, refFor, prepare, append, read } + }), + ) -/** Layer constructor for hosts that want OutputStore in `R`. */ -export const outputStoreLayer = ( - options: MakeOutputStoreOptions, -): Layer.Layer => Layer.effect(OutputStore, makeOutputStore(options)) +/** + * Best-effort retention sweep over every session's stored output under `/tool-output`: files + * older than `retentionMs` (default 7 days) are deleted. It logs and swallows failures. + */ +export const sweepToolOutput = (options?: { + readonly foldHome?: string + readonly retentionMs?: number +}): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const root = toolOutputRootFor(options) + const retentionMs = options?.retentionMs ?? 7 * dayMs + const now = yield* Clock.currentTimeMillis + const sessions = yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => [])) + + for (const sessionName of sessions) { + const sessionDir = join(root, sessionName) + const files = yield* fs.readDirectory(sessionDir).pipe(Effect.orElseSucceed(() => [])) + + for (const file of files) { + if (!file.endsWith('.txt')) continue + const path = join(sessionDir, file) + const info = yield* fs.stat(path).pipe(Effect.orElseSucceed(() => null)) + if (info === null || info.type !== 'File') continue + + const mtime = Option.match(info.mtime, { onNone: () => 0, onSome: (date) => date.getTime() }) + if (now - mtime > retentionMs) yield* fs.remove(path).pipe(Effect.ignore) + } + } + }).pipe( + Effect.catchCause((cause) => Effect.logWarning(`OutputStore sweep failed: ${Cause.pretty(cause)}`)), + Effect.withSpan('output_store.sweep'), + ) diff --git a/packages/fold-agent/src/Session/SessionLayout.ts b/packages/fold-agent/src/Session/SessionLayout.ts index fc7e535..c1e2fd2 100644 --- a/packages/fold-agent/src/Session/SessionLayout.ts +++ b/packages/fold-agent/src/Session/SessionLayout.ts @@ -10,15 +10,25 @@ import { homedir } from 'node:os' import { join } from 'node:path' -import { SessionId, makeSessionId, usageInputTotal } from '@humanlayer/fold-core' -import type { ActiveModel, LogEntry, FoldEventLog, Ids } from '@humanlayer/fold-core' -import { Predicate, Clock, Effect, Exit, FileSystem, Match, Option, Schema, Stream } from 'effect' +import { SessionId, encodedContentText, makeSessionId, usageInputTotal } from '@humanlayer/fold-core' +import type { + ActiveModel, + AgentFinishedLogEntry, + AgentStartedLogEntry, + AssistantMessageLogEntry, + LogEntry, + FoldEventLog, + Ids, + ModelChangeLogEntry, + SessionStartedLogEntry, + SessionTitleLogEntry, + UserMessageLogEntry, +} from '@humanlayer/fold-core' +import { Predicate, Clock, Effect, Exit, FileSystem, Match, Option, Schema, Stream, Struct } from 'effect' import { jsonlEventLog } from '../EventLog/JsonlDescriptor' import { toolOutputSessionDirFor } from '../OutputStore/OutputStore' -type Mutable = { -readonly [Key in keyof Value]: Value[Key] } - /** Options shared by the layout helpers. */ export type SessionLayoutOptions = { /** The project working directory the sessions belong to. Defaults to `process.cwd()`. */ @@ -73,22 +83,22 @@ export const sessionLogPathFor = (sessionId: SessionId, options?: SessionLayoutO /** Schema for a deleted session record in the index. */ const DeletedIndexRecord = Schema.TaggedStruct('deleted', { sessionId: SessionId, - ts: Schema.Number, + ts: Schema.Finite, }) /** Schema for the session summary as persisted in the index. */ const SessionSummarySchema = Schema.Struct({ sessionId: SessionId, path: Schema.String, - mtimeMs: Schema.Number, - size: Schema.optional(Schema.Number), + mtimeMs: Schema.Finite, + size: Schema.optional(Schema.Finite), title: Schema.String, status: Schema.Literals(['ready', 'running', 'stopped', 'error']), - turns: Schema.Number, + turns: Schema.Finite, providerId: Schema.NullOr(Schema.String), modelId: Schema.NullOr(Schema.String), model: Schema.NullOr(Schema.Any), - contextTokens: Schema.NullOr(Schema.Number), + contextTokens: Schema.NullOr(Schema.Finite), mode: Schema.NullOr(Schema.String), rpi: Schema.Boolean, profile: Schema.NullOr(Schema.String), @@ -96,15 +106,17 @@ const SessionSummarySchema = Schema.Struct({ /** Schema for a summary record in the index (includes source file metadata for cache validation). */ const SummaryIndexRecord = Schema.TaggedStruct('summary', { - sourceMtimeMs: Schema.Number, - sourceSize: Schema.Number, + sourceMtimeMs: Schema.Finite, + sourceSize: Schema.Finite, summary: SessionSummarySchema, }) const SessionIndexRecordSchema = Schema.Union([SummaryIndexRecord, DeletedIndexRecord]) type SessionIndexRecord = typeof SessionIndexRecordSchema.Type -const decodeIndexRecord = Schema.decodeUnknownOption(SessionIndexRecordSchema) +const SessionIndexLine = Schema.fromJsonString(SessionIndexRecordSchema) +const decodeIndexLine = Schema.decodeUnknownOption(SessionIndexLine) +const encodeIndexLine = Schema.encodeEffect(SessionIndexLine) const appendSessionIndexRecord = ( record: SessionIndexRecord, @@ -114,9 +126,8 @@ const appendSessionIndexRecord = ( const fs = yield* FileSystem.FileSystem const directory = sessionsDirFor(options) yield* fs.makeDirectory(directory, { recursive: true }).pipe( - Effect.andThen( - fs.writeFileString(join(directory, 'index.jsonl'), `${JSON.stringify(record)}\n`, { flag: 'a' }), - ), + Effect.andThen(encodeIndexLine(record)), + Effect.flatMap((line) => fs.writeFileString(join(directory, 'index.jsonl'), `${line}\n`, { flag: 'a' })), Effect.catch((error) => Effect.logWarning( `could not append session index record at ${join(directory, 'index.jsonl')}: ${error.message}`, @@ -141,16 +152,13 @@ const loadSessionIndex = ( const latest = new Map() for (const line of contents.split('\n')) { if (line.trim().length === 0) continue - try { - const record = decodeIndexRecord(JSON.parse(line)) - if (Option.isSome(record)) latest.set(sessionIdFromIndexRecord(record.value), record.value) - } catch { - // A partial/corrupt cache row is independently recoverable from the source log. - } + // A partial/corrupt cache row is independently recoverable from the source log. + const record = decodeIndexLine(line) + if (Option.isSome(record)) latest.set(sessionIdFromIndexRecord(record.value), record.value) } return latest }), - Effect.catch(() => Effect.succeed(new Map())), + Effect.orElseSucceed(() => new Map()), ) }) @@ -162,7 +170,7 @@ const loadSessionIndex = ( export const prepareSessionLog = ( options?: SessionLayoutOptions, ): Effect.Effect< - { readonly sessionId: SessionId; readonly path: string; readonly log: FoldEventLog }, + { readonly sessionId: SessionId; readonly path: string; readonly log: FoldEventLog }, never, Ids | FileSystem.FileSystem > => @@ -184,9 +192,7 @@ export const listSessionLogs = ( const fs = yield* FileSystem.FileSystem const directory = sessionsDirFor(options) - const names = yield* fs - .readDirectory(directory) - .pipe(Effect.catch(() => Effect.succeed>([]))) + const names = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])) const refs: Array = [] for (const name of names) { @@ -195,7 +201,7 @@ export const listSessionLogs = ( if (Option.isNone(decoded)) continue const path = join(directory, name) - const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.succeed(null))) + const info = yield* fs.stat(path).pipe(Effect.orElseSucceed(() => null)) if (info === null || info.type !== 'File') continue refs.push({ @@ -210,37 +216,42 @@ export const listSessionLogs = ( }) // Type-safe entry predicates that narrow the LogEntry union. -const isSessionStarted = (entry: LogEntry): entry is Extract => +const isSessionStarted = (entry: LogEntry): entry is SessionStartedLogEntry => Predicate.isTagged(entry, 'session_started') -const isSessionTitle = (entry: LogEntry): entry is Extract => - Predicate.isTagged(entry, 'session_title') +const isSessionTitle = (entry: LogEntry): entry is SessionTitleLogEntry => Predicate.isTagged(entry, 'session_title') -const isUserMessage = (entry: LogEntry): entry is Extract => - Predicate.isTagged(entry, 'user-message') +const isUserMessage = (entry: LogEntry): entry is UserMessageLogEntry => Predicate.isTagged(entry, 'user-message') -const isAgentFinished = (entry: LogEntry): entry is Extract => - Predicate.isTagged(entry, 'agent-finished') +const isAgentFinished = (entry: LogEntry): entry is AgentFinishedLogEntry => Predicate.isTagged(entry, 'agent-finished') -type ModelCarrier = Extract +type ModelCarrier = AgentStartedLogEntry | ModelChangeLogEntry const carriesModel = (entry: LogEntry): entry is ModelCarrier => Predicate.isTagged(entry, 'agent_started') || Predicate.isTagged(entry, 'model-change') -type FinishedAssistantMessage = Extract & { - readonly finish: NonNullable['finish']> +type FinishedAssistantMessage = AssistantMessageLogEntry & { + readonly finish: NonNullable } const isFinishedAssistantMessage = (entry: LogEntry): entry is FinishedAssistantMessage => Predicate.isTagged(entry, 'assistant-message') && entry.finish !== null -const userMessageText = (entry: Extract): string => { - const content = entry.message.content - return typeof content === 'string' - ? content - : content.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('') -} +/** + * The launch fields fold records in `session_started.meta`. Meta is an open JSON record; a session + * whose meta does not match reads as having none of these fields. + */ +const FoldSessionMeta = Schema.Struct({ + mode: Schema.optionalKey(Schema.String), + rpi: Schema.optionalKey(Schema.Boolean), + profile: Schema.optionalKey(Schema.String), +}) +type FoldSessionMeta = typeof FoldSessionMeta.Type +const decodeFoldSessionMeta = Schema.decodeUnknownOption(FoldSessionMeta) + +const sessionMeta = (started: SessionStartedLogEntry | undefined): FoldSessionMeta => + started === undefined ? {} : Option.getOrElse(decodeFoldSessionMeta(started.meta), () => ({})) const computeStatus = ( - lastFinished: Extract | undefined, + lastFinished: AgentFinishedLogEntry | undefined, latestRootEntry: LogEntry | undefined, ): SessionSummary['status'] => { // No finish yet, or activity after the last finish → derive from latest activity @@ -274,11 +285,11 @@ const sessionSummary = (ref: SessionLogRef, entries: ReadonlyArray): S ? generatedTitle.title : userEntries[0] === undefined ? 'Untitled session' - : userMessageText(userEntries[0]).replace(/\s+/g, ' ').trim() + : encodedContentText(userEntries[0].message.content).replace(/\s+/g, ' ').trim() const modelEntry = rootEntries.findLast(carriesModel) const model = modelEntry?.model ?? null const latestUsage = rootEntries.findLast(isFinishedAssistantMessage) - const meta = started?.meta ?? {} + const meta = sessionMeta(started) const lastFinished = rootEntries.findLast(isAgentFinished) const latestRootEntry = rootEntries.findLast((entry) => !isSessionTitle(entry)) const status = computeStatus(lastFinished, latestRootEntry) @@ -292,9 +303,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, + mode: meta.mode ?? null, rpi: meta.rpi === true, - profile: typeof meta.profile === 'string' ? meta.profile : null, + profile: meta.profile ?? null, } } @@ -335,24 +346,11 @@ export const listSessionSummaries = ( (ref): Effect.Effect => { const cached = index.get(ref.sessionId) if (isCacheHit(cached, ref)) { - // Explicitly construct to ensure size conforms to SessionLogRef's optional semantics. - const summary = cached.summary - const cachedSummary: Mutable = { - sessionId: summary.sessionId, - path: ref.path, - mtimeMs: ref.mtimeMs, - title: summary.title, - status: summary.status, - turns: summary.turns, - providerId: summary.providerId, - modelId: summary.modelId, - model: summary.model, - contextTokens: summary.contextTokens, - mode: summary.mode, - rpi: summary.rpi, - profile: summary.profile, + // The index's summary, with the file facts (path, mtime, size) taken from the current listing. + const cachedSummary = { + ...Struct.omit(cached.summary, ['size']), + ...Struct.pick(ref, ['path', 'mtimeMs', 'size']), } - if (ref.size !== undefined) cachedSummary.size = ref.size return Effect.succeed(cachedSummary) } return loadSessionSummary(ref).pipe( @@ -417,7 +415,7 @@ export const sessionLogById = ( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const path = sessionLogPathFor(sessionId, options) - const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.succeed(null))) + const info = yield* fs.stat(path).pipe(Effect.orElseSucceed(() => null)) if (info === null || info.type !== 'File') return null diff --git a/packages/fold-agent/src/Session/TitleGenerator.ts b/packages/fold-agent/src/Session/TitleGenerator.ts index 3ee5bf0..8db7ec9 100644 --- a/packages/fold-agent/src/Session/TitleGenerator.ts +++ b/packages/fold-agent/src/Session/TitleGenerator.ts @@ -1,22 +1,18 @@ -import type { LogEntry, FoldModel } from '@humanlayer/fold-core' -import { languageModelLayerFor } from '@humanlayer/fold-core' -import { Predicate, Effect, Schema } from 'effect' +import type { AssistantMessageLogEntry, LogEntry, FoldModel, UserMessageLogEntry } from '@humanlayer/fold-core' +import { encodedContentText, languageModelLayerFor } from '@humanlayer/fold-core' +import { Predicate, Effect, Schema, type Scope } from 'effect' import { LanguageModel } from 'effect/unstable/ai' const TitleResult = Schema.Struct({ title: Schema.String }) const MAX_TRANSCRIPT_CHARS = 12_000 -type MessageEntry = Extract +type MessageEntry = UserMessageLogEntry | AssistantMessageLogEntry 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' - ? entry.message.content - : entry.message.content.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('') - -const messageText = (entry: LogEntry): string => (isMessageEntry(entry) ? extractMessageText(entry) : '') +const messageText = (entry: LogEntry): string => + isMessageEntry(entry) ? encodedContentText(entry.message.content) : '' /** Normalize model output to a single, safe title of at most six words. */ export const normalizeSessionTitle = (title: string): string => @@ -47,11 +43,11 @@ export const titleTranscript = (entries: ReadonlyArray, rootAgentId: s .slice(0, MAX_TRANSCRIPT_CHARS) /** One unlogged structured generation call. Callers persist only the resulting session_title event. */ -export const generateSessionTitle = ( +export const generateSessionTitle = ( entries: ReadonlyArray, rootAgentId: string, - model: FoldModel, -): Effect.Effect => { + model: FoldModel, +): Effect.Effect> => { const fallback = fallbackSessionTitle(entries, rootAgentId) const transcript = titleTranscript(entries, rootAgentId) if (transcript.length === 0) return Effect.succeed(fallback) diff --git a/packages/fold-agent/src/Session/ViewedChanges.ts b/packages/fold-agent/src/Session/ViewedChanges.ts index 12273eb..a5a7748 100644 --- a/packages/fold-agent/src/Session/ViewedChanges.ts +++ b/packages/fold-agent/src/Session/ViewedChanges.ts @@ -9,10 +9,12 @@ const ViewedChangeRecord = Schema.Struct({ sessionId: SessionId, changeKey: Schema.String, patchHash: Schema.String, - ts: Schema.Number, + ts: Schema.Finite, }) -const decodeViewedChangeRecord = Schema.decodeUnknownOption(ViewedChangeRecord) +const ViewedChangeLine = Schema.fromJsonString(ViewedChangeRecord) +const decodeViewedChangeLine = Schema.decodeUnknownOption(ViewedChangeLine) +const encodeViewedChangeLine = Schema.encodeEffect(ViewedChangeLine) export type ViewedPatchHashes = Readonly> @@ -30,18 +32,15 @@ export const loadViewedPatchHashes = ( const viewed: Record = {} for (const line of contents.split('\n')) { if (line.trim().length === 0) continue - try { - const record = decodeViewedChangeRecord(JSON.parse(line)) - if (Option.isSome(record) && record.value.sessionId === sessionId) { - viewed[record.value.changeKey] = record.value.patchHash - } - } catch { - // A partial record does not invalidate the rest of this derived UI index. + // A partial record does not invalidate the rest of this derived UI index. + const record = decodeViewedChangeLine(line) + if (Option.isSome(record) && record.value.sessionId === sessionId) { + viewed[record.value.changeKey] = record.value.patchHash } } return viewed }), - Effect.catch(() => Effect.succeed({})), + Effect.orElseSucceed(() => ({})), ) }) @@ -55,9 +54,9 @@ export const saveViewedPatchHash = ( const fs = yield* FileSystem.FileSystem const directory = sessionsDirFor(options) const ts = yield* Clock.currentTimeMillis - const record = { sessionId, changeKey, patchHash, ts } + const line = yield* encodeViewedChangeLine({ sessionId, changeKey, patchHash, ts }) yield* fs.makeDirectory(directory, { recursive: true }) - yield* fs.writeFileString(viewedChangesPath(options), `${JSON.stringify(record)}\n`, { flag: 'a' }) + yield* fs.writeFileString(viewedChangesPath(options), `${line}\n`, { flag: 'a' }) }).pipe( Effect.catch((error) => Effect.logWarning(`could not save viewed change for session ${sessionId}: ${error.message}`), diff --git a/packages/fold-agent/src/Skills/DiskSkills.ts b/packages/fold-agent/src/Skills/DiskSkills.ts index 744986b..f3c58fc 100644 --- a/packages/fold-agent/src/Skills/DiskSkills.ts +++ b/packages/fold-agent/src/Skills/DiskSkills.ts @@ -22,9 +22,9 @@ import { type FoldSkills, } from '@humanlayer/fold-core' import { Effect, FileSystem } from 'effect' -import { parse as parseYaml } from 'yaml' import { cwdFor } from '../Fs/DefaultFileSystem' +import { parseSkillFile, skillNameOr, type SkillDocument } from './SkillFrontmatter' /** Options for {@link skillsFromDisk}. */ export type DiskSkillsOptions = { @@ -39,11 +39,11 @@ export type DiskSkillsOptions = { const isDirectory = (fs: FileSystem.FileSystem, path: string): Effect.Effect => fs.stat(path).pipe( Effect.map((info) => info.type === 'Directory'), - Effect.catch(() => Effect.succeed(false)), + Effect.orElseSucceed(() => false), ) const fileExists = (fs: FileSystem.FileSystem, path: string): Effect.Effect => - fs.exists(path).pipe(Effect.catch(() => Effect.succeed(false))) + fs.exists(path).pipe(Effect.orElseSucceed(() => false)) /** Walk up from `cwd` to the filesystem root looking for a `.git` entry (worktrees keep a file). */ const findGitRoot = (fs: FileSystem.FileSystem, cwd: string): Effect.Effect => @@ -57,53 +57,12 @@ const findGitRoot = (fs: FileSystem.FileSystem, cwd: string): Effect.Effect => - typeof value === 'object' && value !== null && !Array.isArray(value) - -/** 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 => { - // 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') - if (!content.startsWith('---')) return null - - const endIndex = content.indexOf('\n---', 3) - if (endIndex === -1) return null - - const rawYaml = content.slice(4, endIndex) - const body = content.slice(endIndex + 4).trim() - - try { - const parsed: unknown = parseYaml(rawYaml) - if (!isRecord(parsed)) return null - return { frontmatter: parsed, body } - } catch { - return null - } -} - -/** Parse and validate one SKILL.md; null (with a logged warning) when it cannot be loaded. */ -const loadSkillFile = (fs: FileSystem.FileSystem, skillFilePath: string): Effect.Effect => +/** Apply the Agent Skills spec to decoded frontmatter; null (with a logged warning) when it fails. */ +const validateSkill = (skillFilePath: string, { frontmatter, body }: SkillDocument): Effect.Effect => Effect.gen(function* () { - const raw = yield* fs.readFileString(skillFilePath).pipe(Effect.catch(() => Effect.succeed(null))) - if (raw === null) { - yield* Effect.logWarning(`skill skipped (unreadable): ${skillFilePath}`) - return null - } - - const parsed = extractFrontmatter(raw) - if (parsed === null) { - yield* Effect.logWarning(`skill skipped (missing or invalid frontmatter): ${skillFilePath}`) - return null - } - 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 = skillNameOr(frontmatter, basename(directory)) + const description = frontmatter.description ?? '' const problem = skillNameProblem(name) ?? skillDescriptionProblem(description) if (problem !== null) { @@ -111,9 +70,47 @@ const loadSkillFile = (fs: FileSystem.FileSystem, skillFilePath: string): Effect return null } - return { name, description, content: parsed.body, baseDir: directory } + return { name, description, content: body, baseDir: directory } }) +/** Parse and validate one SKILL.md; null (with a logged warning) when it cannot be loaded. */ +const loadSkillFile = (fs: FileSystem.FileSystem, skillFilePath: string): Effect.Effect => + fs.readFileString(skillFilePath).pipe( + Effect.catchTag('PlatformError', (error) => + Effect.as(Effect.logWarning(`skill skipped (unreadable): ${skillFilePath}`, error), null), + ), + Effect.flatMap((raw) => + raw === null + ? Effect.succeed(null) + : parseSkillFile(raw).pipe( + Effect.flatMap((document) => validateSkill(skillFilePath, document)), + Effect.catchTags({ + SkillFrontmatterMissing: (error) => + Effect.as( + Effect.logWarning(`skill skipped (${error.message}): ${skillFilePath}`), + null, + ), + SkillFrontmatterInvalidYaml: (error) => + Effect.as( + Effect.logWarning( + `skill skipped (invalid frontmatter YAML): ${skillFilePath}`, + error, + ), + null, + ), + SkillFrontmatterInvalidFields: (error) => + Effect.as( + Effect.logWarning( + `skill skipped (invalid frontmatter fields): ${skillFilePath}`, + error, + ), + null, + ), + }), + ), + ), + ) + /** * Scan one root for skills, pi-style: a directory containing SKILL.md is a skill root (no deeper * recursion); other directories recurse; dotfiles and node_modules are skipped. Within one root the @@ -142,7 +139,7 @@ const scanRoot = (fs: FileSystem.FileSystem, root: string): Effect.Effect Effect.succeed([]))) + const entries = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])) for (const entry of [...entries].sort()) { if (entry.startsWith('.') || entry === 'node_modules') continue const childPath = join(directory, entry) @@ -212,4 +209,5 @@ export const makeDiskSkillSource = ( }) /** Configure an agent's skills from disk (the standard chain + optional extra roots). */ -export const skillsFromDisk = (options?: DiskSkillsOptions): FoldSkills => skillSource(makeDiskSkillSource(options)) +export const skillsFromDisk = (options?: DiskSkillsOptions): FoldSkills => + skillSource(makeDiskSkillSource(options)) diff --git a/packages/fold-agent/src/Skills/SkillFrontmatter.ts b/packages/fold-agent/src/Skills/SkillFrontmatter.ts new file mode 100644 index 0000000..a9df242 --- /dev/null +++ b/packages/fold-agent/src/Skills/SkillFrontmatter.ts @@ -0,0 +1,95 @@ +/** + * Shared SKILL.md parsing for every skill loader (disk, Codex, Grok): split the leading `---` YAML + * block from the body, parse the YAML, and decode the fields Fold reads. Loader policy (name + * validation, required description, namespaces, fallbacks) stays with each loader. + */ +import { Data, Effect, Option, Schema } from 'effect' +import { parse as parseYaml } from 'yaml' + +/** + * The SKILL.md frontmatter fields Fold reads. Keys may be absent; a bare `name:` decodes as `null`. + * Other keys (`allowed-tools`, `metadata`, ...) are ignored. + */ +export const SkillFrontmatter = Schema.Struct({ + name: Schema.optionalKey(Schema.NullOr(Schema.String)), + description: Schema.optionalKey(Schema.NullOr(Schema.String)), +}) +export type SkillFrontmatter = typeof SkillFrontmatter.Type + +/** A SKILL.md split into its raw YAML frontmatter (if any) and trimmed body, newlines normalized. */ +export type SkillFileParts = { + readonly frontmatter: Option.Option + readonly body: string +} + +/** A SKILL.md with decoded frontmatter. */ +export type SkillDocument = { + readonly frontmatter: SkillFrontmatter + readonly body: string +} + +/** The SKILL.md has no leading `---` YAML block. */ +export class SkillFrontmatterMissing extends Data.TaggedError('SkillFrontmatterMissing')<{ + readonly message: string +}> {} + +/** The frontmatter block is not valid YAML. */ +export class SkillFrontmatterInvalidYaml extends Data.TaggedError('SkillFrontmatterInvalidYaml')<{ + readonly message: string + readonly cause: unknown +}> {} + +/** The frontmatter parsed but its fields have the wrong types (or it is not a mapping). */ +export class SkillFrontmatterInvalidFields extends Data.TaggedError('SkillFrontmatterInvalidFields')<{ + readonly message: string + readonly cause: Schema.SchemaError +}> {} + +export type SkillFrontmatterError = + | SkillFrontmatterMissing + | SkillFrontmatterInvalidYaml + | SkillFrontmatterInvalidFields + +const decodeFields = Schema.decodeUnknownEffect(SkillFrontmatter) + +/** + * Split a SKILL.md into frontmatter and body. CRLF and CR normalize to LF first so a trailing `\r` + * never leaks into a field. Frontmatter opens with a first line of exactly `---` and closes at the + * next line starting with `---`; without both, the whole file is the body. + */ +export const splitSkillFile = (raw: string): SkillFileParts => { + const content = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + if (!content.startsWith('---\n')) return { frontmatter: Option.none(), body: content.trim() } + const end = content.indexOf('\n---', 3) + if (end === -1) return { frontmatter: Option.none(), body: content.trim() } + return { frontmatter: Option.some(content.slice(4, end)), body: content.slice(end + 4).trim() } +} + +/** Parse raw frontmatter YAML and decode it into {@link SkillFrontmatter}. */ +export const decodeSkillFrontmatter = ( + yaml: string, +): Effect.Effect => + Effect.try({ + try: () => parseYaml(yaml), + catch: (cause) => new SkillFrontmatterInvalidYaml({ message: String(cause), cause }), + }).pipe( + Effect.flatMap(decodeFields), + Effect.catchTag('SchemaError', (cause) => + Effect.fail(new SkillFrontmatterInvalidFields({ message: cause.message, cause })), + ), + ) + +/** Parse a SKILL.md that must carry frontmatter; fails with reason `missing` when it has none. */ +export const parseSkillFile = (raw: string): Effect.Effect => { + const { frontmatter, body } = splitSkillFile(raw) + return Option.match(frontmatter, { + onNone: () => Effect.fail(new SkillFrontmatterMissing({ message: 'missing YAML frontmatter' })), + onSome: (yaml) => Effect.map(decodeSkillFrontmatter(yaml), (fields) => ({ frontmatter: fields, body })), + }) +} + +/** The frontmatter `name` when it is a non-empty string, else `fallback` (the skill directory name). */ +export const skillNameOr = (frontmatter: SkillFrontmatter, fallback: string): string => { + const name = frontmatter.name ?? '' + return name.length > 0 ? name : fallback +} diff --git a/packages/fold-agent/src/Tools/ApplyPatchTool.ts b/packages/fold-agent/src/Tools/ApplyPatchTool.ts index 8dfcd35..eba56ef 100644 --- a/packages/fold-agent/src/Tools/ApplyPatchTool.ts +++ b/packages/fold-agent/src/Tools/ApplyPatchTool.ts @@ -10,7 +10,6 @@ import { computePatch, defineTool, parsePatch, - platformToolDependencies, ToolResultFailure, ToolResultText, type PatchOp, @@ -22,7 +21,7 @@ import { withFileMutationLocks } from '../Fs/MutationQueue' import { resolveToCwd } from '../Fs/PathResolve' import { platformErrorMessage } from './ReadTool' -const verificationFailed = (detail: string): { message: string } => ({ +const verificationFailed = (detail: string) => ({ message: `apply_patch verification failed: ${detail}`, }) @@ -31,10 +30,9 @@ const opPaths = (op: PatchOp): ReadonlyArray => Predicate.isTagged(op, 'update') && op.movePath !== null ? [op.path, op.movePath] : [op.path] /** Build the apply_patch tool over the ambient FileSystem service. */ -export const applyPatchTool = (options?: { readonly cwd?: string }): FoldTool => +export const applyPatchTool = (options?: { readonly cwd?: string }): FoldTool => defineTool({ ...applyPatchToolContract, - dependencies: platformToolDependencies, handler: (params) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem @@ -59,9 +57,7 @@ export const applyPatchTool = (options?: { readonly cwd?: string }): FoldTool => if (Predicate.isTagged(op, 'add')) continue if (!files.has(op.path)) { const source = yield* resolvePath(op.path) - const content = yield* fs - .readFileString(source) - .pipe(Effect.catch(() => Effect.succeed(null))) + const content = yield* fs.readFileString(source).pipe(Effect.orElseSucceed(() => null)) files.set(op.path, content) } } diff --git a/packages/fold-agent/src/Tools/BashTool.ts b/packages/fold-agent/src/Tools/BashTool.ts index 0252c67..5a4f24f 100644 --- a/packages/fold-agent/src/Tools/BashTool.ts +++ b/packages/fold-agent/src/Tools/BashTool.ts @@ -11,7 +11,7 @@ * degrade to inline notes, never crash the run. Non-zero exit and timeout are typed model-visible * failures carrying the accumulated output; signal-killed commands are successes (pi semantics). */ -import { homedir, tmpdir } from 'node:os' +import { homedir } from 'node:os' import { defaultMaxBytes, @@ -19,7 +19,6 @@ import { formatSize, CurrentToolCall, InterruptNote, - platformToolDependencies, ToolEvents, ToolResultFailure, ToolResultText, @@ -27,11 +26,11 @@ import { utf8ByteLength, type FoldTool, } from '@humanlayer/fold-core' -import { Data, Duration, Effect, Fiber, FileSystem, Option, Path, Random, Ref, Schema, Semaphore, Stream } from 'effect' +import { Data, Duration, Effect, Fiber, FileSystem, Option, Path, Ref, Schema, Semaphore, Stream } from 'effect' import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process' import { resolveToCwd } from '../Fs/PathResolve' -import type { OutputStoreService } from '../OutputStore/OutputStore' +import { OutputStore } from '../OutputStore/OutputStore' import { platformErrorMessage } from './ReadTool' /** @@ -46,15 +45,15 @@ export const BashOutputDelta = Schema.Struct({ }) export type BashOutputDelta = typeof BashOutputDelta.Type -const isBashOutputDelta = Schema.is(BashOutputDelta) +const decodeBashOutputDeltaOption = Schema.decodeUnknownOption(BashOutputDelta) /** Decode one tool-progress payload as a bash output delta; null when it is something else. */ -export const decodeBashOutputDelta = (payload: unknown): BashOutputDelta | null => - isBashOutputDelta(payload) ? payload : null +export const decodeBashOutputDelta = (payload: Schema.Json): BashOutputDelta | null => + Option.getOrNull(decodeBashOutputDeltaOption(payload)) const BashParameters = Schema.Struct({ command: Schema.String.annotate({ description: 'Bash command to execute' }), - timeout_ms: Schema.optionalKey(Schema.Number).annotate({ + timeout_ms: Schema.optionalKey(Schema.Finite).annotate({ description: 'Timeout in milliseconds (default 120000)', }), workdir: Schema.optionalKey(Schema.String).annotate({ @@ -93,10 +92,6 @@ const omittedNonTextOutputMessage = (stream: 'stdout' | 'stderr') => export type BashToolOptions = { /** Working directory for resolving relative paths. Defaults to `process.cwd()` at call time. */ readonly cwd?: string - /** Base directory for spill files holding full untruncated output. Defaults to `os.tmpdir()`. */ - readonly spillDir?: string - /** Deterministic per-session output store. When absent, bash uses the legacy temp spill file. */ - readonly outputStore?: OutputStoreService /** Environment entries inherited by every Bash subprocess created by this tool. */ readonly processEnvironment?: Readonly> } @@ -225,19 +220,31 @@ const killWithEscalation = (handle: ChildProcessSpawner.ChildProcessHandle): Eff Effect.gen(function* () { const graceful = yield* handle.kill({ killSignal: 'SIGTERM' }).pipe( Effect.timeoutOption(killGrace), - Effect.catch(() => Effect.succeed(Option.some(undefined))), + Effect.orElseSucceed(() => Option.some(undefined)), ) if (Option.isNone(graceful)) { yield* handle.kill({ killSignal: 'SIGKILL' }).pipe( Effect.timeoutOption(Duration.seconds(5)), - Effect.catch(() => Effect.succeed(Option.none())), + Effect.orElseSucceed(() => Option.none()), ) } }) +const validateTimeout = (timeoutMilliseconds: number): Effect.Effect => { + if (!Number.isFinite(timeoutMilliseconds) || timeoutMilliseconds <= 0) { + return Effect.fail({ message: 'Invalid timeout_ms: must be a finite number of milliseconds' }) + } + if (timeoutMilliseconds > maxTimeoutMilliseconds) { + return Effect.fail({ message: `Invalid timeout_ms: maximum is ${maxTimeoutMilliseconds} milliseconds` }) + } + return Effect.succeed(timeoutMilliseconds) +} + /** Build the bash tool. Runs real processes; only spill-file IO goes through the FileSystem seam. */ -export const bashTool = (options?: BashToolOptions): FoldTool => +export const bashTool = ( + options?: BashToolOptions, +): FoldTool => defineTool({ name: 'bash', description: @@ -254,7 +261,6 @@ export const bashTool = (options?: BashToolOptions): FoldTool => parameters: BashParameters, success: BashSuccess, failure: BashFailure, - dependencies: platformToolDependencies, handler: (params) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem @@ -263,20 +269,9 @@ export const bashTool = (options?: BashToolOptions): FoldTool => const configuredCwd = yield* resolveToCwd(options?.cwd ?? process.cwd(), process.cwd()) const cwd = params.workdir === undefined ? configuredCwd : yield* resolveToCwd(params.workdir, configuredCwd) - const timeoutMilliseconds = params.timeout_ms ?? defaultTimeoutMilliseconds - - if (!Number.isFinite(timeoutMilliseconds) || timeoutMilliseconds <= 0) { - return yield* Effect.fail({ - message: 'Invalid timeout_ms: must be a finite number of milliseconds', - }) - } - if (timeoutMilliseconds > maxTimeoutMilliseconds) { - return yield* Effect.fail({ - message: `Invalid timeout_ms: maximum is ${maxTimeoutMilliseconds} milliseconds`, - }) - } + const timeoutMilliseconds = yield* validateTimeout(params.timeout_ms ?? defaultTimeoutMilliseconds) - if (!(yield* fs.exists(cwd).pipe(Effect.catch(() => Effect.succeed(false))))) { + if (!(yield* fs.exists(cwd).pipe(Effect.orElseSucceed(() => false)))) { return yield* Effect.fail({ message: `Working directory does not exist: ${cwd}\nCannot execute bash commands.`, }) @@ -285,33 +280,19 @@ export const bashTool = (options?: BashToolOptions): FoldTool => const events = yield* ToolEvents const interruptNote = yield* InterruptNote const currentToolCall = yield* CurrentToolCall - const outputStore = options?.outputStore - const spillRef = outputStore?.refFor(currentToolCall.toolCallId) - const spillToken = `${(yield* Random.next).toString(36).slice(2)}${(yield* Random.next).toString(36).slice(2)}` - const spillPath = - spillRef?.path ?? pathService.join(options?.spillDir ?? tmpdir(), `fold-bash-${spillToken}.log`) + // The full, untruncated output streams into the host's output store, one file per tool call. + const outputStore = yield* OutputStore + const spillPath = outputStore.refFor(currentToolCall.toolCallId).path const accumulator = yield* makeAccumulator({ spillPath, writeSpill: (path, chunk) => - outputStore === undefined - ? fs - .writeFileString(path, chunk, { flag: 'a' }) - .pipe( - Effect.catch((error) => - Effect.logWarning( - `could not persist bash output at ${path}: ${error.message}`, - ), - ), - ) - : outputStore - .append(currentToolCall.toolCallId, chunk) - .pipe( - Effect.catch((error) => - Effect.logWarning( - `could not persist bash output at ${path}: ${error.message}`, - ), - ), - ), + outputStore + .append(currentToolCall.toolCallId, chunk) + .pipe( + Effect.catch((error) => + Effect.logWarning(`could not persist bash output at ${path}: ${error.message}`), + ), + ), }) // If this call is interrupted, the synthetic tool result points the model at the partial @@ -402,7 +383,7 @@ export const bashTool = (options?: BashToolOptions): FoldTool => // null = killed by a signal (no exit code): pi treats that as success, not an error. const awaitExit: Effect.Effect = handle.exitCode.pipe( Effect.map((code) => Number(code)), - Effect.catch(() => Effect.succeed(null)), + Effect.orElseSucceed(() => null), ) const firstExit = yield* awaitExit.pipe(Effect.timeoutOption(Duration.millis(timeoutMilliseconds))) diff --git a/packages/fold-agent/src/Tools/CodingTools.ts b/packages/fold-agent/src/Tools/CodingTools.ts index 881635a..b91670c 100644 --- a/packages/fold-agent/src/Tools/CodingTools.ts +++ b/packages/fold-agent/src/Tools/CodingTools.ts @@ -4,22 +4,55 @@ * ToolsetResolver advertises the family-appropriate subset per request (claude-family sees write/edit, * gpt/codex-family sees apply_patch) and re-resolves automatically when the session switches models. */ +import * as NodeServices from '@effect/platform-node/NodeServices' import type { FoldTool } from '@humanlayer/fold-core' +import { type FileSystem, Layer, type Path } from 'effect' +import { FetchHttpClient, type HttpClient } from 'effect/unstable/http' +import type { ChildProcessSpawner } from 'effect/unstable/process' +import { layerOutputStore, type OutputStore } from '../OutputStore/OutputStore' import { bashTool, type BashToolOptions } from './BashTool' import { fileTools } from './FileTools' +import { Photon } from './Image/Photon' import { webTools, type WebToolsOptions } from './WebTools' /** Options for {@link codingTools}: the shared cwd plus bash output-spill configuration. */ -export type CodingToolsOptions = Pick & - WebToolsOptions +export type CodingToolsOptions = Pick & WebToolsOptions /** * The standard coding toolset: read, write, edit, apply_patch, bash, and web tools. The model-family policy decides * which editing tools are advertised per request; installing the union is the intended setup. */ -export const codingTools = (options?: CodingToolsOptions): ReadonlyArray => [ - ...fileTools(options), - bashTool(options), - ...webTools(options), -] +export const codingTools = ( + options?: CodingToolsOptions, +): ReadonlyArray< + FoldTool< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + > +> => [...fileTools(options), bashTool(options), ...webTools(options)] + +/** + * Every coding-tool host service on Node, for hosts that start sessions themselves: the Node platform, + * a fetch-backed HTTP client, photon, and an output store writing into `outputDirectory`. + */ +export const layerCodingToolServices = (options: { + readonly outputDirectory: string +}): Layer.Layer< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient +> => + Layer.mergeAll( + NodeServices.layer, + FetchHttpClient.layer, + Photon.layer, + layerOutputStore({ directory: options.outputDirectory }).pipe(Layer.provide(NodeServices.layer)), + ) diff --git a/packages/fold-agent/src/Tools/EditTool.ts b/packages/fold-agent/src/Tools/EditTool.ts index 68fac0b..e02d600 100644 --- a/packages/fold-agent/src/Tools/EditTool.ts +++ b/packages/fold-agent/src/Tools/EditTool.ts @@ -9,22 +9,20 @@ import { applyEdits, editToolContract, normalizeEditInput, - platformToolDependencies, ToolResultFailure, ToolResultText, type FoldTool, } from '@humanlayer/fold-core' -import { Effect, FileSystem } from 'effect' +import { Effect, FileSystem, type Path } from 'effect' import { withFileMutationLock } from '../Fs/MutationQueue' import { resolveToCwd } from '../Fs/PathResolve' import { errnoCode, platformErrorMessage } from './ReadTool' /** Build the edit tool over the ambient FileSystem service. */ -export const editTool = (options?: { readonly cwd?: string }): FoldTool => +export const editTool = (options?: { readonly cwd?: string }): FoldTool => defineTool({ ...editToolContract, - dependencies: platformToolDependencies, handler: (params) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem diff --git a/packages/fold-agent/src/Tools/FileTools.ts b/packages/fold-agent/src/Tools/FileTools.ts index 27111f4..d437aa8 100644 --- a/packages/fold-agent/src/Tools/FileTools.ts +++ b/packages/fold-agent/src/Tools/FileTools.ts @@ -1,10 +1,9 @@ /** * This file bundles the file tools - read, write, edit, and apply_patch - over the ambient FileSystem. * It is also the `@humanlayer/fold-agent/tools/files` entry, for hosts such as Cloudflare Workers that - * want these tools without the rest of fold-agent (bash, providers, config). + * want these tools without the rest of fold-agent (bash, providers, config). The read tool needs the + * `Photon` image service; where its WASM module cannot load, images are omitted rather than failing. */ -import type { FoldTool } from '@humanlayer/fold-core' - import { applyPatchTool } from './ApplyPatchTool' import { editTool } from './EditTool' import { readTool } from './ReadTool' @@ -12,11 +11,12 @@ import { writeTool } from './WriteTool' export { applyPatchTool } from './ApplyPatchTool' export { editTool } from './EditTool' +export { Photon } from './Image/Photon' export { readTool } from './ReadTool' export { writeTool } from './WriteTool' /** read, write, edit, and apply_patch, resolving relative paths against `cwd`. */ -export const fileTools = (options?: { readonly cwd?: string }): ReadonlyArray => [ +export const fileTools = (options?: { readonly cwd?: string }) => [ readTool(options), writeTool(options), editTool(options), diff --git a/packages/fold-agent/src/Tools/Image/ExifOrientation.ts b/packages/fold-agent/src/Tools/Image/ExifOrientation.ts index bc63433..11f6c60 100644 --- a/packages/fold-agent/src/Tools/Image/ExifOrientation.ts +++ b/packages/fold-agent/src/Tools/Image/ExifOrientation.ts @@ -3,7 +3,13 @@ * WebP EXIF chunks and apply the corresponding flips/rotations with photon so resized images render * upright. Flips mutate in place; rotations return a new image (the caller frees the old one). */ -import type { Photon, PhotonImage } from './Photon' +import { Match, Schema } from 'effect' + +import type { PhotonImage, PhotonModule } from './Photon' + +const Orientation = Schema.Literals([1, 2, 3, 4, 5, 6, 7, 8]) +type Orientation = typeof Orientation.Type +const isOrientation = Schema.is(Orientation) const hasExifHeader = (bytes: Uint8Array, offset: number): boolean => bytes[offset] === 0x45 && @@ -13,7 +19,7 @@ const hasExifHeader = (bytes: Uint8Array, offset: number): boolean => bytes[offset + 4] === 0x00 && bytes[offset + 5] === 0x00 -const readOrientationFromTiff = (bytes: Uint8Array, tiffStart: number): number => { +const readOrientationFromTiff = (bytes: Uint8Array, tiffStart: number): Orientation => { if (tiffStart + 8 > bytes.length) return 1 const littleEndian = (((bytes[tiffStart] ?? 0) << 8) | (bytes[tiffStart + 1] ?? 0)) === 0x4949 @@ -43,7 +49,7 @@ const readOrientationFromTiff = (bytes: Uint8Array, tiffStart: number): number = if (entryPosition + 12 > bytes.length) return 1 if (read16(entryPosition) === 0x0112) { const value = read16(entryPosition + 8) - return value >= 1 && value <= 8 ? value : 1 + return isOrientation(value) ? value : 1 } } @@ -104,7 +110,7 @@ const findWebpTiffOffset = (bytes: Uint8Array): number => { } /** Read the EXIF orientation (1-8) from JPEG or WebP bytes; 1 when absent or unreadable. */ -export const exifOrientation = (bytes: Uint8Array): number => { +export const exifOrientation = (bytes: Uint8Array): Orientation => { let tiffOffset = -1 if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8) { @@ -128,7 +134,7 @@ export const exifOrientation = (bytes: Uint8Array): number => { type DstIndex = (x: number, y: number, width: number, height: number) => number -const rotate90 = (photon: Photon, image: PhotonImage, dstIndex: DstIndex): PhotonImage => { +const rotate90 = (photon: PhotonModule, image: PhotonImage, dstIndex: DstIndex): PhotonImage => { const width = image.get_width() const height = image.get_height() const source = image.get_raw_pixels() @@ -149,33 +155,37 @@ const rotate90 = (photon: Photon, image: PhotonImage, dstIndex: DstIndex): Photo } /** Apply the EXIF orientation to a decoded image. Rotations return a NEW image; flips mutate. */ -export const applyExifOrientation = (photon: Photon, image: PhotonImage, originalBytes: Uint8Array): PhotonImage => { - switch (exifOrientation(originalBytes)) { - case 2: +export const applyExifOrientation = ( + photon: PhotonModule, + image: PhotonImage, + originalBytes: Uint8Array, +): PhotonImage => + Match.value(exifOrientation(originalBytes)).pipe( + Match.when(1, () => image), + Match.when(2, () => { photon.fliph(image) return image - case 3: + }), + Match.when(3, () => { photon.fliph(image) photon.flipv(image) return image - case 4: + }), + Match.when(4, () => { photon.flipv(image) return image - case 5: { + }), + Match.when(5, () => { const rotated = rotate90(photon, image, (x, y, _width, height) => x * height + (height - 1 - y)) photon.fliph(rotated) return rotated - } - case 6: - return rotate90(photon, image, (x, y, _width, height) => x * height + (height - 1 - y)) - case 7: { + }), + Match.when(6, () => rotate90(photon, image, (x, y, _width, height) => x * height + (height - 1 - y))), + Match.when(7, () => { const rotated = rotate90(photon, image, (x, y, width, height) => (width - 1 - x) * height + y) photon.fliph(rotated) return rotated - } - case 8: - return rotate90(photon, image, (x, y, width, height) => (width - 1 - x) * height + y) - default: - return image - } -} + }), + Match.when(8, () => rotate90(photon, image, (x, y, width, height) => (width - 1 - x) * height + y)), + Match.exhaustive, + ) diff --git a/packages/fold-agent/src/Tools/Image/Photon.ts b/packages/fold-agent/src/Tools/Image/Photon.ts index 33bc27a..b4693b3 100644 --- a/packages/fold-agent/src/Tools/Image/Photon.ts +++ b/packages/fold-agent/src/Tools/Image/Photon.ts @@ -1,26 +1,118 @@ /** - * This file lazily loads the photon WASM image library (pi's choice: pure Rust-to-WASM, no native - * addon). Load failures degrade to null so the read tool can fall back to its "image omitted" note - * instead of crashing the run. + * This file wraps the photon WASM image library (pi's choice: pure Rust-to-WASM, no native addon) as + * the `Photon` service. Its layer loads the module once; a load failure does not fail the layer, it + * yields a service whose operations fail with `PhotonError` reason `unavailable`, so the read tool + * can fall back to its "image omitted" note instead of crashing the run. Every photon image an + * operation creates is freed when the caller's scope closes. */ -import type * as PhotonModule from '@silvia-odwyer/photon-node' +import type * as PhotonNode from '@silvia-odwyer/photon-node' +import { Context, Data, Effect, Layer, type Scope } from 'effect' -export type Photon = typeof PhotonModule +import { applyExifOrientation } from './ExifOrientation' + +export type PhotonModule = typeof PhotonNode /** The subset of photon's PhotonImage surface the resize pipeline touches. */ -export type PhotonImage = InstanceType +export type PhotonImage = InstanceType + +/** Why a photon operation failed; `unavailable` means the WASM module could not be loaded. */ +export type PhotonFailureReason = 'unavailable' | 'decode-failed' | 'orient-failed' | 'resize-failed' | 'encode-failed' + +export class PhotonError extends Data.TaggedError('PhotonError')<{ + readonly reason: PhotonFailureReason + readonly cause: unknown +}> {} + +type PhotonService = { + /** Decode image bytes; the image is freed when the scope closes. */ + readonly decode: (bytes: Uint8Array) => Effect.Effect + /** Apply the EXIF orientation of `originalBytes`; a rotated copy is freed when the scope closes. */ + readonly orient: ( + image: PhotonImage, + originalBytes: Uint8Array, + ) => Effect.Effect + /** Lanczos3 resize into a new image, freed when the scope closes. */ + readonly resize: ( + image: PhotonImage, + width: number, + height: number, + ) => Effect.Effect + readonly encodePng: (image: PhotonImage) => Effect.Effect + readonly encodeJpeg: (image: PhotonImage, quality: number) => Effect.Effect +} + +const freeOnRelease = (image: PhotonImage): Effect.Effect => Effect.sync(() => image.free()) -let photon: Photon | null | undefined +const fromModule = (photon: PhotonModule): PhotonService => ({ + decode: Effect.fn('Photon.decode')((bytes: Uint8Array) => + Effect.acquireRelease( + Effect.try({ + try: () => photon.PhotonImage.new_from_byteslice(bytes), + catch: (cause) => new PhotonError({ reason: 'decode-failed', cause }), + }), + freeOnRelease, + ), + ), + orient: Effect.fn('Photon.orient')((image: PhotonImage, originalBytes: Uint8Array) => + Effect.acquireRelease( + Effect.try({ + try: () => applyExifOrientation(photon, image, originalBytes), + catch: (cause) => new PhotonError({ reason: 'orient-failed', cause }), + }), + // Flips mutate `image` in place; only a rotated copy is a new image this scope owns. + (oriented) => (oriented === image ? Effect.void : freeOnRelease(oriented)), + ), + ), + resize: Effect.fn('Photon.resize')((image: PhotonImage, width: number, height: number) => + Effect.acquireRelease( + Effect.try({ + try: () => photon.resize(image, width, height, photon.SamplingFilter.Lanczos3), + catch: (cause) => new PhotonError({ reason: 'resize-failed', cause }), + }), + freeOnRelease, + ), + ), + encodePng: Effect.fn('Photon.encodePng')((image: PhotonImage) => + Effect.try({ + try: () => image.get_bytes(), + catch: (cause) => new PhotonError({ reason: 'encode-failed', cause }), + }), + ), + encodeJpeg: Effect.fn('Photon.encodeJpeg')((image: PhotonImage, quality: number) => + Effect.try({ + try: () => image.get_bytes_jpeg(quality), + catch: (cause) => new PhotonError({ reason: 'encode-failed', cause }), + }), + ), +}) -/** Load photon once per process; null when the WASM module cannot be loaded. */ -export const loadPhoton = async (): Promise => { - if (photon !== undefined) return photon +/** A service whose every operation fails with the load failure. */ +const unavailable = (error: PhotonError): PhotonService => ({ + decode: () => Effect.fail(error), + orient: () => Effect.fail(error), + resize: () => Effect.fail(error), + encodePng: () => Effect.fail(error), + encodeJpeg: () => Effect.fail(error), +}) - try { - photon = await import('@silvia-odwyer/photon-node') - } catch { - photon = null - } +export class Photon extends Context.Service()('fold-agent/Tools/Image/Photon') { + /** Load photon once; a load failure yields a service whose operations fail with `unavailable`. */ + static readonly layer = Layer.effect( + Photon, + Effect.tryPromise({ + try: () => import('@silvia-odwyer/photon-node'), + catch: (cause) => new PhotonError({ reason: 'unavailable', cause }), + }).pipe( + Effect.map(fromModule), + Effect.catchTag('PhotonError', (error) => + Effect.logWarning('photon image library unavailable; images will be omitted', error.cause).pipe( + Effect.as(unavailable(error)), + ), + ), + ), + ) - return photon + /** A photon that failed to load with `cause`: every operation fails with reason `unavailable`. */ + static readonly layerUnavailable = (cause: unknown): Layer.Layer => + Layer.succeed(Photon, unavailable(new PhotonError({ reason: 'unavailable', cause }))) } diff --git a/packages/fold-agent/src/Tools/Image/Process.ts b/packages/fold-agent/src/Tools/Image/Process.ts index be2dd95..9c74427 100644 --- a/packages/fold-agent/src/Tools/Image/Process.ts +++ b/packages/fold-agent/src/Tools/Image/Process.ts @@ -2,11 +2,12 @@ * This file ports pi's image resize/normalize pipeline for the read tool (D18): EXIF-oriented decode, * pass-through when already within limits, Lanczos3 resize to 2000x2000, a PNG-then-JPEG-quality * encode ladder under the 4.5MB base64 cap (headroom below Anthropic's 5MB inline limit), a 0.75 - * downscale loop as last resort, and BMP-to-PNG conversion. All failures degrade to model-visible - * "[Image omitted: ...]" notes rather than errors. + * downscale loop as last resort, and BMP-to-PNG conversion. Failures are `ImageProcessError`s; callers + * turn them into pi's model-visible "[Image omitted: ...]" notes with `imageOmittedNote`. */ -import { applyExifOrientation } from './ExifOrientation' -import { loadPhoton } from './Photon' +import { Data, Effect, Match, Option } from 'effect' + +import { Photon, type PhotonError, type PhotonFailureReason, type PhotonImage } from './Photon' /** 4.5MB of base64 payload: headroom below Anthropic's 5MB inline image limit (pi parity). */ export const defaultMaxImageBytes = 4.5 * 1024 * 1024 @@ -14,8 +15,35 @@ export const defaultMaxImageBytes = 4.5 * 1024 * 1024 const maxDimension = 2000 const jpegQualityLadder = [80, 85, 70, 55, 40] +const inlineSupportedMimeTypes = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']) + const toBase64 = (bytes: Uint8Array): string => Buffer.from(bytes).toString('base64') +/** + * Image processing failed. `stage` says which step failed (converting an unsupported container to PNG, + * or fitting the image under the inline limits); `too-large` means no encoding fit even at 1x1. + */ +export class ImageProcessError extends Data.TaggedError('ImageProcessError')<{ + readonly stage: 'convert' | 'resize' + readonly reason: PhotonFailureReason | 'too-large' + readonly cause?: PhotonError +}> {} + +/** pi's verbatim model-visible note for an image that could not be processed. */ +export const imageOmittedNote = (error: ImageProcessError): string => + Match.value(error.stage).pipe( + Match.when('convert', () => '[Image omitted: could not be converted to a supported inline image format.]'), + Match.when('resize', () => '[Image omitted: could not be resized below the inline image size limit.]'), + Match.exhaustive, + ) + +/** An image ready for inline delivery, plus model-visible hints about conversion/resizing. */ +export type ProcessedImage = { + readonly data: string + readonly mimeType: string + readonly hints: ReadonlyArray +} + type ResizedImage = { readonly data: string readonly mimeType: string @@ -26,24 +54,52 @@ type ResizedImage = { readonly wasResized: boolean } -/** Resize/re-encode to fit dimension and base64-size limits. Null when photon fails or nothing fits. */ -const resizeImage = async (inputBytes: Uint8Array, mimeType: string): Promise => { - const photon = await loadPhoton() - if (photon === null) return null - - const inputBase64Size = Math.ceil(inputBytes.byteLength / 3) * 4 - let image: ReturnType | undefined - - try { - const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes) - image = applyExifOrientation(photon, rawImage, inputBytes) - if (image !== rawImage) rawImage.free() +type Encoded = { readonly data: string; readonly mimeType: string } + +/** PNG first, then descending JPEG qualities: the first candidate under the cap wins. */ +const encodeUnderCap = (image: PhotonImage): Effect.Effect, PhotonError, Photon> => + Effect.gen(function* () { + const photon = yield* Photon + const candidates = [ + { encode: photon.encodePng(image), mimeType: 'image/png' }, + ...jpegQualityLadder.map((quality) => ({ + encode: photon.encodeJpeg(image, quality), + mimeType: 'image/jpeg', + })), + ] + for (const candidate of candidates) { + const data = toBase64(yield* candidate.encode) + if (data.length < defaultMaxImageBytes) return Option.some({ data, mimeType: candidate.mimeType }) + } + return Option.none() + }) + +/** Fit width/height inside maxDimension x maxDimension, preserving aspect ratio. */ +const fitWithinMaxDimension = (width: number, height: number) => { + let targetWidth = width + let targetHeight = height + if (targetWidth > maxDimension) { + targetHeight = Math.round((targetHeight * maxDimension) / targetWidth) + targetWidth = maxDimension + } + if (targetHeight > maxDimension) { + targetWidth = Math.round((targetWidth * maxDimension) / targetHeight) + targetHeight = maxDimension + } + return { width: targetWidth, height: targetHeight } +} +/** Resize/re-encode to fit dimension and base64-size limits. */ +const resizeImage = Effect.fn('resizeImage')( + function* (inputBytes: Uint8Array, mimeType: string) { + const photon = yield* Photon + const image = yield* photon.orient(yield* photon.decode(inputBytes), inputBytes) const originalWidth = image.get_width() const originalHeight = image.get_height() + const inputBase64Size = Math.ceil(inputBytes.byteLength / 3) * 4 if (originalWidth <= maxDimension && originalHeight <= maxDimension && inputBase64Size < defaultMaxImageBytes) { - return { + const passThrough: ResizedImage = { data: toBase64(inputBytes), mimeType, originalWidth, @@ -52,115 +108,65 @@ const resizeImage = async (inputBytes: Uint8Array, mimeType: string): Promise maxDimension) { - targetHeight = Math.round((targetHeight * maxDimension) / targetWidth) - targetWidth = maxDimension - } - if (targetHeight > maxDimension) { - targetWidth = Math.round((targetWidth * maxDimension) / targetHeight) - targetHeight = maxDimension - } - - let currentWidth = targetWidth - let currentHeight = targetHeight - + let { width, height } = fitWithinMaxDimension(originalWidth, originalHeight) while (true) { - const resized = photon.resize(image, currentWidth, currentHeight, photon.SamplingFilter.Lanczos3) - try { - // PNG first, then descending JPEG qualities: first candidate under the cap wins. - const candidates = [ - { bytes: resized.get_bytes(), mimeType: 'image/png' }, - ...jpegQualityLadder.map((quality) => ({ - bytes: resized.get_bytes_jpeg(quality), - mimeType: 'image/jpeg', - })), - ] - for (const candidate of candidates) { - const data = toBase64(candidate.bytes) - if (data.length < defaultMaxImageBytes) { - return { - data, - mimeType: candidate.mimeType, - originalWidth, - originalHeight, - width: currentWidth, - height: currentHeight, - wasResized: true, - } - } + const encoded = yield* Effect.scoped(Effect.flatMap(photon.resize(image, width, height), encodeUnderCap)) + if (Option.isSome(encoded)) { + const resized: ResizedImage = { + ...encoded.value, + originalWidth, + originalHeight, + width, + height, + wasResized: true, } - } finally { - resized.free() + return resized } - if (currentWidth === 1 && currentHeight === 1) break - const nextWidth = Math.max(1, Math.floor(currentWidth * 0.75)) - const nextHeight = Math.max(1, Math.floor(currentHeight * 0.75)) - if (nextWidth === currentWidth && nextHeight === currentHeight) break - currentWidth = nextWidth - currentHeight = nextHeight + const nextWidth = Math.max(1, Math.floor(width * 0.75)) + const nextHeight = Math.max(1, Math.floor(height * 0.75)) + if (nextWidth === width && nextHeight === height) break + width = nextWidth + height = nextHeight } - return null - } catch { - return null - } finally { - image?.free() - } -} + return yield* new ImageProcessError({ stage: 'resize', reason: 'too-large' }) + }, + Effect.scoped, + Effect.catchTag('PhotonError', (cause) => + Effect.fail(new ImageProcessError({ stage: 'resize', reason: cause.reason, cause })), + ), +) /** Decode any photon-readable bytes and re-encode as PNG (the BMP conversion path). */ -const convertToPng = async (inputBytes: Uint8Array): Promise => { - const photon = await loadPhoton() - if (photon === null) return null - - try { - const image = photon.PhotonImage.new_from_byteslice(inputBytes) - try { - return image.get_bytes() - } finally { - image.free() - } - } catch { - return null - } -} - -const inlineSupportedMimeTypes = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']) - -/** Outcome of processing image bytes for a tool result. */ -export type ProcessedImage = - | { readonly ok: true; readonly data: string; readonly mimeType: string; readonly hints: ReadonlyArray } - | { readonly ok: false; readonly message: string } +const convertToPng = Effect.fn('convertToPng')( + function* (inputBytes: Uint8Array) { + const photon = yield* Photon + return yield* photon.encodePng(yield* photon.decode(inputBytes)) + }, + Effect.scoped, + Effect.mapError((cause) => new ImageProcessError({ stage: 'convert', reason: cause.reason, cause })), +) /** * Prepare sniffed image bytes for inline delivery: convert unsupported containers (BMP) to PNG, then - * resize/re-encode under the inline limits. Failure messages are pi's, verbatim. + * resize/re-encode under the inline limits. */ -export const processImage = async (inputBytes: Uint8Array, sniffedMimeType: string): Promise => { +export const processImage = Effect.fn('processImage')(function* (inputBytes: Uint8Array, sniffedMimeType: string) { const hints: Array = [] let bytes = inputBytes let mimeType = sniffedMimeType if (!inlineSupportedMimeTypes.has(mimeType)) { - const converted = await convertToPng(bytes) - if (converted === null) { - return { ok: false, message: '[Image omitted: could not be converted to a supported inline image format.]' } - } + bytes = yield* convertToPng(bytes) hints.push(`[Image converted from ${mimeType} to image/png.]`) - bytes = converted mimeType = 'image/png' } - const resized = await resizeImage(bytes, mimeType) - if (resized === null) { - return { ok: false, message: '[Image omitted: could not be resized below the inline image size limit.]' } - } - + const resized = yield* resizeImage(bytes, mimeType) if (resized.wasResized) { const scale = resized.originalWidth / resized.width hints.push( @@ -168,5 +174,6 @@ export const processImage = async (inputBytes: Uint8Array, sniffedMimeType: stri ) } - return { ok: true, data: resized.data, mimeType: resized.mimeType, hints } -} + const processed: ProcessedImage = { data: resized.data, mimeType: resized.mimeType, hints } + return processed +}) diff --git a/packages/fold-agent/src/Tools/ReadTool.ts b/packages/fold-agent/src/Tools/ReadTool.ts index c16d08a..d07bcda 100644 --- a/packages/fold-agent/src/Tools/ReadTool.ts +++ b/packages/fold-agent/src/Tools/ReadTool.ts @@ -9,7 +9,6 @@ import { defineTool, formatSize, defaultMaxBytes, - platformToolDependencies, readToolContract, ToolResultFailure, ToolResultImagePart, @@ -19,11 +18,12 @@ import { truncateHead, type FoldTool, } from '@humanlayer/fold-core' -import { Effect, FileSystem, Match, Schema, type PlatformError } from 'effect' +import { Effect, FileSystem, Match, Option, Schema, type Path, type PlatformError } from 'effect' import { resolveReadPath, resolveToCwd } from '../Fs/PathResolve' import { detectSupportedImageMimeType, imageSniffBytes } from './Image/Mime' -import { processImage } from './Image/Process' +import type { Photon } from './Image/Photon' +import { imageOmittedNote, processImage } from './Image/Process' const binaryFileExtensions = new Set([ '.zip', @@ -99,24 +99,24 @@ export const platformErrorMessage = (action: string, path: string, error: Platfo ) } +const decodeErrnoCause = Schema.decodeUnknownOption(Schema.Struct({ code: Schema.String })) + /** 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' }), - Match.orElse((reason) => reason._tag), - ) + return Option.match(decodeErrnoCause(error.reason.cause), { + onSome: ({ code }) => code, + onNone: () => + Match.value(error.reason).pipe( + Match.tags({ NotFound: () => 'ENOENT', PermissionDenied: () => 'EACCES' }), + Match.orElse((reason) => reason._tag), + ), + }) } /** Build the read tool over the ambient FileSystem service. */ -export const readTool = (options?: { readonly cwd?: string }): FoldTool => +export const readTool = (options?: { readonly cwd?: string }): FoldTool => defineTool({ ...readToolContract, - dependencies: platformToolDependencies, handler: (params) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem @@ -133,21 +133,27 @@ export const readTool = (options?: { readonly cwd?: string }): FoldTool => const imageMimeType = detectSupportedImageMimeType(bytes.subarray(0, imageSniffBytes)) if (imageMimeType !== null) { - const processed = yield* Effect.promise(() => processImage(bytes, imageMimeType)) - - if (!processed.ok) { - return ToolResultText.make({ - text: `Read image file [${imageMimeType}]\n${processed.message}`, - }) - } - - const note = [`Read image file [${processed.mimeType}]`, ...processed.hints].join('\n') - return ToolResultMultipart.make({ - content: [ - ToolResultTextPart.make({ text: note }), - ToolResultImagePart.make({ data: processed.data, mediaType: processed.mimeType }), - ], - }) + return yield* processImage(bytes, imageMimeType).pipe( + Effect.map((processed) => + ToolResultMultipart.make({ + content: [ + ToolResultTextPart.make({ + text: [`Read image file [${processed.mimeType}]`, ...processed.hints].join( + '\n', + ), + }), + ToolResultImagePart.make({ data: processed.data, mediaType: processed.mimeType }), + ], + }), + ), + Effect.catchTag('ImageProcessError', (error) => + Effect.succeed( + ToolResultText.make({ + text: `Read image file [${imageMimeType}]\n${imageOmittedNote(error)}`, + }), + ), + ), + ) } const text = yield* decodeTextFile(params.path, bytes) diff --git a/packages/fold-agent/src/Tools/WebFetchTool.ts b/packages/fold-agent/src/Tools/WebFetchTool.ts index 1844e79..cd209b8 100644 --- a/packages/fold-agent/src/Tools/WebFetchTool.ts +++ b/packages/fold-agent/src/Tools/WebFetchTool.ts @@ -10,12 +10,13 @@ import { type ToolResultSuccess, } from '@humanlayer/fold-core' import { Duration, Effect, Option, Schema, Stream } from 'effect' -import { FetchHttpClient, Headers, HttpClient } from 'effect/unstable/http' +import { Headers, HttpClient } from 'effect/unstable/http' import type { HttpClientResponse } from 'effect/unstable/http' import TurndownService from 'turndown' import { detectSupportedImageMimeType, imageSniffBytes } from './Image/Mime' -import { processImage } from './Image/Process' +import type { Photon } from './Image/Photon' +import { imageOmittedNote, processImage } from './Image/Process' const maxResponseSize = 5 * 1024 * 1024 const defaultTimeoutMs = 30_000 @@ -38,7 +39,7 @@ const failWith = (message: string): Effect.Effect => /** The advertised body size, decoded from the raw header; `None` when absent or unparseable. */ const declaredBodySize = (headers: Headers.Headers): Option.Option => - Headers.get(headers, 'content-length').pipe(Option.flatMap(Schema.decodeOption(Schema.NumberFromString))) + Headers.get(headers, 'content-length').pipe(Option.flatMap(Schema.decodeOption(Schema.FiniteFromString))) /** The lowercased content-type, or an empty string when the header is absent. */ const contentTypeOf = (headers: Headers.Headers): string => @@ -171,22 +172,29 @@ const renderDocument = ( document: FetchedDocument, format: 'markdown' | 'text' | 'html', turndown: TurndownService, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const imageMimeType = imageMimeFor(document.bytes, document.contentType) if (imageMimeType !== null) { - const processed = yield* Effect.promise(() => processImage(document.bytes, imageMimeType)) - if (!processed.ok) { - return ToolResultText.make({ text: `Fetched image [${imageMimeType}]\n${processed.message}` }) - } - - const note = [`Fetched image [${processed.mimeType}] from ${url}`, ...processed.hints].join('\n') - return ToolResultMultipart.make({ - content: [ - ToolResultTextPart.make({ text: note }), - ToolResultImagePart.make({ data: processed.data, mediaType: processed.mimeType }), - ], - }) + return yield* processImage(document.bytes, imageMimeType).pipe( + Effect.map((processed) => + ToolResultMultipart.make({ + content: [ + ToolResultTextPart.make({ + text: [`Fetched image [${processed.mimeType}] from ${url}`, ...processed.hints].join( + '\n', + ), + }), + ToolResultImagePart.make({ data: processed.data, mediaType: processed.mimeType }), + ], + }), + ), + Effect.catchTag('ImageProcessError', (error) => + Effect.succeed( + ToolResultText.make({ text: `Fetched image [${imageMimeType}]\n${imageOmittedNote(error)}` }), + ), + ), + ) } const body = new TextDecoder().decode(document.bytes) @@ -197,10 +205,12 @@ const renderDocument = ( // --- tool --------------------------------------------------------------------------------------------- -export const webFetchTool = (): FoldTool => { +export const webFetchTool = (): FoldTool => { const turndown = makeTurndown() - const runWebFetch = (params: WebFetchParameters): Effect.Effect => + const runWebFetch = ( + params: WebFetchParameters, + ): Effect.Effect => Effect.gen(function* () { if (!params.url.startsWith('http://') && !params.url.startsWith('https://')) { return yield* failWith('URL must start with http:// or https://') @@ -209,10 +219,10 @@ export const webFetchTool = (): FoldTool => { const timeoutMs = Math.min((params.timeout_seconds ?? defaultTimeoutMs / 1000) * 1000, maxTimeoutMs) const document = yield* fetchDocument(params.url, timeoutMs) return yield* renderDocument(params.url, document, params.format ?? 'markdown', turndown) - }).pipe( - Effect.provide(FetchHttpClient.layer), - Effect.withSpan('tool.web_fetch', { attributes: { url: params.url } }), - ) + }).pipe(Effect.withSpan('tool.web_fetch', { attributes: { url: params.url } })) - return defineTool({ ...webFetchToolContract, handler: runWebFetch }) + return defineTool({ + ...webFetchToolContract, + handler: runWebFetch, + }) } diff --git a/packages/fold-agent/src/Tools/WebSearchTool.ts b/packages/fold-agent/src/Tools/WebSearchTool.ts index 0045ec9..4186962 100644 --- a/packages/fold-agent/src/Tools/WebSearchTool.ts +++ b/packages/fold-agent/src/Tools/WebSearchTool.ts @@ -6,8 +6,8 @@ import { webSearchToolContract, type FoldTool, } from '@humanlayer/fold-core' -import { Effect, Fiber, Predicate } from 'effect' -import { FetchHttpClient } from 'effect/unstable/http' +import { Data, Duration, Effect, Option, Schema } from 'effect' +import { HttpClient, HttpClientRequest } from 'effect/unstable/http' const defaultTimeoutMs = 25_000 const maxNumResults = 20 @@ -36,11 +36,10 @@ const resolveExaUrl = (options?: WebSearchToolOptions): string => { return url.toString() } -const resolveParallelHeaders = (options?: WebSearchToolOptions): Record => { +const resolveParallelHeaders = (options?: WebSearchToolOptions) => { const apiKey = options?.parallelApiKey ?? resolveEnv(options, 'PARALLEL_API_KEY') - const headers: { 'User-Agent': string; Authorization?: string } = { 'User-Agent': 'fold/1.0' } - if (apiKey !== undefined && apiKey.length > 0) headers.Authorization = `Bearer ${apiKey}` - return headers + const userAgent = { 'User-Agent': 'fold/1.0' } + return apiKey !== undefined && apiKey.length > 0 ? { ...userAgent, Authorization: `Bearer ${apiKey}` } : userAgent } const checksum = (text: string): number => { @@ -60,105 +59,102 @@ 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 -} +/** The JSON-RPC `tools/call` request both MCP search endpoints accept. */ +const McpToolCall = Schema.Struct({ + jsonrpc: Schema.Literal('2.0'), + id: Schema.Finite, + method: Schema.Literal('tools/call'), + params: Schema.Struct({ + name: Schema.String, + arguments: Schema.Record(Schema.String, Schema.Json), + }), +}) + +/** The part of a JSON-RPC `tools/call` response the tool reads: the result's content blocks. */ +const McpToolResponse = Schema.Struct({ + result: Schema.optional( + Schema.Struct({ + content: Schema.optional(Schema.Array(Schema.Struct({ text: Schema.optional(Schema.String) }))), + }), + ), +}) +type McpToolResponse = typeof McpToolResponse.Type -const parsePayload = (payload: string): string | undefined => { - const trimmed = payload.trim() - if (!trimmed.startsWith('{')) return undefined +const decodeMcpPayload = Schema.decodeEffect(Schema.fromJsonString(McpToolResponse)) - 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 +const firstText = (response: McpToolResponse): Option.Option => + Option.fromUndefinedOr( + response.result?.content?.map(({ text }) => text).find((text) => text !== undefined && text.length > 0), + ) - return content.map(textField).find((text) => text !== undefined) +/** A web search failed; `message` is shown to the model. */ +class McpFailure extends Data.TaggedError('McpFailure')<{ readonly message: string }> {} + +/** Decode one JSON payload (a whole body or an SSE `data:` line); non-JSON payloads carry no result. */ +const parsePayload = (payload: string): Effect.Effect, McpFailure> => { + const trimmed = payload.trim() + if (!trimmed.startsWith('{')) return Effect.succeedNone + return decodeMcpPayload(trimmed).pipe( + Effect.map(firstText), + Effect.mapError( + (error) => new McpFailure({ message: `Failed to parse web search response: ${error.message}` }), + ), + ) } -const parseMcpResponse = (body: string): Effect.Effect => - Effect.try({ - try: () => { - const direct = body.trim().length > 0 ? parsePayload(body) : undefined - if (direct !== undefined) return direct - - for (const line of body.split('\n')) { - if (!line.startsWith('data: ')) continue - const text = parsePayload(line.slice(6)) - if (text !== undefined) return text - } - - return undefined - }, - catch: (error) => ({ - message: `Failed to parse web search response: ${Predicate.isError(error) ? error.message : String(error)}`, - }), +const parseMcpResponse = (body: string): Effect.Effect, McpFailure> => + Effect.gen(function* () { + const direct = yield* parsePayload(body) + if (Option.isSome(direct)) return direct + + for (const line of body.split('\n')) { + if (!line.startsWith('data: ')) continue + const text = yield* parsePayload(line.slice(6)) + if (Option.isSome(text)) return text + } + + return Option.none() }) const callMcp = (input: { readonly url: string readonly tool: string - readonly arguments: Record + readonly arguments: Record readonly headers?: Record readonly timeoutMs: number -}): Effect.Effect => +}): Effect.Effect, McpFailure, HttpClient.HttpClient> => Effect.gen(function* () { - const fetch = yield* FetchHttpClient.Fetch - const controller = new AbortController() - const timer = yield* Effect.sleep(input.timeoutMs).pipe( - Effect.andThen(Effect.sync(() => controller.abort())), - Effect.forkChild, + const request = yield* HttpClientRequest.post(input.url, { + headers: { accept: 'application/json, text/event-stream', ...input.headers }, + }).pipe( + HttpClientRequest.schemaBodyJson(McpToolCall)({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: input.tool, arguments: input.arguments }, + }), ) - - return yield* Effect.gen(function* () { - const response = yield* Effect.tryPromise({ - try: () => - fetch(input.url, { - method: 'POST', - signal: controller.signal, - headers: { - accept: 'application/json, text/event-stream', - 'content-type': 'application/json', - ...input.headers, - }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: { name: input.tool, arguments: input.arguments }, - }), - }), - catch: (error) => ({ - message: - Predicate.isError(error) && error.name === 'AbortError' - ? `${input.tool} request timed out` - : Predicate.isError(error) - ? error.message - : String(error), - }), - }) - - if (!response.ok) { - return yield* Effect.fail({ - message: `${input.tool} request failed with status code: ${response.status}`, - }) - } - - const body = yield* Effect.tryPromise({ - try: () => response.text(), - catch: (error) => ({ - message: `Failed to read web search response: ${Predicate.isError(error) ? error.message : String(error)}`, - }), + const response = yield* HttpClient.execute(request) + if (response.status < 200 || response.status >= 300) { + return yield* new McpFailure({ + message: `${input.tool} request failed with status code: ${response.status}`, }) - return yield* parseMcpResponse(body) - }).pipe(Effect.ensuring(Fiber.interrupt(timer))) - }) + } + + return yield* parseMcpResponse(yield* response.text) + }).pipe( + Effect.catchTags({ + HttpBodyError: (error) => + Effect.fail(new McpFailure({ message: `Failed to encode web search request (${error.reason._tag})` })), + HttpClientError: (error) => Effect.fail(new McpFailure({ message: error.message })), + }), + Effect.timeoutOrElse({ + duration: Duration.millis(input.timeoutMs), + orElse: () => Effect.fail(new McpFailure({ message: `${input.tool} request timed out` })), + }), + ) -export const webSearchTool = (options?: WebSearchToolOptions): FoldTool => +export const webSearchTool = (options?: WebSearchToolOptions): FoldTool => defineTool({ ...webSearchToolContract, handler: (params) => @@ -197,6 +193,8 @@ export const webSearchTool = (options?: WebSearchToolOptions): FoldTool => timeoutMs, }) - return ToolResultText.make({ text: result ?? 'No search results found. Please try a different query.' }) + return ToolResultText.make({ + text: Option.getOrElse(result, () => 'No search results found. Please try a different query.'), + }) }).pipe(Effect.mapError((error) => ToolResultFailure.make({ text: error.message }))), }) diff --git a/packages/fold-agent/src/Tools/WebTools.ts b/packages/fold-agent/src/Tools/WebTools.ts index 8b37840..4b6b276 100644 --- a/packages/fold-agent/src/Tools/WebTools.ts +++ b/packages/fold-agent/src/Tools/WebTools.ts @@ -1,8 +1,13 @@ import type { FoldTool } from '@humanlayer/fold-core' +import type { HttpClient } from 'effect/unstable/http' +import type { Photon } from './Image/Photon' import { webFetchTool } from './WebFetchTool' import { webSearchTool, type WebSearchToolOptions } from './WebSearchTool' export type WebToolsOptions = WebSearchToolOptions -export const webTools = (options?: WebToolsOptions): ReadonlyArray => [webFetchTool(), webSearchTool(options)] +export const webTools = (options?: WebToolsOptions): ReadonlyArray> => [ + webFetchTool(), + webSearchTool(options), +] diff --git a/packages/fold-agent/src/Tools/WriteTool.ts b/packages/fold-agent/src/Tools/WriteTool.ts index c17b47c..f65150a 100644 --- a/packages/fold-agent/src/Tools/WriteTool.ts +++ b/packages/fold-agent/src/Tools/WriteTool.ts @@ -5,7 +5,6 @@ */ import { defineTool, - platformToolDependencies, ToolResultFailure, ToolResultText, utf8ByteLength, @@ -19,10 +18,9 @@ import { resolveToCwd } from '../Fs/PathResolve' import { platformErrorMessage } from './ReadTool' /** Build the write tool over the ambient FileSystem service. */ -export const writeTool = (options?: { readonly cwd?: string }): FoldTool => +export const writeTool = (options?: { readonly cwd?: string }): FoldTool => defineTool({ ...writeToolContract, - dependencies: platformToolDependencies, handler: (params) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem @@ -34,19 +32,11 @@ export const writeTool = (options?: { readonly cwd?: string }): FoldTool => fs, absolutePath, Effect.gen(function* () { - yield* fs.makeDirectory(pathService.dirname(absolutePath), { recursive: true }).pipe( - Effect.mapError((error) => ({ - message: platformErrorMessage('write', params.path, error), - })), - ) - yield* fs.writeFileString(absolutePath, params.content).pipe( - Effect.mapError((error) => ({ - message: platformErrorMessage('write', params.path, error), - })), - ) + yield* fs.makeDirectory(pathService.dirname(absolutePath), { recursive: true }) + yield* fs.writeFileString(absolutePath, params.content) }), ).pipe( - // Realpath failures while keying the lock (permissions, symlink loops) surface too. + // Directory, write, and lock-keying realpath failures (permissions, symlink loops) all surface here. Effect.catchTag('PlatformError', (error) => Effect.fail({ message: platformErrorMessage('write', params.path, error) }), ), diff --git a/packages/fold-agent/src/index.ts b/packages/fold-agent/src/index.ts index 01e35e1..1bdf244 100644 --- a/packages/fold-agent/src/index.ts +++ b/packages/fold-agent/src/index.ts @@ -43,6 +43,7 @@ export * from './Tools/CodingTools' export * from './Tools/EditTool' export * from './Tools/FileTools' export * from './Tools/Image/Mime' +export * from './Tools/Image/Photon' export * from './Tools/Image/Process' export * from './Tools/ReadTool' export * from './Tools/WebFetchTool' diff --git a/packages/fold-agent/test/Bin/ManagedBinaries.vi.test.ts b/packages/fold-agent/test/Bin/ManagedBinaries.vi.test.ts index 8a59457..0126db9 100644 --- a/packages/fold-agent/test/Bin/ManagedBinaries.vi.test.ts +++ b/packages/fold-agent/test/Bin/ManagedBinaries.vi.test.ts @@ -1,34 +1,32 @@ /** * Managed-binaries tests (D18): the system -> managed -> download resolution ladder, alias and * version-floor handling on system hits, sha256 verification before anything touches disk, the - * download kill switch, never-failing degradation, and per-process memoization. Every seam is - * injected (which/download/exec/env); installs land on a real temp dir so the extract/rename/chmod - * path is exercised against the actual filesystem. + * download kill switch, never-failing degradation, and per-process caching. The resolver runs on + * the real filesystem in a temp dir (PATH scan, extract/rename/chmod); the child-process spawner and + * HttpClient are fake layers that record what they were asked to do, and env comes from a + * `ConfigProvider`. */ -import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { ConfigProvider, Effect, FileSystem, Layer, Sink, Stream } from 'effect' +import { HttpClient, HttpClientResponse } from 'effect/unstable/http' +import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process' import { - BinaryDownloadError, - BinaryExecError, ensureManagedBinaries, + FOLD_DISABLE_BINARY_DOWNLOADS, + HostPlatform, + ManagedBinaries, managedBinDir, parseBinaryVersion, - FOLD_DISABLE_BINARY_DOWNLOADS, - type DownloadSeam, - type ExecSeam, type ManagedBinaryDefinition, - type WhichSeam, } from '../../src/index' -import { tempDir } from '../TestHelpers' +import { offlineHttpClient, tempDir } from '../TestHelpers' const binaryBytes = new TextEncoder().encode('#!/bin/sh\necho fake binary\n') -// sha256 of `binaryBytes`, precomputed so the verification test has a real matching digest. const definitionOf = (overrides?: Partial): ManagedBinaryDefinition => ({ name: 'rg', repo: 'example/rg', @@ -44,84 +42,205 @@ const definitionOf = (overrides?: Partial): ManagedBina ...overrides, }) -const whichOf = - (hits: Readonly>): WhichSeam => - (name) => - Effect.succeed(hits[name] ?? null) - -/** Download seam returning fixed bytes, recording every requested URL. */ -const recordingDownload = (bytes: Uint8Array): { readonly seam: DownloadSeam; readonly urls: Array } => { +/** HttpClient returning fixed bytes, recording every requested URL. */ +const recordingDownload = ( + bytes: Uint8Array, +): { readonly layer: Layer.Layer; readonly urls: Array } => { const urls: Array = [] return { urls, - seam: (url) => - Effect.sync(() => { - urls.push(url) - return bytes + layer: Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + urls.push(request.url) + return HttpClientResponse.fromWeb(request, new Response(bytes)) + }), + ), + ), + } +} + +/** What one fake process run produces. */ +type FakeRun = { + readonly stdout?: string + readonly stderr?: string + readonly exitCode?: number +} + +type FakeCommand = ( + command: string, + args: ReadonlyArray, +) => Effect.Effect + +const textStream = (text: string) => Stream.make(new TextEncoder().encode(text)) + +/** A spawner whose processes run `respond` instead of a real program; records every command line. */ +const fakeSpawner = ( + respond: FakeCommand, +): { + readonly layer: Layer.Layer + readonly commands: Array +} => { + const commands: Array = [] + return { + commands, + layer: Layer.effect( + ChildProcessSpawner.ChildProcessSpawner, + Effect.gen(function* () { + const context = yield* Effect.context() + return ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command)) + return Effect.die(new Error('unexpected piped command')) + commands.push([command.command, ...command.args].join(' ')) + return respond(command.command, command.args).pipe( + Effect.provideContext(context), + Effect.map((run) => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(run.exitCode ?? 0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + stdout: textStream(run.stdout ?? ''), + stderr: textStream(run.stderr ?? ''), + all: textStream(`${run.stdout ?? ''}${run.stderr ?? ''}`), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }), + ), + ) + }) }), + ), } } /** - * Exec seam that emulates `tar xzf -C ` by writing the expected binary into the - * extraction dir, and answers `--version` probes with the given output. + * Emulates `tar xzf -C ` (and `unzip -d `) by writing the expected binary into + * the extraction dir, and answers `--version` probes with the given output. */ -const extractingExec = - (pathInArchive: string, versionOutput = ''): ExecSeam => - (command, args) => - Effect.sync(() => { - if (args[0] === '--version' || args.includes('--version')) return { stdout: versionOutput } - const extractDir = args[args.indexOf('-C') + 1] - if (command === 'tar' && extractDir !== undefined) { - const target = join(extractDir, pathInArchive) - mkdirSync(dirname(target), { recursive: true }) - writeFileSync(target, binaryBytes) - return { stdout: '' } - } - return { stdout: '' } +const extracting = + (pathInArchive: string, versionOutput = ''): FakeCommand => + (_command, args) => + Effect.gen(function* () { + if (args.includes('--version')) return { stdout: versionOutput } + const flagIndex = Math.max(args.indexOf('-C'), args.indexOf('-d')) + const extractDir = args[flagIndex + 1] + if (flagIndex === -1 || extractDir === undefined) return { exitCode: 2, stderr: 'no target dir' } + const fs = yield* FileSystem.FileSystem + const target = join(extractDir, pathInArchive) + yield* fs.makeDirectory(dirname(target), { recursive: true }).pipe(Effect.orDie) + yield* fs.writeFile(target, binaryBytes).pipe(Effect.orDie) + return {} }) -const emptyEnv = (): string | undefined => undefined +/** A spawner that must never run: any command is a test failure. */ +const noCommands: FakeCommand = (command, args) => + Effect.die(new Error(`unexpected command ${command} ${args.join(' ')}`)) + +/** The resolver over a test registry, with every external seam replaced. */ +const resolverLayer = (input: { + readonly registry: ReadonlyArray + readonly env: Record + readonly spawner: Layer.Layer + readonly http: Layer.Layer +}): Layer.Layer => + ManagedBinaries.layerWith(input.registry).pipe( + Layer.provide( + Layer.mergeAll( + input.spawner, + input.http, + ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env })), + Layer.succeed(HostPlatform, { platform: 'linux', arch: 'x64' }), + ).pipe(Layer.provideMerge(NodeFileSystem.layer)), + ), + ) + +/** Put one fake executable (or, with a non-exec mode, a plain file) into a PATH dir. */ +const onPath = (directory: string, name: string, mode = 0o755): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = join(directory, name) + yield* fs.makeDirectory(directory, { recursive: true }) + yield* fs.writeFile(path, binaryBytes) + yield* fs.chmod(path, mode) + return path + }).pipe(Effect.orDie) + +const exists = (path: string): Effect.Effect => + FileSystem.FileSystem.use((fs) => fs.exists(path)).pipe(Effect.orDie) it.effect('a system alias hit short-circuits the ladder without downloading', () => Effect.gen(function* () { const home = yield* tempDir + const pathDir = join(home, 'sys') + const fdfind = yield* onPath(pathDir, 'fdfind') const download = recordingDownload(binaryBytes) - const [status] = yield* ensureManagedBinaries({ - foldHome: home, - memoize: false, - env: emptyEnv, - which: whichOf({ fdfind: '/usr/bin/fdfind' }), - download: download.seam, - registry: [definitionOf({ name: 'fd', systemNames: ['fd', 'fdfind'] })], - }) + const spawner = fakeSpawner(noCommands) + + const [status] = yield* ensureManagedBinaries({ foldHome: home }).pipe( + Effect.provide( + resolverLayer({ + registry: [definitionOf({ name: 'fd', systemNames: ['fd', 'fdfind'] })], + env: { PATH: pathDir }, + spawner: spawner.layer, + http: download.layer, + }), + ), + ) expect(status?.resolution).toBe('system') - expect(status?.path).toBe('/usr/bin/fdfind') + expect(status?.path).toBe(fdfind) expect(status?.detail).toContain('fdfind') expect(download.urls).toEqual([]) + expect(spawner.commands).toEqual([]) + }).pipe(Effect.provide(NodeFileSystem.layer)), +) + +it.effect('a non-executable file on PATH is not a system hit', () => + Effect.gen(function* () { + const home = yield* tempDir + const pathDir = join(home, 'sys') + yield* onPath(pathDir, 'rg', 0o644) + + const [status] = yield* ensureManagedBinaries({ foldHome: home, disableDownloads: true }).pipe( + Effect.provide( + resolverLayer({ + registry: [definitionOf()], + env: { PATH: pathDir }, + spawner: fakeSpawner(noCommands).layer, + http: recordingDownload(binaryBytes).layer, + }), + ), + ) + + expect(status?.resolution).toBe('unavailable') }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('requireManagedInstall installs the canonical managed binary even when a system binary exists', () => Effect.gen(function* () { const home = yield* tempDir + const pathDir = join(home, 'sys') + yield* onPath(pathDir, 'rg') const download = recordingDownload(binaryBytes) - const [status] = yield* ensureManagedBinaries({ - foldHome: home, - memoize: false, - env: emptyEnv, - which: whichOf({ rg: '/opt/homebrew/bin/rg' }), - download: download.seam, - exec: extractingExec('rg-1.0.0/rg'), - requireManagedInstall: true, - registry: [definitionOf()], - }) + const [status] = yield* ensureManagedBinaries({ foldHome: home, requireManagedInstall: true }).pipe( + Effect.provide( + resolverLayer({ + registry: [definitionOf()], + env: { PATH: pathDir }, + spawner: fakeSpawner(extracting('rg-1.0.0/rg')).layer, + http: download.layer, + }), + ), + ) expect(status?.resolution).toBe('installed-now') expect(status?.path).toBe(join(managedBinDir(home), 'rg')) - expect(existsSync(join(managedBinDir(home), 'rg'))).toBe(true) + expect(yield* exists(join(managedBinDir(home), 'rg'))).toBe(true) expect(download.urls).toEqual(['https://example.com/rg-1.0.0.tar.gz']) }).pipe(Effect.provide(NodeFileSystem.layer)), ) @@ -129,56 +248,71 @@ it.effect('requireManagedInstall installs the canonical managed binary even when it.effect('requireManagedInstall plus disabled downloads can still report a usable system binary', () => Effect.gen(function* () { const home = yield* tempDir + const pathDir = join(home, 'sys') + const rg = yield* onPath(pathDir, 'rg') + const [status] = yield* ensureManagedBinaries({ foldHome: home, - memoize: false, disableDownloads: true, - env: emptyEnv, - which: whichOf({ rg: '/opt/homebrew/bin/rg' }), requireManagedInstall: true, - registry: [definitionOf()], - }) + }).pipe( + Effect.provide( + resolverLayer({ + registry: [definitionOf()], + env: { PATH: pathDir }, + spawner: fakeSpawner(noCommands).layer, + http: recordingDownload(binaryBytes).layer, + }), + ), + ) expect(status?.resolution).toBe('system') - expect(status?.path).toBe('/opt/homebrew/bin/rg') - expect(existsSync(join(managedBinDir(home), 'rg'))).toBe(false) + expect(status?.path).toBe(rg) + expect(yield* exists(join(managedBinDir(home), 'rg'))).toBe(false) }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('a system binary below the version floor falls through past the system rung', () => Effect.gen(function* () { const home = yield* tempDir - const [status] = yield* ensureManagedBinaries({ - foldHome: home, - memoize: false, - disableDownloads: true, - env: emptyEnv, - which: whichOf({ 'ast-grep': '/usr/bin/ast-grep' }), - exec: extractingExec('unused', 'ast-grep 0.39.6'), - registry: [definitionOf({ name: 'ast-grep', systemNames: ['ast-grep'], minVersion: '0.44.0' })], - }) + const pathDir = join(home, 'sys') + const astGrep = yield* onPath(pathDir, 'ast-grep') + const spawner = fakeSpawner(extracting('unused', 'ast-grep 0.39.6')) + + const [status] = yield* ensureManagedBinaries({ foldHome: home, disableDownloads: true }).pipe( + Effect.provide( + resolverLayer({ + registry: [definitionOf({ name: 'ast-grep', systemNames: ['ast-grep'], minVersion: '0.44.0' })], + env: { PATH: pathDir }, + spawner: spawner.layer, + http: recordingDownload(binaryBytes).layer, + }), + ), + ) // Not 'system': the old binary was rejected; with downloads disabled the ladder ends unavailable. expect(status?.resolution).toBe('unavailable') expect(status?.detail).toContain('downloads disabled') + expect(spawner.commands).toEqual([`${astGrep} --version`]) }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('an already-installed managed binary resolves without downloading', () => Effect.gen(function* () { const home = yield* tempDir - mkdirSync(managedBinDir(home), { recursive: true }) - writeFileSync(join(managedBinDir(home), 'rg'), binaryBytes) + yield* onPath(managedBinDir(home), 'rg') const download = recordingDownload(binaryBytes) - const [status] = yield* ensureManagedBinaries({ - foldHome: home, - memoize: false, - env: emptyEnv, - which: whichOf({}), - download: download.seam, - registry: [definitionOf()], - }) + const [status] = yield* ensureManagedBinaries({ foldHome: home }).pipe( + Effect.provide( + resolverLayer({ + registry: [definitionOf()], + env: {}, + spawner: fakeSpawner(noCommands).layer, + http: download.layer, + }), + ), + ) expect(status?.resolution).toBe('managed') expect(status?.path).toBe(join(managedBinDir(home), 'rg')) @@ -190,49 +324,92 @@ it.effect('a missing binary downloads, extracts, and installs into /bi Effect.gen(function* () { const home = yield* tempDir const download = recordingDownload(binaryBytes) + const spawner = fakeSpawner(extracting('rg-1.0.0/rg')) - const [status] = yield* ensureManagedBinaries({ - foldHome: home, - memoize: false, - env: emptyEnv, - which: whichOf({}), - download: download.seam, - exec: extractingExec('rg-1.0.0/rg'), - registry: [definitionOf()], - }) + const [status] = yield* ensureManagedBinaries({ foldHome: home }).pipe( + Effect.provide( + resolverLayer({ registry: [definitionOf()], env: {}, spawner: spawner.layer, http: download.layer }), + ), + ) + const fs = yield* FileSystem.FileSystem + const installed = join(managedBinDir(home), 'rg') expect(status?.resolution).toBe('installed-now') - expect(status?.path).toBe(join(managedBinDir(home), 'rg')) - expect(existsSync(join(managedBinDir(home), 'rg'))).toBe(true) + expect(status?.path).toBe(installed) + expect(((yield* fs.stat(installed)).mode & 0o777).toString(8)).toBe('755') + // The temp extraction dir is gone; only the installed binary remains. + expect(yield* fs.readDirectory(managedBinDir(home))).toEqual(['rg']) expect(download.urls).toEqual(['https://example.com/rg-1.0.0.tar.gz']) + expect(spawner.commands).toHaveLength(1) + expect(spawner.commands[0]).toMatch(/^tar xzf .*rg-1\.0\.0\.tar\.gz -C /) + }).pipe(Effect.provide(NodeFileSystem.layer)), +) + +it.effect('a zip asset falls back to tar when unzip fails', () => + Effect.gen(function* () { + const home = yield* tempDir + const spawner = fakeSpawner((command, args) => + command === 'unzip' + ? Effect.succeed({ exitCode: 127, stderr: 'unzip: not found' }) + : extracting('sg')(command, args), + ) + + const [status] = yield* ensureManagedBinaries({ foldHome: home }).pipe( + Effect.provide( + resolverLayer({ + registry: [ + definitionOf({ + name: 'ast-grep', + assetFor: () => ({ + url: 'https://example.com/ast-grep.zip', + archive: 'zip', + pathInArchive: 'sg', + sha256: null, + }), + }), + ], + env: {}, + spawner: spawner.layer, + http: recordingDownload(binaryBytes).layer, + }), + ), + ) + + expect(status?.resolution).toBe('installed-now') + expect(spawner.commands.map((line) => line.split(' ')[0])).toEqual(['unzip', 'tar']) }).pipe(Effect.provide(NodeFileSystem.layer)), ) it.effect('a sha256 mismatch degrades to unavailable and writes nothing', () => Effect.gen(function* () { const home = yield* tempDir - const [status] = yield* ensureManagedBinaries({ - foldHome: home, - memoize: false, - env: emptyEnv, - which: whichOf({}), - download: recordingDownload(binaryBytes).seam, - exec: extractingExec('rg-1.0.0/rg'), - registry: [ - definitionOf({ - assetFor: () => ({ - url: 'https://example.com/rg-1.0.0.tar.gz', - archive: 'tar.gz', - pathInArchive: 'rg-1.0.0/rg', - sha256: 'deadbeef', - }), + const spawner = fakeSpawner(extracting('rg-1.0.0/rg')) + + const [status] = yield* ensureManagedBinaries({ foldHome: home }).pipe( + Effect.provide( + resolverLayer({ + registry: [ + definitionOf({ + assetFor: () => ({ + url: 'https://example.com/rg-1.0.0.tar.gz', + archive: 'tar.gz', + pathInArchive: 'rg-1.0.0/rg', + sha256: 'deadbeef', + }), + }), + ], + env: {}, + spawner: spawner.layer, + http: recordingDownload(binaryBytes).layer, }), - ], - }) + ), + ) expect(status?.resolution).toBe('unavailable') expect(status?.detail).toContain('sha256 mismatch') - expect(existsSync(join(managedBinDir(home), 'rg'))).toBe(false) + // Verification runs on the in-memory bytes: not even the bin dir was created. + expect(yield* exists(managedBinDir(home))).toBe(false) + expect(spawner.commands).toEqual([]) }).pipe(Effect.provide(NodeFileSystem.layer)), ) @@ -241,16 +418,19 @@ it.effect('the env kill switch skips downloads entirely', () => const home = yield* tempDir const download = recordingDownload(binaryBytes) - const [status] = yield* ensureManagedBinaries({ - foldHome: home, - memoize: false, - env: (name) => (name === FOLD_DISABLE_BINARY_DOWNLOADS ? '1' : undefined), - which: whichOf({}), - download: download.seam, - registry: [definitionOf()], - }) + const [status] = yield* ensureManagedBinaries({ foldHome: home }).pipe( + Effect.provide( + resolverLayer({ + registry: [definitionOf()], + env: { [FOLD_DISABLE_BINARY_DOWNLOADS]: '1' }, + spawner: fakeSpawner(noCommands).layer, + http: download.layer, + }), + ), + ) expect(status?.resolution).toBe('unavailable') + expect(status?.detail).toContain('downloads disabled') expect(download.urls).toEqual([]) }).pipe(Effect.provide(NodeFileSystem.layer)), ) @@ -258,63 +438,71 @@ it.effect('the env kill switch skips downloads entirely', () => it.effect('one failing binary never blocks the rest (ensure never fails)', () => Effect.gen(function* () { const home = yield* tempDir - const failingDownload: DownloadSeam = (url) => - Effect.fail(new BinaryDownloadError({ message: `GET ${url}: network down` })) - - const statuses = yield* ensureManagedBinaries({ - foldHome: home, - memoize: false, - env: emptyEnv, - which: whichOf({ fd: '/usr/bin/fd' }), - download: failingDownload, - registry: [definitionOf(), definitionOf({ name: 'fd', systemNames: ['fd'] })], - }) + const pathDir = join(home, 'sys') + yield* onPath(pathDir, 'fd') + + const statuses = yield* ensureManagedBinaries({ foldHome: home }).pipe( + Effect.provide( + resolverLayer({ + registry: [definitionOf(), definitionOf({ name: 'fd', systemNames: ['fd'] })], + env: { PATH: pathDir }, + spawner: fakeSpawner(noCommands).layer, + http: offlineHttpClient, + }), + ), + ) expect(statuses.map((status) => status.resolution)).toEqual(['unavailable', 'system']) expect(statuses[0]?.detail).toContain('network down') }).pipe(Effect.provide(NodeFileSystem.layer)), ) -it.effect('an exec failure during extraction also degrades to unavailable', () => +it.effect('a failing extraction also degrades to unavailable and cleans up', () => Effect.gen(function* () { const home = yield* tempDir - const brokenExec: ExecSeam = (command, args) => - Effect.fail(new BinaryExecError({ message: `${command} ${args.join(' ')}: exploded` })) - - const [status] = yield* ensureManagedBinaries({ - foldHome: home, - memoize: false, - env: emptyEnv, - which: whichOf({}), - download: recordingDownload(binaryBytes).seam, - exec: brokenExec, - registry: [definitionOf()], - }) + const spawner = fakeSpawner(() => Effect.succeed({ exitCode: 1, stderr: 'exploded' })) + + const [status] = yield* ensureManagedBinaries({ foldHome: home }).pipe( + Effect.provide( + resolverLayer({ + registry: [definitionOf()], + env: {}, + spawner: spawner.layer, + http: recordingDownload(binaryBytes).layer, + }), + ), + ) expect(status?.resolution).toBe('unavailable') expect(status?.detail).toContain('exploded') + expect(yield* FileSystem.FileSystem.use((fs) => fs.readDirectory(managedBinDir(home)))).toEqual([]) }).pipe(Effect.provide(NodeFileSystem.layer)), ) -it.effect('memoized ensures share one resolution pass per (foldHome, mode)', () => +it.effect('cached ensures share one resolution pass per (foldHome, mode); memoize false runs fresh', () => Effect.gen(function* () { const home = yield* tempDir const download = recordingDownload(binaryBytes) - const options = { - foldHome: home, - env: emptyEnv, - which: whichOf({}), - download: download.seam, - exec: extractingExec('rg-1.0.0/rg'), - registry: [definitionOf()], - } - - const first = yield* ensureManagedBinaries(options) - const second = yield* ensureManagedBinaries(options) - - expect(first[0]?.resolution).toBe('installed-now') - expect(second[0]?.resolution).toBe('installed-now') - // One download despite two ensure calls: the memoized run was shared. + + const [first, second, fresh] = yield* Effect.all([ + ensureManagedBinaries({ foldHome: home }), + ensureManagedBinaries({ foldHome: home, requireManagedInstall: false }), + ensureManagedBinaries({ foldHome: home, memoize: false }), + ]).pipe( + Effect.provide( + resolverLayer({ + registry: [definitionOf()], + env: {}, + spawner: fakeSpawner(extracting('rg-1.0.0/rg')).layer, + http: download.layer, + }), + ), + ) + + expect(first?.[0]?.resolution).toBe('installed-now') + expect(second?.[0]?.resolution).toBe('installed-now') + // One download despite two cached calls; the fresh pass finds the managed copy. + expect(fresh?.[0]?.resolution).toBe('managed') expect(download.urls).toEqual(['https://example.com/rg-1.0.0.tar.gz']) }).pipe(Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-agent/test/Catalog/LoadCatalog.vi.test.ts b/packages/fold-agent/test/Catalog/LoadCatalog.vi.test.ts index 2295ce2..a3fe354 100644 --- a/packages/fold-agent/test/Catalog/LoadCatalog.vi.test.ts +++ b/packages/fold-agent/test/Catalog/LoadCatalog.vi.test.ts @@ -1,5 +1,5 @@ /** - * loadModelCatalog flow tests (D15) over the in-memory FileSystem, a recording fake fetch seam, and a + * loadModelCatalog flow tests (D15) over the in-memory FileSystem, a recording fake HttpClient, and a * fixed clock - zero network, zero real disk. Covered: a fresh cache short-circuits the fetch; a * stale cache refetches and rewrites the cache; a fetch failure degrades to the stale cache; no cache * plus a fetch failure degrades to the baked snapshot; FOLD_DISABLE_MODELS_FETCH skips the fetch @@ -8,14 +8,9 @@ import { expect, it } from '@effect/vitest' import type { ModelCatalogEntry } from '@humanlayer/fold-core' import { Effect, FileSystem, Layer, Ref } from 'effect' +import { HttpClient, HttpClientResponse } from 'effect/unstable/http' -import { - bakedModelCatalog, - CatalogFetchError, - loadModelCatalog, - modelCatalogCachePath, - FOLD_DISABLE_MODELS_FETCH, -} from '../../src/index' +import { bakedModelCatalog, loadModelCatalog, modelCatalogCachePath, FOLD_DISABLE_MODELS_FETCH } from '../../src/index' import { memoryFileSystem } from '../TestHelpers' const foldHome = '/home/user/.fold' @@ -53,28 +48,35 @@ const fetchedPayload = { }, } -/** A fetch seam that counts calls and yields the given outcome. */ -const recordingFetch = (outcome: Effect.Effect) => +/** An HttpClient that counts calls and answers every request with the given response. */ +const recordingFetch = (respond: () => Response) => Effect.gen(function* () { const calls = yield* Ref.make(0) return { calls: Ref.get(calls), - fetchJson: (_url: string) => Ref.update(calls, (count) => count + 1).pipe(Effect.andThen(outcome)), + layer: Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Ref.update(calls, (count) => count + 1).pipe( + Effect.as(HttpClientResponse.fromWeb(request, respond())), + ), + ), + ), } }) -const failingOutcome = Effect.fail(new CatalogFetchError({ message: 'network unreachable' })) +const fetchedOutcome = () => Response.json(fetchedPayload) +const failingOutcome = () => new Response('network unreachable', { status: 503 }) it.effect('a fresh cache short-circuits the fetch', () => Effect.gen(function* () { - const fetch = yield* recordingFetch(Effect.succeed(fetchedPayload)) + const fetch = yield* recordingFetch(fetchedOutcome) const entries = yield* loadModelCatalog({ foldHome, - fetchJson: fetch.fetchJson, now: Effect.succeed(fixedNow), - }) + }).pipe(Effect.provide(fetch.layer)) expect(entries).toEqual([cachedEntry]) expect(yield* fetch.calls).toBe(0) @@ -88,13 +90,12 @@ it.effect('a fresh cache short-circuits the fetch', () => it.effect('a stale cache refetches, returns the live entries, and rewrites the cache', () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem - const fetch = yield* recordingFetch(Effect.succeed(fetchedPayload)) + const fetch = yield* recordingFetch(fetchedOutcome) const entries = yield* loadModelCatalog({ foldHome, - fetchJson: fetch.fetchJson, now: Effect.succeed(fixedNow), - }) + }).pipe(Effect.provide(fetch.layer)) expect(yield* fetch.calls).toBe(1) expect(entries).toHaveLength(1) @@ -117,9 +118,8 @@ it.effect('a fetch failure degrades to the stale cache with a warning', () => const entries = yield* loadModelCatalog({ foldHome, - fetchJson: fetch.fetchJson, now: Effect.succeed(fixedNow), - }) + }).pipe(Effect.provide(fetch.layer)) expect(yield* fetch.calls).toBe(1) expect(entries).toEqual([cachedEntry]) @@ -136,9 +136,8 @@ it.effect('no cache plus a fetch failure degrades to the baked snapshot', () => const entries = yield* loadModelCatalog({ foldHome, - fetchJson: fetch.fetchJson, now: Effect.succeed(fixedNow), - }) + }).pipe(Effect.provide(fetch.layer)) expect(entries).toBe(bakedModelCatalog) }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, memoryFileSystem({})))), @@ -148,30 +147,31 @@ it.effect('FOLD_DISABLE_MODELS_FETCH skips the fetch: stale cache when present, Effect.gen(function* () { const env = (name: string): string | undefined => (name === FOLD_DISABLE_MODELS_FETCH ? '1' : undefined) - const fetchA = yield* recordingFetch(Effect.succeed(fetchedPayload)) + const fetchA = yield* recordingFetch(fetchedOutcome) const staleEntries = yield* loadModelCatalog({ foldHome, env, - fetchJson: fetchA.fetchJson, now: Effect.succeed(fixedNow), }).pipe( Effect.provide( - Layer.succeed( - FileSystem.FileSystem, - memoryFileSystem({ [cachePath]: cacheFile(fixedNow - 25 * hourMs) }), + Layer.merge( + fetchA.layer, + Layer.succeed( + FileSystem.FileSystem, + memoryFileSystem({ [cachePath]: cacheFile(fixedNow - 25 * hourMs) }), + ), ), ), ) expect(staleEntries).toEqual([cachedEntry]) expect(yield* fetchA.calls).toBe(0) - const fetchB = yield* recordingFetch(Effect.succeed(fetchedPayload)) + const fetchB = yield* recordingFetch(fetchedOutcome) const bakedEntries = yield* loadModelCatalog({ foldHome, env, - fetchJson: fetchB.fetchJson, now: Effect.succeed(fixedNow), - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, memoryFileSystem({})))) + }).pipe(Effect.provide(Layer.merge(fetchB.layer, Layer.succeed(FileSystem.FileSystem, memoryFileSystem({}))))) expect(bakedEntries).toBe(bakedModelCatalog) expect(yield* fetchB.calls).toBe(0) }), @@ -184,13 +184,12 @@ it.effect('corrupt or wrong-version caches read as absent: the fetch runs and re JSON.stringify({ version: 2, fetchedAt: fixedNow, entries: [] }), ]) { const fs = memoryFileSystem({ [cachePath]: corrupt }) - const fetch = yield* recordingFetch(Effect.succeed(fetchedPayload)) + const fetch = yield* recordingFetch(fetchedOutcome) const entries = yield* loadModelCatalog({ foldHome, - fetchJson: fetch.fetchJson, now: Effect.succeed(fixedNow), - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) + }).pipe(Effect.provide(Layer.merge(fetch.layer, Layer.succeed(FileSystem.FileSystem, fs)))) expect(yield* fetch.calls).toBe(1) expect(entries[0]?.modelId).toBe('fetched-model') diff --git a/packages/fold-agent/test/Catalog/Normalize.vi.test.ts b/packages/fold-agent/test/Catalog/Normalize.vi.test.ts index 31a22d2..a75ac66 100644 --- a/packages/fold-agent/test/Catalog/Normalize.vi.test.ts +++ b/packages/fold-agent/test/Catalog/Normalize.vi.test.ts @@ -6,10 +6,11 @@ * malformed model/provider without killing the rest of the catalog. */ import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Effect, Schema } from 'effect' import { decodeModelsDevModels, + ModelsDevPayload, modelCatalogEntriesFromModelsDev, modelCatalogEntryFromModelsDev, } from '../../src/index' @@ -161,9 +162,9 @@ it.effect('a malformed model or provider is skipped; the rest of the catalog sur }), ) -it.effect('a payload that is not a provider map fails with ModelsDevDecodeError', () => +it.effect('a payload that is not a provider map fails to decode at the boundary', () => Effect.gen(function* () { - const error = yield* decodeModelsDevModels('').pipe(Effect.flip) - expect(error._tag).toBe('ModelsDevDecodeError') + const error = yield* Schema.decodeUnknownEffect(ModelsDevPayload)('').pipe(Effect.flip) + expect(error._tag).toBe('SchemaError') }), ) diff --git a/packages/fold-codex/test/CodexImageReadDelivery.vi.test.ts b/packages/fold-agent/test/Codex/CodexImageReadDelivery.vi.test.ts similarity index 84% rename from packages/fold-codex/test/CodexImageReadDelivery.vi.test.ts rename to packages/fold-agent/test/Codex/CodexImageReadDelivery.vi.test.ts index 95d7754..e581651 100644 --- a/packages/fold-codex/test/CodexImageReadDelivery.vi.test.ts +++ b/packages/fold-agent/test/Codex/CodexImageReadDelivery.vi.test.ts @@ -2,12 +2,16 @@ * Deterministic end-to-end regression for the image-read path up to the Codex HTTP boundary. It executes * the real read tool and session prompt builder, then asserts the intended request emitted with no active tools. */ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { expect, it } from '@effect/vitest' -import { Effect, Encoding, Option, Schema } from 'effect' +import { CodexAuthStore, CodexTokenData, layerCodexAuthStore, makeCodexLanguageModel } from '@humanlayer/fold-codex' +import { Effect, Encoding, Layer, Schema } from 'effect' import { FetchHttpClient } from 'effect/unstable/http' -import { CodexTokenData, makeCodexLanguageModel } from '../src/index' -import type { CodexAuthStore } from '../src/index' import { type CapturedFetchRequest, makeCapturingFetch, runImageReadInference } from './SessionModelPathTestHarness' const terminalSse = `data: ${JSON.stringify({ @@ -24,13 +28,6 @@ const token = new CodexTokenData({ accountId: 'acct_capture', }) -const memoryAuthStore: CodexAuthStore = { - path: 'memory://codex-image-read-capture', - load: Effect.succeed(Option.some(token)), - save: (updated) => Effect.succeed(updated), - clear: Effect.void, -} - const CapturedResponsesRequest = Schema.Struct({ model: Schema.String, stream: Schema.Boolean, @@ -49,11 +46,16 @@ it.effect('sends an actual image read as input_image with zero inference tools', ) return Effect.gen(function* () { + const authStorePath = join(mkdtempSync(join(tmpdir(), 'fold-codex-image-read-')), 'auth.json') + yield* Effect.gen(function* () { + const store = yield* CodexAuthStore + yield* store.save(token) + }).pipe(Effect.provide(layerCodexAuthStore({ path: authStorePath }))) const model = yield* makeCodexLanguageModel({ model: 'gpt-5.5', reasoning: 'off', apiUrl: 'https://codex.capture.test/backend-api/codex', - store: memoryAuthStore, + authStorePath, requestRetryTimes: 0, }) const fixture = yield* runImageReadInference(model) @@ -101,5 +103,8 @@ it.effect('sends an actual image read as input_image with zero inference tools', expect(captured.body).not.toContain('Image omitted here') expect(captured.body).not.toContain('The following image content belongs') expect(captured.body).toContain(fixture.sourceImageBase64) - }).pipe(Effect.provideService(FetchHttpClient.Fetch, capturingFetch)) + }).pipe( + Effect.provide(Layer.merge(FetchHttpClient.layer, NodeFileSystem.layer)), + Effect.provideService(FetchHttpClient.Fetch, capturingFetch), + ) }) diff --git a/packages/fold-codex/test/CodexLive.vi.test.ts b/packages/fold-agent/test/Codex/CodexLive.vi.test.ts similarity index 84% rename from packages/fold-codex/test/CodexLive.vi.test.ts rename to packages/fold-agent/test/Codex/CodexLive.vi.test.ts index 8e743d0..95a19cb 100644 --- a/packages/fold-codex/test/CodexLive.vi.test.ts +++ b/packages/fold-agent/test/Codex/CodexLive.vi.test.ts @@ -8,12 +8,17 @@ import { existsSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { describe, expect, it } from '@effect/vitest' -import { Effect, Option, Schema, Stream } from 'effect' +import { makeCodexLanguageModel } from '@humanlayer/fold-codex' +import { Effect, Layer, Option, Schema, Stream } from 'effect' +import { FetchHttpClient } from 'effect/unstable/http' -import { makeCodexLanguageModel } from '../src/index' import { expectedImageIdentification, runImageReadInference } from './SessionModelPathTestHarness' +/** What a Codex model needs from its host: an HTTP client and a filesystem for its credential store. */ +const codexHostServices = Layer.merge(FetchHttpClient.layer, NodeFileSystem.layer) + const authPath = join(homedir(), '.fold', 'auth.json') const decodeCredentialProbe = Schema.decodeUnknownOption( @@ -50,7 +55,7 @@ describe.skipIf(skip)('codex live (skipped in CI or without a codex entry in ~/. expect(response.text.toLowerCase()).toContain('pong') expect(response.finishReason).toBe('stop') - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(codexHostServices)), 180_000, ) @@ -76,7 +81,7 @@ describe.skipIf(skip)('codex live (skipped in CI or without a codex entry in ~/. expect(text.toLowerCase()).toContain('ping') expect(parts.some((part) => part.type === 'finish')).toBe(true) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(codexHostServices)), 180_000, ) }) @@ -94,7 +99,7 @@ describe.skipIf(skipImageRead)( const normalized = result.text.toLowerCase().replaceAll(' ', '').trim() expect(normalized).toContain(expectedImageIdentification) - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(codexHostServices)), 180_000, ) }, diff --git a/packages/fold-codex/test/EffectToolResultEncodingRepro.vi.test.ts b/packages/fold-agent/test/Codex/EffectToolResultEncodingRepro.vi.test.ts similarity index 97% rename from packages/fold-codex/test/EffectToolResultEncodingRepro.vi.test.ts rename to packages/fold-agent/test/Codex/EffectToolResultEncodingRepro.vi.test.ts index 63da14a..9620eb8 100644 --- a/packages/fold-codex/test/EffectToolResultEncodingRepro.vi.test.ts +++ b/packages/fold-agent/test/Codex/EffectToolResultEncodingRepro.vi.test.ts @@ -106,7 +106,7 @@ it.live('passes strings through and emits multipart client tool results in Effec const captured = requests[0] assert.isDefined(captured) - const body = Schema.decodeUnknownSync(CapturedRequest)(JSON.parse(captured.body)) + const body = yield* Schema.decodeUnknownEffect(CapturedRequest)(JSON.parse(captured.body)) const outputs = body.input.filter( (item) => item['type'] === 'function_call_output' && Predicate.isString(item['call_id']), ) diff --git a/packages/fold-codex/test/SessionModelPathTestHarness.ts b/packages/fold-agent/test/Codex/SessionModelPathTestHarness.ts similarity index 74% rename from packages/fold-codex/test/SessionModelPathTestHarness.ts rename to packages/fold-agent/test/Codex/SessionModelPathTestHarness.ts index c20d660..77ebb21 100644 --- a/packages/fold-codex/test/SessionModelPathTestHarness.ts +++ b/packages/fold-agent/test/Codex/SessionModelPathTestHarness.ts @@ -3,34 +3,26 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { deflateSync } from 'node:zlib' -import * as NodeServices from '@effect/platform-node/NodeServices' -import { readTool } from '@humanlayer/fold-agent' import { AgentId, buildPrompt, - CurrentAgent, - CurrentToolCall, EventLog, foldPartOptionsKey, - InterruptNote, layerInMemoryEventLog, LogEntryInputs, MessageId, messagesForAgent, providerToolCallIdKey, - StopController, - Subagents, ToolCallId, - ToolEvents, - ToolState, type FoldTool, - type PlatformServices, - type ToolHandlerServices, type ToolResultLogEntry, } from '@humanlayer/fold-core' -import { Effect, Layer, Predicate, Schema, type Scope, Stream } from 'effect' +import { Effect, Predicate, Schema, Stream } from 'effect' import { type LanguageModel, type Prompt, Toolkit } from 'effect/unstable/ai' +import { readTool } from '../../src/index' +import { callTool, runHandler } from '../TestHelpers' + export const imageIdentificationPrompt = 'Inspect the image returned by read. Name its three vertical color bands from left to right. ' + 'Answer only as left=,middle=,right=.' @@ -123,25 +115,6 @@ export const makeDeterministicColorBandsPng = (): Uint8Array => { ]) } -const toolHandlerTestLayer: Layer.Layer = Layer.mergeAll( - NodeServices.layer, - Layer.succeed(ToolState, { get: () => Effect.succeed(null), set: () => Effect.void }), - Layer.succeed(ToolEvents, { emit: () => Effect.void }), - Layer.succeed(StopController, { requestStop: () => Effect.void, isStopRequested: Effect.succeed(false) }), - Layer.succeed(CurrentAgent, { - agentId: fixtureAgentId, - parentAgentId: null, - }), - Layer.succeed(CurrentToolCall, { toolCallId: ToolCallId.make('tool_call_aaaaaaaaaaaaaaaaaaaaaaaa') }), - Layer.succeed(InterruptNote, { set: () => Effect.void }), - Layer.succeed(Subagents, { - dispatch: () => Effect.die(new Error('Subagents are unavailable in the image-read test harness')), - fork: () => Effect.die(new Error('Subagents are unavailable in the image-read test harness')), - resume: () => Effect.die(new Error('Subagents are unavailable in the image-read test harness')), - continueSubagent: () => Effect.die(new Error('Subagents are unavailable in the image-read test harness')), - }), -) - const decodeJson = Schema.decodeUnknownEffect(Schema.Json) const toolCallIdAt = (index: number): ToolCallId => @@ -152,8 +125,8 @@ const messageIdAt = (index: number): MessageId => export type DurableToolResultInput = { readonly name: string - readonly params: typeof Schema.Json.Type - readonly result: typeof Schema.Json.Type + readonly params: Schema.Json + readonly result: Schema.Json readonly isFailure: boolean readonly providerToolCallId?: string } @@ -164,11 +137,7 @@ export type SessionPromptFixture = { } /** Execute one production Fold tool handler under the same platform and per-call services as runtime. */ -export const executeToolHandler = (tool: FoldTool, params: unknown): Effect.Effect => - tool.init.pipe( - Effect.flatMap((contribution) => contribution.handler(params)), - Effect.provide(toolHandlerTestLayer), - ) +export const executeToolHandler = (tool: FoldTool, params: unknown) => runHandler(callTool(tool, params)) /** Decode a real handler result/failure into the JSON shape accepted by the durable EventLog. */ export const decodeToolResultJson = (result: unknown) => decodeJson(result) @@ -256,41 +225,40 @@ export const buildSessionPromptWithToolResults = (input: { export type ImageReadPromptFixture = { readonly prompt: Prompt.Prompt - readonly readResult: typeof Schema.Json.Type + readonly readResult: Schema.Json readonly sourceImageBase64: string } /** Execute the production read tool, then feed its durable result through the session prompt builder. */ -export const makeImageReadPromptFixture: Effect.Effect = - makeTemporaryTestDirectory('fold-image-read-delivery-').pipe( - Effect.flatMap((directory) => - Effect.gen(function* () { - const sourceImage = makeDeterministicColorBandsPng() - yield* Effect.sync(() => writeFileSync(join(directory, 'visual-fixture.png'), sourceImage)) +export const makeImageReadPromptFixture = makeTemporaryTestDirectory('fold-image-read-delivery-').pipe( + Effect.flatMap((directory) => + Effect.gen(function* () { + const sourceImage = makeDeterministicColorBandsPng() + yield* Effect.sync(() => writeFileSync(join(directory, 'visual-fixture.png'), sourceImage)) - const result = yield* executeToolHandler(readTool({ cwd: directory }), { path: 'visual-fixture.png' }) - const readResult = yield* decodeJson(result) - const { prompt } = yield* buildSessionPromptWithToolResults({ - userText: imageIdentificationPrompt, - toolResults: [ - { - name: 'read', - params: { path: 'visual-fixture.png' }, - result: readResult, - isFailure: false, - providerToolCallId: 'call_image_read_delivery', - }, - ], - }) + const result = yield* executeToolHandler(readTool({ cwd: directory }), { path: 'visual-fixture.png' }) + const readResult = yield* decodeJson(result) + const { prompt } = yield* buildSessionPromptWithToolResults({ + userText: imageIdentificationPrompt, + toolResults: [ + { + name: 'read', + params: { path: 'visual-fixture.png' }, + result: readResult, + isFailure: false, + providerToolCallId: 'call_image_read_delivery', + }, + ], + }) - return { - prompt, - readResult, - sourceImageBase64: Buffer.from(sourceImage).toString('base64'), - } - }), - ), - ) + return { + prompt, + readResult, + sourceImageBase64: Buffer.from(sourceImage).toString('base64'), + } + }), + ), +) /** * Run the exact model-call shape used by AgentRuntime: session-built prompt, streaming, an explicitly diff --git a/packages/fold-codex/test/ToolResultFormattingIntegration.vi.test.ts b/packages/fold-agent/test/Codex/ToolResultFormattingIntegration.vi.test.ts similarity index 96% rename from packages/fold-codex/test/ToolResultFormattingIntegration.vi.test.ts rename to packages/fold-agent/test/Codex/ToolResultFormattingIntegration.vi.test.ts index cb3f60e..21d0ece 100644 --- a/packages/fold-codex/test/ToolResultFormattingIntegration.vi.test.ts +++ b/packages/fold-agent/test/Codex/ToolResultFormattingIntegration.vi.test.ts @@ -8,11 +8,11 @@ import { join } from 'node:path' import { assert, it } from '@effect/vitest' import { OpenAiClient, OpenAiLanguageModel } from '@humanlayer/effect-ai-openai' -import { bashTool, editTool, readTool, writeTool } from '@humanlayer/fold-agent' import { Effect, Layer, Redacted, Schema } from 'effect' import { LanguageModel } from 'effect/unstable/ai' import { FetchHttpClient } from 'effect/unstable/http' +import { bashTool, editTool, readTool, writeTool } from '../../src/index' import { buildSessionPromptWithToolResults, type CapturedFetchRequest, @@ -61,7 +61,7 @@ const expectedModelText = [ 'Successfully replaced 1 block(s) in editable.txt.', ] -const expectedDurableResults: ReadonlyArray = [ +const expectedDurableResults: ReadonlyArray = [ { _tag: 'text', text: '1→read alpha\n2→read beta\n3→' }, { _tag: 'text', text: 'bash success\n' }, { _tag: 'failure', text: 'bash failure\n\n\nCommand exited with code 7' }, @@ -75,10 +75,10 @@ const makeActualBuiltInToolResults = Effect.gen(function* () { writeFileSync(join(directory, 'editable.txt'), 'before edit\nkeep\n') const readResult = yield* executeToolHandler(readTool({ cwd: directory }), { path: 'read-source.txt' }) - const bashSuccessResult = yield* executeToolHandler(bashTool({ cwd: directory, spillDir: directory }), { + const bashSuccessResult = yield* executeToolHandler(bashTool({ cwd: directory }), { command: "printf 'bash success\\n'", }) - const bashFailureResult = yield* executeToolHandler(bashTool({ cwd: directory, spillDir: directory }), { + const bashFailureResult = yield* executeToolHandler(bashTool({ cwd: directory }), { command: "printf 'bash failure\\n'; exit 7", }).pipe(Effect.flip) const writeResult = yield* executeToolHandler(writeTool({ cwd: directory }), { diff --git a/packages/fold-agent/test/Compatibility/CodexCompatibility.vi.test.ts b/packages/fold-agent/test/Compatibility/CodexCompatibility.vi.test.ts index 233a47d..692e5f9 100644 --- a/packages/fold-agent/test/Compatibility/CodexCompatibility.vi.test.ts +++ b/packages/fold-agent/test/Compatibility/CodexCompatibility.vi.test.ts @@ -1,9 +1,14 @@ import { expect, it } from '@effect/vitest' +import type { FoldSkills } from '@humanlayer/fold-core' import { Effect, FileSystem, Path } from 'effect' import { loadCodexCompatibility, loadCodexInstructions, makeCodexSkillSource } from '../../src/index' import { memoryFileSystem } from '../TestHelpers' +/** Build a loader's skill source over the test's in-memory filesystem. */ +const skillsIn = (skills: FoldSkills, fs: FileSystem.FileSystem) => + skills.make.pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) + const skill = (name: string, description: string, marker = name): string => ['---', `name: ${name}`, `description: ${description}`, '---', '', marker].join('\n') @@ -100,8 +105,25 @@ it.effect('loads enabled plugin skills from local or the newest cached version', home: '/home/user', codexHome: '/codex', }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) - const names = (yield* compatibility.skills.list).map(({ name }) => name) + const names = (yield* (yield* skillsIn(compatibility.skills, fs)).list).map(({ name }) => name) expect(names).toEqual(['alpha:right', 'semver:new']) expect(compatibility.diagnostics).toEqual([]) }), ) + +it.effect('skips skills with malformed YAML, wrong field types, or a blank description', () => + Effect.gen(function* () { + const fs = memoryFileSystem({ + '/repo/.agents/skills/broken/SKILL.md': '---\nname: [unterminated\n---\nBroken', + '/repo/.agents/skills/numeric/SKILL.md': '---\nname: 42\ndescription: Numeric name\n---\nBody', + '/repo/.agents/skills/blank/SKILL.md': '---\nname: blank\ndescription: " "\n---\nBody', + '/repo/.agents/skills/plain/SKILL.md': 'No frontmatter', + '/repo/.agents/skills/good/SKILL.md': skill('good', 'Good skill'), + }) + const source = yield* makeCodexSkillSource({ cwd: '/repo', home: '' }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provide(Path.layer), + ) + expect(yield* source.list).toEqual([{ name: 'good', description: 'Good skill' }]) + }), +) diff --git a/packages/fold-agent/test/Compatibility/GrokCompatibility.vi.test.ts b/packages/fold-agent/test/Compatibility/GrokCompatibility.vi.test.ts index b114917..d458fb5 100644 --- a/packages/fold-agent/test/Compatibility/GrokCompatibility.vi.test.ts +++ b/packages/fold-agent/test/Compatibility/GrokCompatibility.vi.test.ts @@ -1,11 +1,16 @@ import { homedir } from 'node:os' import { expect, it } from '@effect/vitest' +import type { FoldSkills } from '@humanlayer/fold-core' import { Effect, FileSystem, Path } from 'effect' import { loadGrokCompatibility, loadGrokInstructions } from '../../src/index' import { memoryFileSystem } from '../TestHelpers' +/** Build a loader's skill source over the test's in-memory filesystem. */ +const skillsIn = (skills: FoldSkills, fs: FileSystem.FileSystem) => + skills.make.pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) + const skill = (name: string, description: string, marker = name): string => ['---', `name: ${name}`, `description: ${description}`, '---', '', marker].join('\n') @@ -65,10 +70,10 @@ it.effect('loads Grok, Agents, Claude, configured, and plugin skills with provid home: '/home/user', configuredPaths: ['/configured'], }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) - const names = (yield* compatibility.skills.list).map(({ name }) => name) + const names = (yield* (yield* skillsIn(compatibility.skills, fs)).list).map(({ name }) => name) expect(names).toEqual(['claude', 'shared', 'agents', 'configured', 'global', 'acme:deploy']) - expect((yield* compatibility.skills.load('shared')).content).toBe('repo grok') + expect((yield* (yield* skillsIn(compatibility.skills, fs)).load('shared')).content).toBe('repo grok') expect(compatibility.diagnostics).toEqual([]) }), ) @@ -87,7 +92,7 @@ it.effect('keeps Codex-only roots out of Grok compatibility', () => }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) expect(compatibility.instructionBlock).toBeNull() - expect(yield* compatibility.skills.list).toEqual([]) + expect(yield* (yield* skillsIn(compatibility.skills, fs)).list).toEqual([]) }), ) @@ -103,7 +108,7 @@ it.effect('reports malformed plugin metadata without failing compatibility loadi home: '/home/user', }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) - expect(yield* compatibility.skills.list).toEqual([]) + expect(yield* (yield* skillsIn(compatibility.skills, fs)).list).toEqual([]) expect(compatibility.diagnostics).toEqual([ { stage: 'manifest', @@ -132,7 +137,9 @@ it.effect('uses the operating-system home for default Grok plugin discovery', () Effect.provide(Path.layer), ) - expect((yield* compatibility.skills.list).map(({ name }) => name)).toContain('default-home:proof') + expect((yield* (yield* skillsIn(compatibility.skills, fs)).list).map(({ name }) => name)).toContain( + 'default-home:proof', + ) }), ) @@ -152,7 +159,7 @@ it.effect('skips malformed skill frontmatter and rejects backslash plugin roots home: '/home/user', }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) - expect(yield* compatibility.skills.list).toEqual([]) + expect(yield* (yield* skillsIn(compatibility.skills, fs)).list).toEqual([]) expect(compatibility.diagnostics).toContainEqual({ stage: 'manifest', code: 'invalid_skill_root', @@ -160,3 +167,28 @@ it.effect('skips malformed skill frontmatter and rejects backslash plugin roots }) }), ) + +it.effect('falls back to the directory name and first paragraph without usable frontmatter', () => + Effect.gen(function* () { + const fs = memoryFileSystem({ + '/repo/.grok/skills/plain/SKILL.md': '# Plain skill\r\n\r\nMore detail.', + '/repo/.grok/skills/typed/SKILL.md': '---\nname: 42\ndescription: Ignored\n---\n# Typed skill\n\nBody', + '/repo/.grok/skills/named/SKILL.md': '---\nname: renamed\n---\nFirst paragraph\n\nSecond', + }) + + const compatibility = yield* loadGrokCompatibility({ + cwd: '/repo', + projectRoot: '/repo', + home: '', + }).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provide(Path.layer)) + + expect(yield* (yield* skillsIn(compatibility.skills, fs)).list).toEqual([ + { name: 'renamed', description: 'First paragraph' }, + { name: 'plain', description: 'Plain skill' }, + { name: 'typed', description: 'Typed skill' }, + ]) + expect((yield* (yield* skillsIn(compatibility.skills, fs)).load('plain')).content).toBe( + '# Plain skill\n\nMore detail.', + ) + }), +) diff --git a/packages/fold-agent/test/Config/ConfigLoad.vi.test.ts b/packages/fold-agent/test/Config/ConfigLoad.vi.test.ts index e463af5..6d84201 100644 --- a/packages/fold-agent/test/Config/ConfigLoad.vi.test.ts +++ b/packages/fold-agent/test/Config/ConfigLoad.vi.test.ts @@ -4,7 +4,7 @@ * FileSystem (never touches the real disk). */ import { expect, it } from '@effect/vitest' -import { Effect, FileSystem, Layer, Predicate } from 'effect' +import { Effect, FileSystem, Predicate } from 'effect' import { loadFoldConfig, loadFoldConfigOrNull, parseFoldConfig, stripJsonc } from '../../src/index' import { memoryFileSystem } from '../TestHelpers' @@ -85,7 +85,7 @@ it.effect('loads and decodes the config file from the fold home', () => { const config = yield* loadFoldConfig({ foldHome: '/home/user/.fold' }) expect(config.roles.smart.model).toBe('claude-opus-4-8') - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) }) it.effect('fails with ConfigFileNotFoundError when the file is absent; OrNull returns null', () => { @@ -96,5 +96,5 @@ it.effect('fails with ConfigFileNotFoundError when the file is absent; OrNull re const orNull = yield* loadFoldConfigOrNull({ foldHome: '/home/user/.fold' }) expect(orNull).toBeNull() - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) }) diff --git a/packages/fold-agent/test/Config/ConfigProfiles.vi.test.ts b/packages/fold-agent/test/Config/ConfigProfiles.vi.test.ts index 3482900..bdb6b8c 100644 --- a/packages/fold-agent/test/Config/ConfigProfiles.vi.test.ts +++ b/packages/fold-agent/test/Config/ConfigProfiles.vi.test.ts @@ -101,6 +101,21 @@ const withProfileRoles = (config: FoldConfig, profile: ProfileConfig): FoldConfi return { ...config, roles } } +const expectEveryBindingAtMax = (profiles: ReadonlyArray): void => { + for (const profile of profiles) { + expect(profile).toBeDefined() + if (profile === undefined) continue + for (const binding of profileBindings(profile)) { + expect(binding.reasoning, binding.model).toBe('max') + const model = binding.model + expect(model).toBeDefined() + if (model === undefined) continue + const entry = lookupCatalogEntry(bakedModelCatalog, syntheticOpenAiModel(model)) + expect(entry?.reasoningEfforts, model).toContain('max') + } + } +} + it.effect('the starter config ships the ultraclaude, powerclaude, and ultracodex everything-max RLM presets', () => Effect.gen(function* () { const config = yield* parseFoldConfig(starterConfigJsonc()) @@ -126,18 +141,7 @@ it.effect('the starter config ships the ultraclaude, powerclaude, and ultracodex // Every binding runs at max, and the baked catalog confirms every pinned model supports it - // this is what makes `reasoning: "max"` a known-good shipped default rather than a hope. - for (const profile of [ultraclaude, powerclaude, ultracodex]) { - expect(profile).toBeDefined() - if (profile === undefined) continue - for (const binding of profileBindings(profile)) { - expect(binding.reasoning, binding.model).toBe('max') - const model = binding.model - expect(model).toBeDefined() - if (model === undefined) continue - const entry = lookupCatalogEntry(bakedModelCatalog, syntheticOpenAiModel(model)) - expect(entry?.reasoningEfforts, model).toContain('max') - } - } + expectEveryBindingAtMax([ultraclaude, powerclaude, ultracodex]) }), ) diff --git a/packages/fold-agent/test/Config/ConfigSchemaJson.vi.test.ts b/packages/fold-agent/test/Config/ConfigSchemaJson.vi.test.ts index a824171..432bc50 100644 --- a/packages/fold-agent/test/Config/ConfigSchemaJson.vi.test.ts +++ b/packages/fold-agent/test/Config/ConfigSchemaJson.vi.test.ts @@ -5,7 +5,7 @@ * existing config. All filesystem work is over an in-memory FileSystem. */ import { expect, it } from '@effect/vitest' -import { Effect, FileSystem, JsonSchema, Layer } from 'effect' +import { Effect, FileSystem, JsonSchema } from 'effect' import { configInit, @@ -108,5 +108,5 @@ it.effect('configInit writes the schema and a starter config, then never clobber const configFile = yield* memoryFileFor(fs, second.configPath) expect(configFile).toBe('{ "edited": true }') expect(yield* memoryFileFor(fs, second.authPath)).toBe('{ "codex": { "access": "tok" } }') - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) }) diff --git a/packages/fold-agent/test/Config/ProviderConfig.vi.test.ts b/packages/fold-agent/test/Config/ProviderConfig.vi.test.ts index 9e211f4..05bca94 100644 --- a/packages/fold-agent/test/Config/ProviderConfig.vi.test.ts +++ b/packages/fold-agent/test/Config/ProviderConfig.vi.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { expect, it } from '@effect/vitest' -import { Effect, FileSystem, Layer } from 'effect' +import { Effect, FileSystem } from 'effect' import { configureProvider, describeModelConfiguration, loadFoldConfig, starterConfigJsonc } from '../../src/index' import { memoryFileSystem, tempDir } from '../TestHelpers' @@ -189,5 +189,5 @@ it.effect('does not replace a malformed existing config', () => { ).pipe(Effect.flip) expect(error._tag).toBe('ConfigParseError') - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) }) diff --git a/packages/fold-agent/test/EventLog/EventLogJsonl.vi.test.ts b/packages/fold-agent/test/EventLog/EventLogJsonl.vi.test.ts index 0b80a59..1789588 100644 --- a/packages/fold-agent/test/EventLog/EventLogJsonl.vi.test.ts +++ b/packages/fold-agent/test/EventLog/EventLogJsonl.vi.test.ts @@ -13,7 +13,7 @@ import { StateId, type LogEntryInput, } from '@humanlayer/fold-core' -import { Effect, Fiber, FileSystem, Predicate, Stream } from 'effect' +import { Effect, Fiber, FileSystem, Predicate, type Schema, Stream } from 'effect' import { layerJsonl } from '../../src/index' @@ -28,7 +28,7 @@ const makeSessionStarted = (cwd: string): LogEntryInput => ({ meta: {}, }) -const makeToolState = (value: unknown): LogEntryInput => ({ +const makeToolState = (value: Schema.Json): LogEntryInput => ({ _tag: 'tool_state', agentId: AgentId.create(), parentAgentId: null, diff --git a/packages/fold-agent/test/Memory/AgentFiles.vi.test.ts b/packages/fold-agent/test/Memory/AgentFiles.vi.test.ts index 472aaae..512baef 100644 --- a/packages/fold-agent/test/Memory/AgentFiles.vi.test.ts +++ b/packages/fold-agent/test/Memory/AgentFiles.vi.test.ts @@ -5,7 +5,7 @@ * and the `` rendering shape. */ import { expect, it } from '@effect/vitest' -import { Effect, FileSystem, Layer } from 'effect' +import { Effect, FileSystem } from 'effect' import { loadMemoryFiles, memoryPromptBlock, renderMemoryFiles } from '../../src/index' import { memoryFileSystem } from '../TestHelpers' @@ -31,7 +31,7 @@ it.effect('collects global then root..cwd, base first then local overlay', () => // AGENTS.md wins over CLAUDE.md in /repo. expect(files.some((file) => file.path === '/repo/CLAUDE.md')).toBe(false) expect(files[1]?.content).toBe('repo base') - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) }) it.effect('loads a local overlay even when the directory has no base file', () => { @@ -42,7 +42,7 @@ it.effect('loads a local overlay even when the directory has no base file', () = return Effect.gen(function* () { const files = yield* loadMemoryFiles({ cwd: '/repo', home: '/home/user' }) expect(files.map((file) => file.path)).toEqual(['/repo/CLAUDE.local.md']) - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) }) it.effect('global chain: falls through to ~/.agents then ~/.codex (first existing wins)', () => { @@ -54,7 +54,7 @@ it.effect('global chain: falls through to ~/.agents then ~/.codex (first existin return Effect.gen(function* () { const files = yield* loadMemoryFiles({ cwd: '/work', home: '/home/user' }) expect(files.map((file) => file.path)).toEqual(['/home/user/.codex/AGENTS.md', '/work/AGENTS.md']) - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) }) it.effect('renders one project_context block with a project_instructions per file', () => { @@ -66,7 +66,7 @@ it.effect('renders one project_context block with a project_instructions per fil expect(block ?? '').toContain('') expect(block ?? '').toContain('') expect(block ?? '').toContain('do the thing') - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) }) it('renders null for an empty set', () => { @@ -78,5 +78,5 @@ it.effect('returns null block when no agentfiles exist for the cwd', () => { return Effect.gen(function* () { const block = yield* memoryPromptBlock({ cwd: '/repo', home: '/home/user' }) expect(block).toBeNull() - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) }) diff --git a/packages/fold-agent/test/Mode/Launch.vi.test.ts b/packages/fold-agent/test/Mode/Launch.vi.test.ts index 408b7b9..58fe583 100644 --- a/packages/fold-agent/test/Mode/Launch.vi.test.ts +++ b/packages/fold-agent/test/Mode/Launch.vi.test.ts @@ -7,10 +7,10 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' +import * as NodeServices from '@effect/platform-node/NodeServices' import { expect, it } from '@effect/vitest' import { customModel, layerLiveIdFactory, type ActiveModel, type FoldModel } from '@humanlayer/fold-core' -import { Effect, Predicate, Stream } from 'effect' +import { Effect, Layer, Predicate, Stream } from 'effect' import { LanguageModel, type Response } from 'effect/unstable/ai' import { @@ -25,7 +25,11 @@ import { rlmMode, switchSessionMode, } from '../../src/index' -import { tempDir } from '../TestHelpers' +import { Photon } from '../../src/Tools/Image/Photon' +import { offlineHttpClient, tempDir } from '../TestHelpers' + +/** What a launch needs from its host: the platform, image processing, and an offline HTTP client. */ +const launchTestServices = Layer.mergeAll(NodeServices.layer, offlineHttpClient, Photon.layer) // The Ids service is provided by the runtime in production (see cli.ts). Tests supply the same live // factory so `launchSession` mints real ids; swap in `layerDeterministicIds` where determinism matters. @@ -125,7 +129,7 @@ it.effect('launchSession composes the model, agentfiles, and mode tools over sta expect(tools).toContain('subagent') }), ) - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.provide(launchTestServices)), ) it.effect('launchSession with rpi appends the hint block after the mode prompt', () => @@ -155,7 +159,7 @@ it.effect('launchSession with rpi appends the hint block after the mode prompt', expect(leadingJson.indexOf(RPI_HINT_PROMPT)).toBeGreaterThan(leadingJson.indexOf(DEFAULT_CODING_PROMPT)) }), ) - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.provide(launchTestServices)), ) it.effect('switchSessionMode preserves identity and writes one recomposed mode epoch', () => @@ -187,7 +191,7 @@ it.effect('switchSessionMode preserves identity and writes one recomposed mode e expect(JSON.stringify(switchedPrompt)).toContain(RPI_HINT_PROMPT) }), ) - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.provide(launchTestServices)), ) it.effect('resumeLatestSession adopts the newest log for the working directory', () => @@ -222,7 +226,7 @@ it.effect('resumeLatestSession adopts the newest log for the working directory', expect(JSON.stringify(entries)).toContain('first message') }), ) - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.provide(launchTestServices)), ) it.effect('resumeSessionById adopts an exact session id from the current project directory', () => @@ -251,7 +255,7 @@ it.effect('resumeSessionById adopts an exact session id from the current project expect(JSON.stringify(yield* resumed.entries)).toContain('remember this by id') }), ) - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.provide(launchTestServices)), ) it.effect('resumeSessionById is scoped to the selected cwd project slug', () => @@ -275,7 +279,7 @@ it.effect('resumeSessionById is scoped to the selected cwd project slug', () => }).pipe(Effect.scoped, Effect.flip) expect(error._tag).toBe('SessionToResumeNotFoundError') - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.provide(launchTestServices)), ) it.effect('launchSession resolves CLI-style model selection overrides through fold-agent config', () => @@ -313,7 +317,7 @@ it.effect('launchSession resolves CLI-style model selection overrides through fo } }), ) - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.provide(launchTestServices)), ) it.effect('a direct Codex launch replaces the complete mixed-provider role map', () => @@ -354,7 +358,7 @@ it.effect('a direct Codex launch replaces the complete mixed-provider role map', } }), ) - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.provide(launchTestServices)), ) it.effect('launchSession wires session profiles end to end: role-bound roster starts and setProfile works', () => @@ -383,7 +387,7 @@ it.effect('launchSession wires session profiles end to end: role-bound roster st yield* session.setProfile('fast', alwaysTextModel('rebound')) }), ) - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.provide(launchTestServices)), ) const namedProfileConfigText = `{ @@ -437,7 +441,7 @@ it.effect('--profile substitutes the profile roles and applies its pinned rlm mo expect(JSON.stringify(leading)).toContain(RPI_HINT_PROMPT) }), ) - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.provide(launchTestServices)), ) it.effect('an explicit mode option beats the profile pinned mode', () => @@ -465,7 +469,7 @@ it.effect('an explicit mode option beats the profile pinned mode', () => expect(started.tools).toContain('bash') }), ) - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.provide(launchTestServices)), ) it.effect('an unknown --profile fails with UnknownProfileError naming what exists', () => @@ -483,7 +487,7 @@ it.effect('an unknown --profile fails with UnknownProfileError naming what exist if (!Predicate.isTagged(error, 'UnknownProfileError')) return expect(error.profile).toBe('nope') expect(error.available).toEqual(['ultratest']) - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.provide(launchTestServices)), ) it.effect('resumeLatestSession fails with NoSessionToResumeError when none exist for the cwd', () => @@ -496,7 +500,7 @@ it.effect('resumeLatestSession fails with NoSessionToResumeError when none exist Effect.flip, ) expect(error._tag).toBe('NoSessionToResumeError') - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.provide(launchTestServices)), ) // --- mergeModelSelection: the CLI --provider/--model/--reasoning merge over a config binding ---------- diff --git a/packages/fold-agent/test/Mode/Rlm.vi.test.ts b/packages/fold-agent/test/Mode/Rlm.vi.test.ts index d0334d8..2babf8c 100644 --- a/packages/fold-agent/test/Mode/Rlm.vi.test.ts +++ b/packages/fold-agent/test/Mode/Rlm.vi.test.ts @@ -51,9 +51,9 @@ const models: ModeModels = { orchestrator: namedModel('orchestrator-model'), } -const rlmTools = (): ReadonlyArray => rlmMode.buildTools({ cwd: '/tmp/project', models, rpi: false }) +const rlmTools = (): ReadonlyArray> => rlmMode.buildTools({ cwd: '/tmp/project', models, rpi: false }) -const toolNames = (tools: ReadonlyArray): ReadonlyArray => tools.map((tool) => tool.name) +const toolNames = (tools: ReadonlyArray>): ReadonlyArray => tools.map((tool) => tool.name) it('is named rlm and runs on the orchestrator role', () => { expect(rlmMode.name).toBe('rlm') diff --git a/packages/fold-agent/test/Mode/Rpi.vi.test.ts b/packages/fold-agent/test/Mode/Rpi.vi.test.ts index 1660c94..c31bf39 100644 --- a/packages/fold-agent/test/Mode/Rpi.vi.test.ts +++ b/packages/fold-agent/test/Mode/Rpi.vi.test.ts @@ -62,16 +62,16 @@ const models: ModeModels = { orchestrator: namedModel('orchestrator-model'), } -const byNameIn = (definitions: ReadonlyArray, name: string): SubagentDefinition => { +const byNameIn = (definitions: ReadonlyArray>, name: string): SubagentDefinition => { const found = definitions.find((definition) => definition.name === name) if (found === undefined) throw new Error(`no subagent named ${name}`) return found } -const toolNames = (tools: ReadonlyArray): ReadonlyArray => tools.map((tool) => tool.name) +const toolNames = (tools: ReadonlyArray>): ReadonlyArray => tools.map((tool) => tool.name) /** The roster a definition may dispatch: the union of its subagentTool values' rosters. */ -const dispatchableFrom = (definition: SubagentDefinition): ReadonlyArray => +const dispatchableFrom = (definition: SubagentDefinition): ReadonlyArray => (definition.tools ?? []).flatMap((tool) => (subagentRosterOf(tool) ?? []).map((agent) => agent.name)) // Built ONCE at module scope: the identity assertions compare against these exact delegate instances. diff --git a/packages/fold-agent/test/Mode/Subagents.vi.test.ts b/packages/fold-agent/test/Mode/Subagents.vi.test.ts index 62a3770..06bd2ca 100644 --- a/packages/fold-agent/test/Mode/Subagents.vi.test.ts +++ b/packages/fold-agent/test/Mode/Subagents.vi.test.ts @@ -46,18 +46,18 @@ const models: ModeModels = { orchestrator: namedModel('orchestrator-model'), } -const roster = (): ReadonlyArray => defaultSubagents({ cwd: '/tmp/project' }) +const roster = (): ReadonlyArray> => defaultSubagents({ cwd: '/tmp/project' }) -const byName = (name: string): SubagentDefinition => { +const byName = (name: string): SubagentDefinition => { const found = roster().find((definition) => definition.name === name) if (found === undefined) throw new Error(`no subagent named ${name}`) return found } -const toolNames = (tools: ReadonlyArray): ReadonlyArray => tools.map((tool) => tool.name) +const toolNames = (tools: ReadonlyArray>): ReadonlyArray => tools.map((tool) => tool.name) /** The roster a definition may dispatch: the union of its subagentTool values' rosters. */ -const dispatchableFrom = (definition: SubagentDefinition): ReadonlyArray => +const dispatchableFrom = (definition: SubagentDefinition): ReadonlyArray => (definition.tools ?? []).flatMap((tool) => (subagentRosterOf(tool) ?? []).map((agent) => agent.name)) it('registers general-purpose, bash, researcher, and web-search-researcher', () => { @@ -94,10 +94,7 @@ it('gives researcher read + bash + skill only - no editing tools, no way to dele } expect(dispatchableFrom(researcher)).toEqual([]) // The shared ast-grep outline guidance rides as a second leading block after the ported prompt. - expect(Array.isArray(researcher.systemPrompt)).toBe(true) - if (Array.isArray(researcher.systemPrompt)) { - expect(researcher.systemPrompt.at(-1)).toBe(AST_GREP_OUTLINE_GUIDANCE) - } + expect(researcher.systemPrompt).toEqual([expect.any(String), AST_GREP_OUTLINE_GUIDANCE]) }) it('gives web-search-researcher web tools only and no way to delegate', () => { diff --git a/packages/fold-agent/test/OutputStore/OutputStore.vi.test.ts b/packages/fold-agent/test/OutputStore/OutputStore.vi.test.ts index 2e4ee6a..04f29a9 100644 --- a/packages/fold-agent/test/OutputStore/OutputStore.vi.test.ts +++ b/packages/fold-agent/test/OutputStore/OutputStore.vi.test.ts @@ -5,7 +5,13 @@ import { expect, it } from '@effect/vitest' import { SessionId, ToolCallId } from '@humanlayer/fold-core' import { Effect } from 'effect' -import { makeOutputStore, toolOutputPathFor } from '../../src/OutputStore/OutputStore' +import { + layerOutputStore, + OutputStore, + sweepToolOutput, + toolOutputPathFor, + toolOutputSessionDirFor, +} from '../../src/OutputStore/OutputStore' import { tempDir } from '../TestHelpers' it.effect('stores tool output at a deterministic session/tool-call path', () => @@ -13,16 +19,18 @@ it.effect('stores tool output at a deterministic session/tool-call path', () => const root = yield* tempDir const sessionId = SessionId.make('sess_aaaaaaaaaaaaaaaaaaaaaaaa') const toolCallId = ToolCallId.make('tool_call_bbbbbbbbbbbbbbbbbbbbbbbb') - const store = yield* makeOutputStore({ sessionId, foldHome: root }) const expectedPath = toolOutputPathFor({ sessionId, toolCallId, foldHome: root }) - const first = yield* store.append(toolCallId, 'one\n') - const second = yield* store.append(toolCallId, 'two\nthree') + yield* Effect.gen(function* () { + const store = yield* OutputStore + const first = yield* store.append(toolCallId, 'one\n') + const second = yield* store.append(toolCallId, 'two\nthree') - expect(first.path).toBe(expectedPath) - expect(second.path).toBe(expectedPath) - expect(yield* store.read(first)).toBe('one\ntwo\nthree') - expect(yield* store.read(first, { offset: 2, limit: 1 })).toBe('two') + expect(first.path).toBe(expectedPath) + expect(second.path).toBe(expectedPath) + expect(yield* store.read(first)).toBe('one\ntwo\nthree') + expect(yield* store.read(first, { offset: 2, limit: 1 })).toBe('two') + }).pipe(Effect.provide(layerOutputStore({ directory: toolOutputSessionDirFor({ sessionId, foldHome: root }) }))) }).pipe(Effect.provide(NodeFileSystem.layer)), ) @@ -31,14 +39,16 @@ it.live('sweeps old stored output files best-effort', () => const root = yield* tempDir const sessionId = SessionId.make('sess_cccccccccccccccccccccccc') const toolCallId = ToolCallId.make('tool_call_dddddddddddddddddddddddd') - const store = yield* makeOutputStore({ sessionId, foldHome: root, retentionMs: 1 }) - const ref = yield* store.append(toolCallId, 'old output') + const ref = yield* Effect.gen(function* () { + const store = yield* OutputStore + return yield* store.append(toolCallId, 'old output') + }).pipe(Effect.provide(layerOutputStore({ directory: toolOutputSessionDirFor({ sessionId, foldHome: root }) }))) const old = new Date(0) utimesSync(ref.path, old, old) - expect(yield* store.read(ref)).toBe('old output') + expect(existsSync(ref.path)).toBe(true) - yield* store.sweep + yield* sweepToolOutput({ foldHome: root, retentionMs: 1 }) expect(existsSync(ref.path)).toBe(false) }).pipe(Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-agent/test/Session/ViewedChanges.vi.test.ts b/packages/fold-agent/test/Session/ViewedChanges.vi.test.ts index 04c8d07..7390906 100644 --- a/packages/fold-agent/test/Session/ViewedChanges.vi.test.ts +++ b/packages/fold-agent/test/Session/ViewedChanges.vi.test.ts @@ -1,6 +1,6 @@ import { expect, it } from '@effect/vitest' import { SessionId } from '@humanlayer/fold-core' -import { Effect, FileSystem, Layer } from 'effect' +import { Effect, FileSystem } from 'effect' import { loadViewedPatchHashes, saveViewedPatchHash } from '../../src/index' import { memoryFileSystem } from '../TestHelpers' @@ -19,5 +19,5 @@ it.effect('persists latest viewed patch hashes per session and ignores corrupt r expect(yield* loadViewedPatchHashes(first, options)).toEqual({ 'unstaged:app.ts': 'new' }) expect(yield* loadViewedPatchHashes(second, options)).toEqual({ 'unstaged:app.ts': 'other-session' }) - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) }) diff --git a/packages/fold-agent/test/Skills/DiskSkills.vi.test.ts b/packages/fold-agent/test/Skills/DiskSkills.vi.test.ts index 0b1913b..cee4671 100644 --- a/packages/fold-agent/test/Skills/DiskSkills.vi.test.ts +++ b/packages/fold-agent/test/Skills/DiskSkills.vi.test.ts @@ -5,7 +5,7 @@ * handling, and baseDir wiring. */ import { expect, it } from '@effect/vitest' -import { Effect, FileSystem, Layer, Predicate } from 'effect' +import { Effect, FileSystem, Predicate } from 'effect' import { makeDiskSkillSource } from '../../src/index' import { memoryFileSystem } from '../TestHelpers' @@ -46,7 +46,7 @@ it.effect('scans Claude and Fold roots across home, git root, and cwd with later ['review', 'Repo review skill'], ]), ) - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) }) it.effect('loads Claude project skills independently of AGENTS.md', () => { @@ -60,7 +60,7 @@ it.effect('loads Claude project skills independently of AGENTS.md', () => { const source = yield* makeDiskSkillSource({ cwd: '/repo', home: '/home/user' }) expect(yield* source.list).toEqual([{ name: 'claude-only', description: 'Claude-compatible skill' }]) - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) }) it.effect('skips the git-root scan when the repo root IS the cwd (no double scan)', () => { @@ -74,7 +74,7 @@ it.effect('skips the git-root scan when the repo root IS the cwd (no double scan const metas = yield* source.list expect(metas).toEqual([{ name: 'solo', description: 'Only skill' }]) - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) }) it.effect('defaults the name from the skill directory and sets baseDir', () => { @@ -89,24 +89,29 @@ it.effect('defaults the name from the skill directory and sets baseDir', () => { expect(skill.name).toBe('from-dir-name') expect(skill.baseDir).toBe('/cwd/.agents/skills/from-dir-name') expect(skill.content).toBe('Do the thing.') - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) }) -it.effect('skips skills violating the spec (invalid name, missing description) without failing', () => { - const fs = memoryFileSystem({ - '/cwd/.agents/skills/Bad--Name/SKILL.md': skillFile(null, 'Invalid directory-derived name'), - '/cwd/.agents/skills/no-description/SKILL.md': ['---', 'name: no-description', '---', 'body'].join('\n'), - '/cwd/.agents/skills/no-frontmatter/SKILL.md': 'just a plain markdown file', - '/cwd/.agents/skills/good/SKILL.md': skillFile('good', 'A valid skill'), - }) +it.effect( + 'skips skills violating the spec (invalid name, missing description, bad frontmatter) without failing', + () => { + const fs = memoryFileSystem({ + '/cwd/.agents/skills/Bad--Name/SKILL.md': skillFile(null, 'Invalid directory-derived name'), + '/cwd/.agents/skills/no-description/SKILL.md': ['---', 'name: no-description', '---', 'body'].join('\n'), + '/cwd/.agents/skills/no-frontmatter/SKILL.md': 'just a plain markdown file', + '/cwd/.agents/skills/bad-yaml/SKILL.md': '---\nname: [unterminated\n---\nbody', + '/cwd/.agents/skills/numeric-name/SKILL.md': '---\nname: 42\ndescription: Numeric name\n---\nbody', + '/cwd/.agents/skills/good/SKILL.md': skillFile('good', 'A valid skill'), + }) - return Effect.gen(function* () { - const source = yield* makeDiskSkillSource({ cwd: '/cwd', home: '/home/user' }) - const metas = yield* source.list + return Effect.gen(function* () { + const source = yield* makeDiskSkillSource({ cwd: '/cwd', home: '/home/user' }) + const metas = yield* source.list - expect(metas).toEqual([{ name: 'good', description: 'A valid skill' }]) - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) -}) + expect(metas).toEqual([{ name: 'good', description: 'A valid skill' }]) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) + }, +) it.effect('finds nested skill groups but does not recurse into a skill directory', () => { const fs = memoryFileSystem({ @@ -120,7 +125,7 @@ it.effect('finds nested skill groups but does not recurse into a skill directory const metas = yield* source.list expect(metas).toEqual([{ name: 'one', description: 'Grouped skill' }]) - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) }) it.effect('load fails with the roster for unknown names', () => { @@ -135,7 +140,7 @@ it.effect('load fails with the roster for unknown names', () => { expect(failure._tag).toBe('SkillNotFoundError') if (!Predicate.isTagged(failure, 'SkillNotFoundError')) throw new Error('expected SkillNotFoundError') expect(failure.availableSkills).toEqual(['present']) - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) }) it.effect('a fresh scan per list picks up newly added skills (the refresh path)', () => { @@ -151,7 +156,7 @@ it.effect('a fresh scan per list picks up newly added skills (the refresh path)' yield* fs.writeFileString('/cwd/.agents/skills/second/SKILL.md', skillFile('second', 'Added later')) expect((yield* source.list).map((meta) => meta.name)).toEqual(['first', 'second']) - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) }) it.effect('parses CRLF SKILL.md files without corrupting fields (trailing \\r regression)', () => { @@ -169,7 +174,7 @@ it.effect('parses CRLF SKILL.md files without corrupting fields (trailing \\r re expect(skill.name).toBe('crlf-skill') expect(skill.description).toBe('Written on Windows') expect(skill.content).toBe('Body line.') - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) }) it.effect('supports extra scan roots with highest precedence', () => { @@ -186,5 +191,5 @@ it.effect('supports extra scan roots with highest precedence', () => { }) expect(yield* source.list).toEqual([{ name: 'tool', description: 'From extra root' }]) - }).pipe(Effect.provide(Layer.succeed(FileSystem.FileSystem, fs))) + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)) }) diff --git a/packages/fold-agent/test/Skills/SkillFrontmatter.vi.test.ts b/packages/fold-agent/test/Skills/SkillFrontmatter.vi.test.ts new file mode 100644 index 0000000..324ea4c --- /dev/null +++ b/packages/fold-agent/test/Skills/SkillFrontmatter.vi.test.ts @@ -0,0 +1,71 @@ +import { expect, it } from '@effect/vitest' +import { Effect, Option } from 'effect' + +import { parseSkillFile, skillNameOr, splitSkillFile } from '../../src/Skills/SkillFrontmatter' + +it.effect('parses frontmatter fields and the trimmed body, ignoring unknown keys', () => + Effect.gen(function* () { + const document = yield* parseSkillFile( + ['---', 'name: deploy', 'description: Ship it', 'allowed-tools: [Bash]', '---', '', 'Body.', ''].join('\n'), + ) + + expect(document).toEqual({ frontmatter: { name: 'deploy', description: 'Ship it' }, body: 'Body.' }) + }), +) + +it.effect('decodes a bare key as null and an absent key as missing', () => + Effect.gen(function* () { + const { frontmatter } = yield* parseSkillFile(['---', 'name:', '---', 'Body.'].join('\n')) + + expect(frontmatter).toEqual({ name: null }) + expect(skillNameOr(frontmatter, 'from-dir')).toBe('from-dir') + }), +) + +it.effect('normalizes CRLF and CR so no field carries a trailing \\r', () => + Effect.gen(function* () { + const crlf = yield* parseSkillFile(['---', 'description: Windows', 'name: crlf', '---', 'Body.'].join('\r\n')) + const cr = yield* parseSkillFile(['---', 'name: cr', '---', 'Body.'].join('\r')) + + expect(crlf).toEqual({ frontmatter: { name: 'crlf', description: 'Windows' }, body: 'Body.' }) + expect(cr).toEqual({ frontmatter: { name: 'cr' }, body: 'Body.' }) + }), +) + +it.effect('fails with SkillFrontmatterMissing when there is no frontmatter block', () => + Effect.gen(function* () { + const plain = yield* parseSkillFile('just markdown').pipe(Effect.flip) + const unclosed = yield* parseSkillFile('---\nname: open\nno closing fence').pipe(Effect.flip) + + expect(plain._tag).toBe('SkillFrontmatterMissing') + expect(unclosed._tag).toBe('SkillFrontmatterMissing') + expect(splitSkillFile('# Title\r\n\r\nText.\r\n')).toEqual({ + frontmatter: Option.none(), + body: '# Title\n\nText.', + }) + }), +) + +it.effect('fails with SkillFrontmatterInvalidYaml when the YAML does not parse', () => + Effect.gen(function* () { + const error = yield* parseSkillFile('---\nname: [unterminated\n---\nBody').pipe(Effect.flip) + + expect(error._tag).toBe('SkillFrontmatterInvalidYaml') + }), +) + +it.effect('fails with SkillFrontmatterInvalidFields for wrong field types or a non-mapping document', () => + Effect.gen(function* () { + const numericName = yield* parseSkillFile('---\nname: 42\ndescription: ok\n---\nBody').pipe(Effect.flip) + const listDescription = yield* parseSkillFile('---\ndescription: [a, b]\n---\nBody').pipe(Effect.flip) + const scalarDocument = yield* parseSkillFile('---\njust a string\n---\nBody').pipe(Effect.flip) + const emptyDocument = yield* parseSkillFile('---\n---\nBody').pipe(Effect.flip) + + expect([numericName, listDescription, scalarDocument, emptyDocument].map(({ _tag }) => _tag)).toEqual([ + 'SkillFrontmatterInvalidFields', + 'SkillFrontmatterInvalidFields', + 'SkillFrontmatterInvalidFields', + 'SkillFrontmatterInvalidFields', + ]) + }), +) diff --git a/packages/fold-agent/test/TestHelpers.ts b/packages/fold-agent/test/TestHelpers.ts index 4dae814..19c7adb 100644 --- a/packages/fold-agent/test/TestHelpers.ts +++ b/packages/fold-agent/test/TestHelpers.ts @@ -1,6 +1,7 @@ /** * Shared fixtures for fold-agent tool tests: ambient tool services (recorded ToolEvents, no-op - * ToolState/StopController), scoped temp directories on the real filesystem, and an in-memory + * ToolState/StopController, the platform, photon, and an output store in a scoped temp directory), scoped temp + * directories on the real filesystem, and an in-memory * FileSystem built on `FileSystem.makeNoop` for tests that must not touch the user's disk (skill scan * paths reach the home directory). */ @@ -16,73 +17,19 @@ import { CurrentToolCall, InterruptNote, StopController, - Subagents, ToolCallId, ToolEvents, + ToolResultFailure, + ToolResultSuccess, ToolState, type FoldTool, - type PlatformServices, - type ToolHandlerServices, } from '@humanlayer/fold-core' -import { Effect, FileSystem, Layer, PlatformError, Ref, type Schema } from 'effect' +import { Effect, FileSystem, Layer, type Path, PlatformError, Ref, Schema, type Scope } from 'effect' +import { HttpClient, HttpClientError } from 'effect/unstable/http' +import type { ChildProcessSpawner } from 'effect/unstable/process' -/** Run a tool handler effect with stubbed ambient services and recorded ToolEvents/InterruptNote feeds. */ -export const makeAmbientServices = (): Effect.Effect<{ - readonly layer: Layer.Layer - readonly emitted: Effect.Effect> - /** The most recent InterruptNote the handler recorded, or null. */ - readonly interruptNote: Effect.Effect -}> => - Effect.gen(function* () { - const events = yield* Ref.make>([]) - const note = yield* Ref.make(null) - - return { - layer: Layer.mergeAll( - NodeServices.layer, - Layer.succeed(ToolState, { get: () => Effect.succeed(null), set: () => Effect.void }), - Layer.succeed(ToolEvents, { - emit: (payload) => Ref.update(events, (recorded) => [...recorded, payload]), - }), - Layer.succeed(StopController, { - requestStop: () => Effect.void, - isStopRequested: Effect.succeed(false), - }), - Layer.succeed(CurrentAgent, { - agentId: AgentId.make('agent_aaaaaaaaaaaaaaaaaaaaaaaa'), - parentAgentId: null, - }), - Layer.succeed(CurrentToolCall, { - toolCallId: ToolCallId.make('tool_call_aaaaaaaaaaaaaaaaaaaaaaaa'), - }), - Layer.succeed(InterruptNote, { set: (text) => Ref.set(note, text) }), - Layer.succeed(Subagents, { - dispatch: () => Effect.die(new Error('Subagents not available in this test')), - fork: () => Effect.die(new Error('Subagents not available in this test')), - resume: () => Effect.die(new Error('Subagents not available in this test')), - continueSubagent: () => Effect.die(new Error('Subagents not available in this test')), - }), - NodeFileSystem.layer, - ), - emitted: Ref.get(events), - interruptNote: Ref.get(note), - } - }) - -/** Invoke one tool's handler through its init: the realized handler, still needing the ambient R. */ -export const handlerOf = - (tool: FoldTool) => - (params: unknown): Effect.Effect => - tool.init.pipe(Effect.flatMap((contribution) => contribution.handler(params))) - -/** Run one handler with throwaway ambient services. */ -export const runHandler = ( - effect: Effect.Effect, -): Effect.Effect => - Effect.gen(function* () { - const ambient = yield* makeAmbientServices() - return yield* effect.pipe(Effect.provide(ambient.layer)) - }) +import { layerOutputStore, type OutputStore } from '../src/OutputStore/OutputStore' +import { Photon } from '../src/Tools/Image/Photon' /** A scoped temp directory on the real filesystem, removed when the scope closes. */ export const tempDir = Effect.acquireRelease( @@ -90,6 +37,147 @@ export const tempDir = Effect.acquireRelease( (directory) => Effect.sync(() => rmSync(directory, { recursive: true, force: true })), ) +/** Run a tool handler effect with stubbed ambient services and recorded ToolEvents/InterruptNote feeds. */ +export const makeAmbientServices: Effect.Effect< + { + /** The per-call services, the platform, an output store, and the real photon image library. */ + readonly layer: Layer.Layer< + | ToolState + | ToolEvents + | StopController + | CurrentAgent + | CurrentToolCall + | InterruptNote + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + > + readonly emitted: Effect.Effect> + /** The most recent InterruptNote the handler recorded, or null. */ + readonly interruptNote: Effect.Effect + /** Where the output store writes, removed when the scope closes. */ + readonly outputDirectory: string + }, + never, + Scope.Scope +> = Effect.gen(function* () { + const events = yield* Ref.make>([]) + const note = yield* Ref.make(null) + const outputDirectory = yield* tempDir + + return { + layer: Layer.mergeAll( + NodeServices.layer, + Layer.succeed(ToolState, { get: () => Effect.succeed(null), set: () => Effect.void }), + Layer.succeed(ToolEvents, { + emit: (payload) => Ref.update(events, (recorded) => [...recorded, payload]), + }), + Layer.succeed(StopController, { + requestStop: () => Effect.void, + isStopRequested: Effect.succeed(false), + }), + Layer.succeed(CurrentAgent, { + agentId: AgentId.make('agent_aaaaaaaaaaaaaaaaaaaaaaaa'), + parentAgentId: null, + }), + Layer.succeed(CurrentToolCall, { + toolCallId: ToolCallId.make('tool_call_aaaaaaaaaaaaaaaaaaaaaaaa'), + }), + Layer.succeed(InterruptNote, { set: (text) => Ref.set(note, text) }), + layerOutputStore({ directory: outputDirectory }).pipe(Layer.provide(NodeFileSystem.layer)), + Photon.layer, + ), + emitted: Ref.get(events), + interruptNote: Ref.get(note), + outputDirectory, + } +}) + +/** One realized tool call: it needs the per-call services plus the tool's own host services `R`. */ +type ToolCall = Effect.Effect< + ToolResultSuccess, + ToolResultFailure, + ToolState | ToolEvents | StopController | CurrentAgent | CurrentToolCall | InterruptNote | R +> + +/** + * Initialize a tool the way the session does and return its handler. A realized handler's success and + * failure are erased; every built-in tool succeeds with `ToolResultSuccess` and fails with + * `ToolResultFailure`, so both are decoded back at this test boundary. Anything else is a defect. + */ +export const realizeTool = ( + tool: FoldTool, +): Effect.Effect<(params: unknown) => ToolCall, never, R | Scope.Scope> => + Effect.map( + tool.init, + (contribution) => + (params): ToolCall => + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- the erased handler is decoded here + contribution.handler(params).pipe( + Effect.catch((error) => + Schema.decodeUnknownEffect(ToolResultFailure)(error).pipe( + Effect.orDie, + Effect.flatMap(Effect.fail), + ), + ), + Effect.flatMap((result) => + Schema.decodeUnknownEffect(ToolResultSuccess)(result).pipe(Effect.orDie), + ), + ), + ) + +/** Call a tool through its init, the way the runtime does. */ +export const callTool = ( + tool: FoldTool, + params: unknown, +): Effect.Effect< + ToolResultSuccess, + ToolResultFailure, + ToolState | ToolEvents | StopController | CurrentAgent | CurrentToolCall | InterruptNote | R | Scope.Scope +> => Effect.flatMap(realizeTool(tool), (handler) => handler(params)) + +export const handlerOf = + (tool: FoldTool) => + ( + params: unknown, + ): Effect.Effect< + ToolResultSuccess, + ToolResultFailure, + ToolState | ToolEvents | StopController | CurrentAgent | CurrentToolCall | InterruptNote | R | Scope.Scope + > => + callTool(tool, params) + +/** Run one handler with throwaway ambient services; anything else it needs, the caller provides. */ +export const runHandler = ( + effect: Effect.Effect, +): Effect.Effect< + A, + E, + Exclude< + Exclude< + R, + | ToolState + | ToolEvents + | StopController + | CurrentAgent + | CurrentToolCall + | InterruptNote + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + >, + Scope.Scope + > +> => + Effect.gen(function* () { + const ambient = yield* makeAmbientServices + return yield* effect.pipe(Effect.provide(ambient.layer)) + }).pipe(Effect.scoped) + const notFound = (method: string, path: string) => PlatformError.systemError({ _tag: 'NotFound', @@ -160,24 +248,28 @@ export const memoryFileSystem = (initialFiles: Record): FileSyst /** Read one file back out of a memory filesystem fixture (test assertion helper). */ 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`) - } + fs.readFileString(path).pipe(Effect.orElseSucceed(() => null)) + +/** An HttpClient with no network: every request fails at the transport with "network down". */ +export const offlineHttpClient: Layer.Layer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request, description: 'network down' }), + }), + ), + ), +) + +const TextResult = Schema.Struct({ text: Schema.String }) +const decodeTextResult = Schema.decodeUnknownSync(TextResult) -/** The `message` field of a tool success/failure value. */ -export const messageOf: (value: unknown) => string = stringField('text') +/** The `text` field of a tool success/failure value. */ +export const messageOf = (value: unknown): string => decodeTextResult(value).text -/** The `output` field of a bash tool success value. */ -export const outputOf: (value: unknown) => string = stringField('text') +/** The `text` field of a bash tool success value. */ +export const outputOf = (value: unknown): string => decodeTextResult(value).text const parentDirs = (path: string): ReadonlyArray => { const parents: Array = [] diff --git a/packages/fold-agent/test/Tools/BashTool.vi.test.ts b/packages/fold-agent/test/Tools/BashTool.vi.test.ts index 3819ec3..3849419 100644 --- a/packages/fold-agent/test/Tools/BashTool.vi.test.ts +++ b/packages/fold-agent/test/Tools/BashTool.vi.test.ts @@ -7,12 +7,11 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' -import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { expect, it } from '@effect/vitest' -import { SessionId, ToolCallId } from '@humanlayer/fold-core' import { Duration, Effect, Fiber } from 'effect' +import { FetchHttpClient } from 'effect/unstable/http' -import { bashTool, codingTools, decodeBashOutputDelta, makeOutputStore, toolOutputPathFor } from '../../src/index' +import { bashTool, codingTools, decodeBashOutputDelta } from '../../src/index' import { handlerOf, makeAmbientServices, messageOf, outputOf, runHandler, tempDir } from '../TestHelpers' it.live('captures stdout and reports success on exit 0', () => @@ -27,7 +26,7 @@ it.live('captures stdout and reports success on exit 0', () => it.live('omits null-containing stdout with a model-actionable notice', () => Effect.gen(function* () { const dir = yield* tempDir - const ambient = yield* makeAmbientServices() + const ambient = yield* makeAmbientServices const result = yield* handlerOf(bashTool({ cwd: dir }))({ command: `printf 'before\\0after'` }).pipe( Effect.provide(ambient.layer), ) @@ -78,7 +77,9 @@ it.live('forwards host-provided session environment through codingTools', () => }).find((tool) => tool.name === 'bash') if (bash === undefined) throw new Error('expected codingTools to include Bash') - const result = yield* runHandler(handlerOf(bash)({ command: 'printf "%s" "$HUMANLAYER_SESSION_ID"' })) + const result = yield* runHandler(handlerOf(bash)({ command: 'printf "%s" "$HUMANLAYER_SESSION_ID"' })).pipe( + Effect.provide(FetchHttpClient.layer), + ) expect(outputOf(result)).toBe('coding-tools-session') }), @@ -186,7 +187,7 @@ it.live('timeout kills the whole process group, including grandchildren', () => it.live('emits schema-typed stdout/stderr deltas while running', () => Effect.gen(function* () { const dir = yield* tempDir - const ambient = yield* makeAmbientServices() + const ambient = yield* makeAmbientServices yield* handlerOf(bashTool({ cwd: dir }))({ command: 'echo to-stdout && echo to-stderr 1>&2' }).pipe( Effect.provide(ambient.layer), @@ -211,7 +212,11 @@ it.live('emits schema-typed stdout/stderr deltas while running', () => it.live('tail-truncates long output and spills the full output to a file', () => Effect.gen(function* () { const dir = yield* tempDir - const result = yield* runHandler(handlerOf(bashTool({ cwd: dir, spillDir: dir }))({ command: 'seq 1 3000' })) + // Keep the ambient output store open while the spill file is read back. + const ambient = yield* makeAmbientServices + const result = yield* handlerOf(bashTool({ cwd: dir }))({ command: 'seq 1 3000' }).pipe( + Effect.provide(ambient.layer), + ) const output = outputOf(result) // Tail direction: the last lines survive, the head is cut. pi's line accounting: the trailing @@ -229,23 +234,21 @@ it.live('tail-truncates long output and spills the full output to a file', () => }), ) -it.live('uses OutputStore for deterministic bash spill paths when provided', () => +it.live('spills to the output store at a deterministic per-tool-call path', () => Effect.gen(function* () { const dir = yield* tempDir - const sessionId = SessionId.make('sess_eeeeeeeeeeeeeeeeeeeeeeee') - const toolCallId = ToolCallId.make('tool_call_aaaaaaaaaaaaaaaaaaaaaaaa') - const outputStore = yield* makeOutputStore({ sessionId, foldHome: dir }) - const ambient = yield* makeAmbientServices() + const ambient = yield* makeAmbientServices - const result = yield* handlerOf(bashTool({ cwd: dir, outputStore }))({ command: 'seq 1 3000' }).pipe( + const result = yield* handlerOf(bashTool({ cwd: dir }))({ command: 'seq 1 3000' }).pipe( Effect.provide(ambient.layer), ) - const expectedPath = toolOutputPathFor({ sessionId, toolCallId, foldHome: dir }) + // The ambient CurrentToolCall id names the file inside the provided store's directory. + const expectedPath = join(ambient.outputDirectory, 'tool_call_aaaaaaaaaaaaaaaaaaaaaaaa.txt') expect(outputOf(result)).toContain(`Full output: ${expectedPath}`) expect(readFileSync(expectedPath, 'utf-8')).toContain('1\n2\n3\n') expect(readFileSync(expectedPath, 'utf-8')).toContain('2999\n3000\n') - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.scoped), ) it.live('byte-limit truncation reports the size-limited notice with the spill path', () => @@ -253,7 +256,7 @@ it.live('byte-limit truncation reports the size-limited notice with the spill pa const dir = yield* tempDir // ~100KB of output in few lines: byte limit binds before the line limit. const result = yield* runHandler( - handlerOf(bashTool({ cwd: dir, spillDir: dir }))({ + handlerOf(bashTool({ cwd: dir }))({ command: `for i in $(seq 1 100); do printf 'x%.0s' $(seq 1 1024); printf '\\n'; done`, }), ) @@ -324,8 +327,8 @@ it.live('an empty-output timeout reports only the status (no "(no output)" prefi it.live('streams output to the spill file as it is written; interruption notes the path (ruling 5)', () => Effect.gen(function* () { const dir = yield* tempDir - const ambient = yield* makeAmbientServices() - const tool = bashTool({ cwd: dir, spillDir: dir }) + const ambient = yield* makeAmbientServices + const tool = bashTool({ cwd: dir }) const commandFiber = yield* Effect.forkChild( handlerOf(tool)({ command: 'echo early-output; sleep 30' }).pipe( @@ -337,9 +340,9 @@ it.live('streams output to the spill file as it is written; interruption notes t // The streamed chunk lands on disk long before the command could finish (stream-as-written). const spill = yield* Effect.gen(function* () { while (true) { - const file = readdirSync(dir).find((name) => name.startsWith('fold-bash-')) + const file = readdirSync(ambient.outputDirectory).find((name) => name.endsWith('.txt')) if (file !== undefined) { - const path = join(dir, file) + const path = join(ambient.outputDirectory, file) const content = readFileSync(path, 'utf8') if (content.includes('early-output')) return { path, content } } diff --git a/packages/fold-agent/test/Tools/ImageProcess.vi.test.ts b/packages/fold-agent/test/Tools/ImageProcess.vi.test.ts new file mode 100644 index 0000000..9e99e1f --- /dev/null +++ b/packages/fold-agent/test/Tools/ImageProcess.vi.test.ts @@ -0,0 +1,130 @@ +import { expect, it } from '@effect/vitest' +/** + * The image pipeline through its public Effect: real photon for pass-through, conversion, resizing, and + * decode failures; a photon whose encoders always overshoot the cap for `too-large`; and a photon that + * failed to load for `unavailable`. Every failure maps to pi's verbatim "[Image omitted: ...]" note. + */ +import * as PhotonNode from '@silvia-odwyer/photon-node' +import { Effect, Layer, Ref } from 'effect' + +import { ImageProcessError, imageOmittedNote, processImage } from '../../src/index' +import { Photon } from '../../src/Tools/Image/Photon' + +const onePixelPngBase64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' + +/** A solid PNG of the given size, encoded by photon itself. */ +const solidPng = (width: number, height: number): Uint8Array => { + const image = new PhotonNode.PhotonImage(new Uint8Array(width * height * 4).fill(200), width, height) + const bytes = image.get_bytes() + image.free() + return bytes +} + +/** Minimal valid 1x1 24-bit BMP. */ +const onePixelBmp = (): Uint8Array => { + const bytes = new Uint8Array(58) + const view = new DataView(bytes.buffer) + bytes[0] = 0x42 + bytes[1] = 0x4d + view.setUint32(2, 58, true) + view.setUint32(10, 54, true) + view.setUint32(14, 40, true) + view.setInt32(18, 1, true) + view.setInt32(22, 1, true) + view.setUint16(26, 1, true) + view.setUint16(28, 24, true) + view.setUint32(34, 4, true) + bytes[54] = 0xff + return bytes +} + +it.effect('passes a small image through unchanged', () => + Effect.gen(function* () { + const processed = yield* processImage(Buffer.from(onePixelPngBase64, 'base64'), 'image/png') + expect(processed).toEqual({ data: onePixelPngBase64, mimeType: 'image/png', hints: [] }) + }).pipe(Effect.provide(Photon.layer)), +) + +it.effect('converts BMP to PNG with a conversion hint', () => + Effect.gen(function* () { + const processed = yield* processImage(onePixelBmp(), 'image/bmp') + expect(processed.mimeType).toBe('image/png') + expect(processed.hints).toEqual(['[Image converted from image/bmp to image/png.]']) + expect(Buffer.from(processed.data, 'base64').subarray(1, 4).toString()).toBe('PNG') + }).pipe(Effect.provide(Photon.layer)), +) + +it.effect('resizes an image wider than 2000px and reports the scale', () => + Effect.gen(function* () { + const processed = yield* processImage(solidPng(4000, 10), 'image/png') + expect(processed.mimeType).toBe('image/png') + expect(processed.hints).toEqual([ + '[Image: original 4000x10, displayed at 2000x5. Multiply coordinates by 2.00 to map to original image.]', + ]) + }).pipe(Effect.provide(Photon.layer)), +) + +it.effect('fails with decode-failed for bytes photon cannot read', () => + Effect.gen(function* () { + const error = yield* Effect.flip(processImage(new Uint8Array([1, 2, 3, 4]), 'image/png')) + expect(error).toBeInstanceOf(ImageProcessError) + expect({ stage: error.stage, reason: error.reason }).toEqual({ stage: 'resize', reason: 'decode-failed' }) + expect(imageOmittedNote(error)).toBe('[Image omitted: could not be resized below the inline image size limit.]') + }).pipe(Effect.provide(Photon.layer)), +) + +it.effect('fails with too-large when no encoding fits, after downscaling to 1x1', () => + Effect.gen(function* () { + const resizes = yield* Ref.make>([]) + // Real photon, except every encoding is one byte over the base64 cap. + const oversized = new Uint8Array(Math.ceil((4.5 * 1024 * 1024) / 4) * 3 + 3) + const overshootingPhoton = Layer.effect( + Photon, + Effect.gen(function* () { + const real = yield* Photon + return Photon.of({ + ...real, + resize: (image, width, height) => + Ref.update(resizes, (seen) => [...seen, `${width}x${height}`]).pipe( + Effect.andThen(real.resize(image, width, height)), + ), + encodePng: () => Effect.succeed(oversized), + encodeJpeg: () => Effect.succeed(oversized), + }) + }), + ).pipe(Layer.provide(Photon.layer)) + + const error = yield* Effect.flip(processImage(solidPng(2001, 1), 'image/png')).pipe( + Effect.provide(overshootingPhoton), + ) + expect({ stage: error.stage, reason: error.reason }).toEqual({ stage: 'resize', reason: 'too-large' }) + expect(imageOmittedNote(error)).toBe('[Image omitted: could not be resized below the inline image size limit.]') + const seen = yield* Ref.get(resizes) + expect(seen[0]).toBe('2000x1') + expect(seen.at(-1)).toBe('1x1') + }), +) + +it.effect('fails with unavailable when photon did not load, keeping the stage note', () => + Effect.gen(function* () { + const convertError = yield* Effect.flip(processImage(onePixelBmp(), 'image/bmp')) + expect({ stage: convertError.stage, reason: convertError.reason }).toEqual({ + stage: 'convert', + reason: 'unavailable', + }) + expect(convertError.cause?.cause).toBe('wasm load failed') + expect(imageOmittedNote(convertError)).toBe( + '[Image omitted: could not be converted to a supported inline image format.]', + ) + + const resizeError = yield* Effect.flip(processImage(Buffer.from(onePixelPngBase64, 'base64'), 'image/png')) + expect({ stage: resizeError.stage, reason: resizeError.reason }).toEqual({ + stage: 'resize', + reason: 'unavailable', + }) + expect(imageOmittedNote(resizeError)).toBe( + '[Image omitted: could not be resized below the inline image size limit.]', + ) + }).pipe(Effect.provide(Photon.layerUnavailable('wasm load failed'))), +) diff --git a/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts b/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts index f1fcf6e..0cf57e8 100644 --- a/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts +++ b/packages/fold-agent/test/Tools/WebFetchTool.vi.test.ts @@ -10,6 +10,8 @@ import { createServer, type Server } from 'node:http' import { it } from '@effect/vitest' import { ToolResultMultipart, ToolResultText } from '@humanlayer/fold-core' import { Effect, Schema } from 'effect' +import { constVoid } from 'effect/Function' +import { FetchHttpClient } from 'effect/unstable/http' import { afterAll, beforeAll, expect } from 'vitest' import { webFetchTool } from '../../src/index' @@ -52,7 +54,7 @@ const firstText = (result: unknown): string => { const fetchResult = ( url: string, options?: { readonly format?: 'markdown' | 'text' | 'html'; readonly timeout_seconds?: number }, -) => runHandler(handlerOf(webFetchTool())({ url, ...options })) +) => runHandler(handlerOf(webFetchTool())({ url, ...options })).pipe(Effect.provide(FetchHttpClient.layer)) let server: Server let baseUrl = '' @@ -78,7 +80,7 @@ beforeAll(async () => { if (path === '/streamed-huge') { // No content-length: the streaming cap is the only guard. 6MB in 1MB chunks trips it at 5MB. response.writeHead(200, { 'content-type': 'application/octet-stream' }) - response.on('error', () => {}) + response.on('error', constVoid) for (let index = 0; index < 6; index++) response.write(Buffer.alloc(1024 * 1024, index)) response.end() return @@ -89,7 +91,7 @@ beforeAll(async () => { 'content-type': 'application/octet-stream', 'content-length': String(6 * 1024 * 1024), }) - response.on('error', () => {}) + response.on('error', constVoid) response.end(Buffer.alloc(16)) return } diff --git a/packages/fold-agent/test/Tools/WebTimeouts.vi.test.ts b/packages/fold-agent/test/Tools/WebTimeouts.vi.test.ts index 33aae9c..51d9ced 100644 --- a/packages/fold-agent/test/Tools/WebTimeouts.vi.test.ts +++ b/packages/fold-agent/test/Tools/WebTimeouts.vi.test.ts @@ -1,17 +1,18 @@ import { it } from '@effect/vitest' import { webFetchToolContract, webSearchToolContract, type FoldTool } from '@humanlayer/fold-core' -import { Effect, Fiber, Schema } from 'effect' +import { Effect, Fiber, Layer, Schema } from 'effect' import { TestClock } from 'effect/testing' -import { FetchHttpClient } from 'effect/unstable/http' +import { FetchHttpClient, type HttpClient } from 'effect/unstable/http' import { expect } from 'vitest' +import type { Photon } from '../../src/Tools/Image/Photon' import { webFetchTool } from '../../src/Tools/WebFetchTool' import { webSearchTool } from '../../src/Tools/WebSearchTool' -import { handlerOf, messageOf, runHandler } from '../TestHelpers' +import { handlerOf, makeAmbientServices, messageOf, realizeTool, runHandler } from '../TestHelpers' const cases: ReadonlyArray<{ readonly name: string - readonly tool: () => FoldTool + readonly tool: () => FoldTool readonly params: Record readonly timeoutMs: number readonly message: string @@ -85,7 +86,17 @@ for (const testCase of cases) { }), { preconnect: globalThis.fetch.preconnect }, ) - const fiber = yield* runHandler(handlerOf(testCase.tool())(testCase.params)).pipe( + // Build the ambient services (photon loads for real) and the tool first, so the clock only times + // the request. + const ambient = yield* makeAmbientServices + const services = yield* Layer.build(ambient.layer) + const handler = yield* realizeTool(testCase.tool()).pipe( + Effect.provideContext(services), + Effect.provide(FetchHttpClient.layer), + ) + const fiber = yield* handler(testCase.params).pipe( + Effect.provideContext(services), + Effect.provide(FetchHttpClient.layer), Effect.provideService(FetchHttpClient.Fetch, fetch), Effect.flip, Effect.forkChild, @@ -133,7 +144,7 @@ it.effect('search clears its timeout after a successful response', () => ) const result = yield* runHandler( handlerOf(webSearchTool({ provider: 'exa' }))({ query: 'test', timeout_seconds: 1 }), - ).pipe(Effect.provideService(FetchHttpClient.Fetch, fetch)) + ).pipe(Effect.provide(FetchHttpClient.layer), Effect.provideService(FetchHttpClient.Fetch, fetch)) expect(messageOf(result)).toBe('Search result') yield* TestClock.adjust('2 seconds') expect(signals).toHaveLength(1) diff --git a/packages/fold-cli/src/Ansi.ts b/packages/fold-cli/src/Ansi.ts index c4397d4..41fc726 100644 --- a/packages/fold-cli/src/Ansi.ts +++ b/packages/fold-cli/src/Ansi.ts @@ -14,7 +14,7 @@ const wrap = enabled ? `${open}${text}${close}` : text /** ANSI color helpers used by the headless renderer. */ -export const makeAnsiPalette = (enabled: boolean): AnsiPalette => ({ +export const ansiPalette = (enabled: boolean): AnsiPalette => ({ bold: wrap(enabled, '\u001b[1m', '\u001b[22m'), dim: wrap(enabled, '\u001b[2m', '\u001b[22m'), cyan: wrap(enabled, '\u001b[36m', '\u001b[39m'), diff --git a/packages/fold-cli/src/Commands.ts b/packages/fold-cli/src/Commands.ts index 731d027..1fa24db 100644 --- a/packages/fold-cli/src/Commands.ts +++ b/packages/fold-cli/src/Commands.ts @@ -21,24 +21,45 @@ import { type FoldModeName, } from '@humanlayer/fold-agent' import { - makeCodexAuth, - makeCodexAuthStore, + CodexAuth, + CodexAuthStore, + layerCodexAuth, + layerCodexAuthStore, type CodexAuthError, - type MakeCodexAuthStoreOptions, + type CodexAuthOptions, } from '@humanlayer/fold-codex' 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 { + layerOpenCodeAuth, + layerOpenCodeAuthStore, + OpenCodeAuth, + OpenCodeAuthStore, + type OpenCodeAuthError, + type OpenCodeAuthOptions, +} from '@humanlayer/fold-opencode' +import { + layerXaiAuth, + layerXaiAuthStore, + XaiAuth, + XaiAuthStore, + type XaiAuthError, + type XaiAuthOptions, +} from '@humanlayer/fold-xai' +import { Array as Arr, Clock, Console, Effect, Layer, Match, Option, Schema } from 'effect' import { type CliError, Command, Flag } from 'effect/unstable/cli' -import { FetchHttpClient } from 'effect/unstable/http' -import { makeJsonOutputRenderer, makePromptOutputRenderer, type JsonOutputMode } from './Renderer' +import { jsonOutputRenderer, promptOutputRenderer, type JsonOutputMode } from './Renderer' import { ResumeTarget, runPrompt, type CliSessionOptions } from './Run' declare const FOLD_VERSION: string +/** The release version the native build defines as a global; source runs have no such global. */ +// oxlint-disable-next-line anti-slop/no-runtime-typeof -- probes a build-time global that may not be defined const version = typeof FOLD_VERSION === 'string' ? FOLD_VERSION : '0.0.0' +/** Whether the CLI runs on Bun (the native package); the full-screen TUI needs Bun's runtime. */ +// oxlint-disable-next-line anti-slop/no-runtime-typeof -- probes for the Bun global, absent under Node +const isBunRuntime = typeof Bun !== 'undefined' + type Mutable = { -readonly [Key in keyof Type]: Type[Key] } const decodeSessionId = Schema.decodeUnknownOption(SessionId) @@ -201,33 +222,34 @@ const codexProviderId = (provider: Option.Option): string => optionValue const providerId = (provider: Option.Option, fallback: string): string => optionValue(provider) ?? fallback -const providerAuthStoreOptions = (provider: Option.Option, foldHome: string | undefined, fallback: string) => { - const path = authStorePath(foldHome) - const options: Mutable = { providerId: providerId(provider, fallback) } - if (path !== undefined) options.path = path - return options +/** Where a provider's credential lives: its own entry in the fold home's auth document. */ +const authStoreOptionsFor = ( + input: ProviderAuthInput, + fallback: string, +): { readonly providerId: string; readonly path?: string } => { + const path = authStorePath(optionValue(input.foldHome)) + const id = providerId(input.provider, fallback) + return path === undefined ? { providerId: id } : { providerId: id, path } } -const codexAuthStoreOptions = ( - provider: Option.Option, - foldHome: string | undefined, -): MakeCodexAuthStoreOptions => { - const path = authStorePath(foldHome) - const options: Mutable = { providerId: codexProviderId(provider) } - if (path !== undefined) options.path = path - return options -} +/** Codex auth and its credential store for the selected provider. */ +const codexAuthLayer = (input: ProviderAuthInput, options?: CodexAuthOptions) => + layerCodexAuth(options).pipe(Layer.provideMerge(layerCodexAuthStore(authStoreOptionsFor(input, 'codex')))) -const browserOpenCommand = (url: string): { readonly command: string; readonly args: ReadonlyArray } => { - switch (process.platform) { - case 'darwin': - return { command: 'open', args: [url] } - case 'win32': - return { command: 'cmd', args: ['/c', 'start', '', url] } - default: - return { command: 'xdg-open', args: [url] } - } -} +/** xAI auth and its credential store for the selected provider. */ +const xaiAuthLayer = (input: ProviderAuthInput, options?: XaiAuthOptions) => + layerXaiAuth(options).pipe(Layer.provideMerge(layerXaiAuthStore(authStoreOptionsFor(input, 'xai')))) + +/** OpenCode auth and its credential store for the selected provider. */ +const openCodeAuthLayer = (input: ProviderAuthInput, options?: OpenCodeAuthOptions) => + layerOpenCodeAuth(options).pipe(Layer.provideMerge(layerOpenCodeAuthStore(authStoreOptionsFor(input, 'opencode')))) + +const browserOpenCommand = (url: string): { readonly command: string; readonly args: ReadonlyArray } => + Match.value(process.platform).pipe( + Match.when('darwin', () => ({ command: 'open', args: [url] })), + Match.when('win32', () => ({ command: 'cmd', args: ['/c', 'start', '', url] })), + Match.orElse(() => ({ command: 'xdg-open', args: [url] })), + ) const openUrlInBrowser = (url: string): Effect.Effect => Effect.try({ @@ -353,7 +375,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 && !isBunRuntime) { yield* printFailure( 'The full-screen TUI requires the native @humanlayer/fold package. Use foldcode --prompt "..." or install @humanlayer/fold globally.', ) @@ -374,8 +396,8 @@ const run = Command.make('foldcode', commonFlags, (input) => } const renderer = outputMode === 'human' - ? makePromptOutputRenderer({ colors: !input.noColor, verbose: input.verbose, catalog }) - : makeJsonOutputRenderer({ mode: outputMode }) + ? promptOutputRenderer({ colors: !input.noColor, verbose: input.verbose, catalog }) + : jsonOutputRenderer({ mode: outputMode }) const finished = yield* runPrompt({ ...sessionOptions, prompt }, renderer) if (finished.outcome === 'completed') return @@ -398,7 +420,7 @@ const run = Command.make('foldcode', commonFlags, (input) => const launchTui = (options: CliSessionOptions, catalog: ReadonlyArray, prompt?: string) => Effect.gen(function* () { - if (typeof Bun === 'undefined') { + if (!isBunRuntime) { yield* printFailure( 'The full-screen TUI requires the native @humanlayer/fold package. Use foldcode --prompt "..." or install @humanlayer/fold globally.', ) @@ -456,7 +478,7 @@ const sessions = Command.make( const sessionOptions: Mutable[0]>> = { cwd } if (foldHome !== undefined) sessionOptions.foldHome = foldHome const sessions = yield* listSessionLogs(sessionOptions) - if (sessions.length === 0) { + if (Arr.isReadonlyArrayEmpty(sessions)) { yield* Console.log(`No fold sessions for ${cwd}`) return } @@ -470,7 +492,7 @@ const sessions = Command.make( const tui = Command.make('tui', commonFlags, (input) => Effect.scoped( Effect.gen(function* () { - if (typeof Bun === 'undefined') { + if (!isBunRuntime) { yield* printFailure( 'The full-screen TUI requires the native @humanlayer/fold package. Use foldcode --prompt "..." or install @humanlayer/fold globally.', ) @@ -575,18 +597,8 @@ type ProviderLoginInput = ProviderAuthInput & { readonly noOpen: boolean } const openCodeLogin = (input: ProviderLoginInput) => Effect.gen(function* () { - const foldHome = optionValue(input.foldHome) - const store = makeOpenCodeAuthStore(providerAuthStoreOptions(input.provider, foldHome, 'opencode')) - const openCodeAuth = yield* makeOpenCodeAuth({ - store, - onDeviceCode: (prompt) => - printAuthUrl({ - heading: `OpenCode device login\n\nCode: ${prompt.userCode}`, - url: prompt.url, - noOpen: input.noOpen, - waiting: 'Waiting for approval...', - }), - }).pipe(Effect.provide(FetchHttpClient.layer)) + const store = yield* OpenCodeAuthStore + const openCodeAuth = yield* OpenCodeAuth yield* Console.log( `Using OpenCode device authentication for provider "${providerId(input.provider, 'opencode')}"`, ) @@ -597,7 +609,19 @@ const openCodeLogin = (input: ProviderLoginInput) => yield* Console.log( `Saved OpenCode credential${identity} to ${store.path} (expires ${expiryText(token.expires)})`, ) - }) + }).pipe( + Effect.provide( + openCodeAuthLayer(input, { + onDeviceCode: (prompt) => + printAuthUrl({ + heading: `OpenCode device login\n\nCode: ${prompt.userCode}`, + url: prompt.url, + noOpen: input.noOpen, + waiting: 'Waiting for approval...', + }), + }), + ), + ) const openCodeCommands = Command.make('opencode').pipe( Command.withDescription('Manage OpenCode OAuth credentials'), @@ -631,9 +655,7 @@ const openCodeCommands = Command.make('opencode').pipe( ).pipe(Command.withDescription('Report that OpenCode browser authentication is unsupported')), Command.make('status', { provider: commonFlags.provider, foldHome: commonFlags.foldHome }, (input) => Effect.gen(function* () { - const store = makeOpenCodeAuthStore( - providerAuthStoreOptions(input.provider, optionValue(input.foldHome), 'opencode'), - ) + const store = yield* OpenCodeAuthStore const token = yield* store.load if (Option.isNone(token)) { yield* Console.log( @@ -648,49 +670,48 @@ const openCodeCommands = Command.make('opencode').pipe( yield* Console.log( `OpenCode credential ${token.value.isExpired(now) ? 'expired' : 'valid'}${identity} in ${store.path} (expires ${expiryText(token.value.expires)})`, ) - }), + }).pipe(Effect.provide(layerOpenCodeAuthStore(authStoreOptionsFor(input, 'opencode')))), ).pipe(Command.withDescription('Show the stored OpenCode credential status')), Command.make('logout', { provider: commonFlags.provider, foldHome: commonFlags.foldHome }, (input) => Effect.gen(function* () { - const store = makeOpenCodeAuthStore( - providerAuthStoreOptions(input.provider, optionValue(input.foldHome), 'opencode'), - ) - const service = yield* makeOpenCodeAuth({ store }).pipe(Effect.provide(FetchHttpClient.layer)) + const store = yield* OpenCodeAuthStore + const service = yield* OpenCodeAuth yield* service.logout yield* Console.log(`Removed OpenCode credential from ${store.path}`) - }), + }).pipe(Effect.provide(openCodeAuthLayer(input))), ).pipe(Command.withDescription('Remove the stored OpenCode credential')), ]), ) const xaiLogin = (flow: ResolvedCodexLoginFlow, input: ProviderLoginInput) => Effect.gen(function* () { - const store = yield* makeXaiAuthStore( - providerAuthStoreOptions(input.provider, optionValue(input.foldHome), 'xai'), - ) - const xaiAuth = yield* makeXaiAuth({ - store, - onDeviceCode: (prompt) => - printAuthUrl({ - heading: `xAI device login\n\nCode: ${prompt.userCode}`, - url: prompt.browserUrl, - noOpen: input.noOpen, - waiting: 'Waiting for approval...', - }), - onBrowserUrl: (url) => - printAuthUrl({ - heading: 'xAI browser login', - url, - noOpen: input.noOpen, - waiting: 'Waiting for browser callback on localhost...', - }), - }).pipe(Effect.provide(FetchHttpClient.layer)) + const store = yield* XaiAuthStore + const xaiAuth = yield* XaiAuth yield* Console.log(`Using xAI ${flow} authentication for provider "${providerId(input.provider, 'xai')}"`) const token = yield* flow === 'browser' ? xaiAuth.authenticateBrowser : xaiAuth.authenticateDevice yield* Console.log( `Saved xAI credential${token.accountId === undefined ? '' : ` for account ${token.accountId}`} to ${store.path} (expires ${expiryText(token.expires)})`, ) - }) + }).pipe( + Effect.provide( + xaiAuthLayer(input, { + onDeviceCode: (prompt) => + printAuthUrl({ + heading: `xAI device login\n\nCode: ${prompt.userCode}`, + url: prompt.browserUrl, + noOpen: input.noOpen, + waiting: 'Waiting for approval...', + }), + onBrowserUrl: (url) => + printAuthUrl({ + heading: 'xAI browser login', + url, + noOpen: input.noOpen, + waiting: 'Waiting for browser callback on localhost...', + }), + }), + ), + ) const xaiExplicitLoginCommand = (name: ResolvedCodexLoginFlow) => Command.make( @@ -734,9 +755,7 @@ const xaiCommands = Command.make('xai').pipe( xaiExplicitLoginCommand('device'), Command.make('status', { provider: commonFlags.provider, foldHome: commonFlags.foldHome }, (input) => Effect.gen(function* () { - const store = yield* makeXaiAuthStore( - providerAuthStoreOptions(input.provider, optionValue(input.foldHome), 'xai'), - ) + const store = yield* XaiAuthStore const token = yield* store.load if (Option.isNone(token)) { yield* Console.log(`No xAI credential found in ${store.path}. Run foldcode auth xai login.`) @@ -746,17 +765,15 @@ const xaiCommands = Command.make('xai').pipe( yield* Console.log( `xAI credential ${token.value.isExpired(now) ? 'expired' : 'valid'}${token.value.accountId === undefined ? '' : ` for account ${token.value.accountId}`} in ${store.path} (expires ${expiryText(token.value.expires)})`, ) - }), + }).pipe(Effect.provide(layerXaiAuthStore(authStoreOptionsFor(input, 'xai')))), ).pipe(Command.withDescription('Show the stored xAI credential status')), Command.make('logout', { provider: commonFlags.provider, foldHome: commonFlags.foldHome }, (input) => Effect.gen(function* () { - const store = yield* makeXaiAuthStore( - providerAuthStoreOptions(input.provider, optionValue(input.foldHome), 'xai'), - ) - const service = yield* makeXaiAuth({ store }).pipe(Effect.provide(FetchHttpClient.layer)) + const store = yield* XaiAuthStore + const service = yield* XaiAuth yield* service.logout yield* Console.log(`Removed xAI credential from ${store.path}`) - }), + }).pipe(Effect.provide(xaiAuthLayer(input))), ).pipe(Command.withDescription('Remove the stored xAI credential')), ]), ) @@ -789,7 +806,6 @@ const auth = Command.make('auth').pipe( }, (input) => Effect.gen(function* () { - const foldHome = optionValue(input.foldHome) const selectedFlow = resolveCodexLoginFlow({ flow: optionValue(input.flow), device: input.device, @@ -803,9 +819,7 @@ const auth = Command.make('auth').pipe( noOpen: input.noOpen, stdoutIsTTY: process.stdout.isTTY === true, }) - const store = yield* makeCodexAuthStore(codexAuthStoreOptions(input.provider, foldHome)) - const codexAuth = yield* makeCodexAuth({ - store, + const codexAuthOptions: CodexAuthOptions = { onDeviceCode: (prompt) => Console.log( `Codex device login\n\nOpen: ${prompt.verifyUrl}\nCode: ${prompt.userCode}\n\nWaiting for approval...`, @@ -827,17 +841,21 @@ const auth = Command.make('auth').pipe( } yield* Console.log(`\n${url}\n\nWaiting for browser callback on localhost...`) }), - }).pipe(Effect.provide(FetchHttpClient.layer)) - yield* Console.log( - `Using Codex ${selectedFlow} authentication for provider "${codexProviderId(input.provider)}"`, - ) - const token = yield* selectedFlow === 'device' - ? codexAuth.authenticateDevice - : codexAuth.authenticateBrowser + } + yield* Effect.gen(function* () { + const store = yield* CodexAuthStore + const codexAuth = yield* CodexAuth + yield* Console.log( + `Using Codex ${selectedFlow} authentication for provider "${codexProviderId(input.provider)}"`, + ) + const token = yield* selectedFlow === 'device' + ? codexAuth.authenticateDevice + : codexAuth.authenticateBrowser - yield* Console.log( - `Saved Codex credential${token.accountId === undefined ? '' : ` for account ${token.accountId}`} to ${store.path} (expires ${expiryText(token.expires)})`, - ) + yield* Console.log( + `Saved Codex credential${token.accountId === undefined ? '' : ` for account ${token.accountId}`} to ${store.path} (expires ${expiryText(token.expires)})`, + ) + }).pipe(Effect.provide(codexAuthLayer(input, codexAuthOptions))) }), ).pipe(Command.withDescription('Authenticate Codex and persist the OAuth credential')), Command.make( @@ -851,12 +869,9 @@ const auth = Command.make('auth').pipe( }, (input) => Effect.gen(function* () { - const foldHome = optionValue(input.foldHome) - const store = yield* makeCodexAuthStore(codexAuthStoreOptions(input.provider, foldHome)) + const store = yield* CodexAuthStore if (input.refresh) { - const codexAuth = yield* makeCodexAuth({ store }).pipe( - Effect.provide(FetchHttpClient.layer), - ) + const codexAuth = yield* CodexAuth const token = yield* codexAuth.get yield* Console.log( `Codex credential valid${token.accountId === undefined ? '' : ` for account ${token.accountId}`} in ${store.path} (expires ${expiryText(token.expires)})`, @@ -883,16 +898,15 @@ const auth = Command.make('auth').pipe( : '' }`, ) - }), + }).pipe(Effect.provide(codexAuthLayer(input))), ).pipe(Command.withDescription('Show the stored Codex credential status')), Command.make('logout', { provider: commonFlags.provider, foldHome: commonFlags.foldHome }, (input) => Effect.gen(function* () { - const foldHome = optionValue(input.foldHome) - const store = yield* makeCodexAuthStore(codexAuthStoreOptions(input.provider, foldHome)) - const codexAuth = yield* makeCodexAuth({ store }).pipe(Effect.provide(FetchHttpClient.layer)) + const store = yield* CodexAuthStore + const codexAuth = yield* CodexAuth yield* codexAuth.logout yield* Console.log(`Removed Codex credential from ${store.path}`) - }), + }).pipe(Effect.provide(codexAuthLayer(input))), ).pipe(Command.withDescription('Remove the stored Codex credential')), ]), ), @@ -932,7 +946,7 @@ const withErrorHandling = (effect: Effect.Effect): UserError: (error) => printFailure(error.message), ShowHelp: (error) => Effect.sync(() => { - process.exitCode = error.errors.length === 0 ? 0 : 1 + process.exitCode = Arr.isReadonlyArrayEmpty(error.errors) ? 0 : 1 }), InvalidSessionIdError: (error: InvalidSessionIdError) => printFailure(`invalid --resume value "${error.value}"; pass "latest" or an exact sess_... id`), @@ -947,7 +961,7 @@ const withErrorHandling = (effect: Effect.Effect): RoleResolutionError: (error) => printFailure(error.message), UnknownProfileError: (error) => printFailure( - `unknown profile "${error.profile}"; available: ${error.available.length === 0 ? '(none configured)' : error.available.join(', ')}`, + `unknown profile "${error.profile}"; available: ${Arr.isReadonlyArrayEmpty(error.available) ? '(none configured)' : error.available.join(', ')}`, ), NoSessionToResumeError: (error) => printFailure(`no fold sessions exist for ${error.cwd}`), SessionToResumeNotFoundError: (error) => diff --git a/packages/fold-cli/src/Renderer.ts b/packages/fold-cli/src/Renderer.ts index bfe4257..950174c 100644 --- a/packages/fold-cli/src/Renderer.ts +++ b/packages/fold-cli/src/Renderer.ts @@ -1,7 +1,13 @@ import { decodeBashOutputDelta } from '@humanlayer/fold-agent' import { + encodedContentParts, + encodedContentText, + isPartOfType, + jsonText, lookupCatalogEntry, + toolCallParamsText, shortAgentId, + ToolCallId, usageCacheRead, usageCacheWrite, usageInputTotal, @@ -9,30 +15,25 @@ import { usageOutputTotal, type ActiveModel, type AgentFinishedLogEntry, - type AgentId, - type LogEntry, + type AgentStartedLogEntry, + type AssistantMessageLogEntry, + type ModelChangeLogEntry, + type ToolResultLogEntry, + AgentId, + LogEntry, type ModelCatalogEntry, type ModelPricing, type SessionId, type UsageEncoded, type FoldEvent, } from '@humanlayer/fold-core' -import { Data, Effect, Match } from 'effect' +import { Array as Arr, Data, Effect, Match, Schema } from 'effect' -import { makeAnsiPalette, type AnsiPalette } from './Ansi' +import { ansiPalette, type AnsiPalette } from './Ansi' import { contextUsedPercentForDisplay, contextWindowLimitForDisplay } from './ContextWindow' 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 -} - /** Creation options for the colored headless output renderer. */ export type RendererOptions = { readonly stdout?: Writer @@ -77,10 +78,11 @@ export type SessionHeader = { } /** Human-safe credential status printed in the session header. */ -export type CredentialSummary = - | { readonly _tag: 'found'; readonly detail: string } - | { readonly _tag: 'missing'; readonly detail: string } - | { readonly _tag: 'unknown'; readonly detail: string } +export type CredentialSummary = Data.TaggedEnum<{ + found: { readonly detail: string } + missing: { readonly detail: string } + unknown: { readonly detail: string } +}> export const CredentialSummary = Data.taggedEnum() @@ -98,24 +100,39 @@ export type OutputRenderer = { const defaultStdout: Writer = (text) => Effect.sync(() => process.stdout.write(text)) const defaultStderr: Writer = (text) => Effect.sync(() => process.stderr.write(text)) -const safeStringify = (value: unknown): string => { - try { - return JSON.stringify(value) ?? String(value) - } catch { - return String(value) - } -} - -const jsonLine = (value: unknown): string => `${JSON.stringify(value)}\n` +const DeltaPart = Schema.Union([ + Schema.Struct({ type: Schema.Literal('text-delta'), id: Schema.String, delta: Schema.String }), + Schema.Struct({ type: Schema.Literal('reasoning-delta'), id: Schema.String, delta: Schema.String }), + Schema.Struct({ type: Schema.Literal('tool-progress'), toolName: Schema.String, payload: Schema.Json }), +]) + +const FoldEventJson = Schema.fromJsonString( + Schema.Union([ + Schema.Struct({ kind: Schema.Literal('log'), entry: LogEntry }), + Schema.Struct({ + kind: Schema.Literal('delta'), + agentId: AgentId, + parentAgentId: Schema.NullOr(AgentId), + toolCallId: Schema.NullOr(ToolCallId), + part: DeltaPart, + }), + ]), +) + +const encodeFoldEventJson = Schema.encodeEffect(FoldEventJson) /** Create a JSONL renderer for programmatic/headless consumers. */ -export const makeJsonOutputRenderer = (options?: JsonRendererOptions): OutputRenderer => { +export const jsonOutputRenderer = (options?: JsonRendererOptions): OutputRenderer => { const stdout = options?.stdout ?? defaultStdout const stderr = options?.stderr ?? defaultStderr const mode = options?.mode ?? 'json-concise' const seenLogSeqs = new Set() - const writeEvent = (event: FoldEvent): Effect.Effect => stdout(jsonLine(event)) + const writeEvent = (event: FoldEvent): Effect.Effect => + encodeFoldEventJson(event).pipe( + Effect.orDie, + Effect.flatMap((line) => stdout(`${line}\n`)), + ) return { renderHeader: () => Effect.void, @@ -140,10 +157,10 @@ export const makeJsonOutputRenderer = (options?: JsonRendererOptions): OutputRen } /** Keep one-shot human stdout extraction-safe while retaining normal human diagnostics on stderr. */ -export const makePromptOutputRenderer = (options?: RendererOptions): OutputRenderer => { +export const promptOutputRenderer = (options?: RendererOptions): OutputRenderer => { const stdout = options?.stdout ?? defaultStdout const stderr = options?.stderr ?? defaultStderr - const human = makeOutputRenderer({ ...options, stdout: stderr, stderr }) + const human = humanOutputRenderer({ ...options, stdout: stderr, stderr }) let framed = false return { @@ -167,23 +184,9 @@ 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 textContent = (content: unknown): string => - contentParts(content) - .flatMap((part) => (part.type === 'text' ? [part.text ?? ''] : [])) - .join('') - const label = (ansi: AnsiPalette, text: string): string => ansi.dim(`[${text}]`) -const modelName = (entry: Extract): string => { +const modelName = (entry: AgentStartedLogEntry | ModelChangeLogEntry): string => { const role = entry.model.role === null ? '' : ` role=${entry.model.role}` return `${entry.model.providerId}/${entry.model.modelId}${role}` } @@ -283,7 +286,7 @@ const resumeCommand = ( input: { readonly model: ActiveModel | null; readonly flags: ReadonlyArray }, ): string => { const flags = [`--resume ${shellQuote(sessionId)}`] - if (input.flags.length > 0) { + if (Arr.isReadonlyArrayNonEmpty(input.flags)) { flags.push(...input.flags.map(formattedFlag)) } else if (input.model !== null) { const model = input.model @@ -307,10 +310,10 @@ const outcomeColor = (ansi: AnsiPalette, outcome: AgentFinishedLogEntry['outcome ) /** Create the CLI's colored renderer for durable log rows plus live deltas. */ -export const makeOutputRenderer = (options?: RendererOptions): OutputRenderer => { +export const humanOutputRenderer = (options?: RendererOptions): OutputRenderer => { const stdout = options?.stdout ?? defaultStdout const stderr = options?.stderr ?? defaultStderr - const ansi = makeAnsiPalette(options?.colors ?? true) + const ansi = ansiPalette(options?.colors ?? true) const verbose = options?.verbose ?? false const catalog = options?.catalog ?? [] const agentsWithText = new Set() @@ -341,7 +344,7 @@ export const makeOutputRenderer = (options?: RendererOptions): OutputRenderer => /** The compact `agent_xxxx` display form used on subagent start/done lines (a valid /steer//send target). */ const displayAgentId = (agentId: AgentId): string => `agent_${shortIdSuffix(agentId)}` - const registerAgentLabel = (entry: Extract): void => { + const registerAgentLabel = (entry: AgentStartedLogEntry): void => { if (entry.parentAgentId === null || agentLabels.has(entry.agentId)) return const kind = entry.agentType ?? (entry.mode === 'fork' ? 'fork' : 'agent') const color = tagPalette[agentLabels.size % tagPalette.length] ?? ansi.magenta @@ -405,21 +408,19 @@ export const makeOutputRenderer = (options?: RendererOptions): OutputRenderer => return writeStdout(`${leading}${body}${endsWithNewline ? '\n' : ''}`) }) - const renderToolCalls = (entry: Extract) => + const renderToolCalls = (entry: AssistantMessageLogEntry) => Effect.forEach( - contentParts(entry.message.content).filter((part) => part.type === 'tool-call'), + encodedContentParts(entry.message.content).filter(isPartOfType('tool-call')), (part) => renderAgentLine( entry.agentId, - `${label(ansi, 'tool')} ${ansi.cyan(part.name ?? 'tool')} ${truncate(safeStringify(part.params), verbose ? 2000 : 300)}`, + `${label(ansi, 'tool')} ${ansi.cyan(part.name)} ${truncate(toolCallParamsText(part), verbose ? 2000 : 300)}`, ), { discard: true }, ) - const renderToolResult = (entry: Extract) => { - const failed = contentParts(entry.message.content).some( - (part) => part.type === 'tool-result' && part.isFailure === true, - ) + const renderToolResult = (entry: ToolResultLogEntry) => { + const failed = entry.message.content.filter(isPartOfType('tool-result')).some((part) => part.isFailure) const color = failed ? ansi.red : ansi.green return renderAgentLine(entry.agentId, `${label(ansi, 'tool')} ${color('result')} ${ansi.dim(entry.toolCallId)}`) } @@ -447,7 +448,7 @@ export const makeOutputRenderer = (options?: RendererOptions): OutputRenderer => ) }, 'user-message': (message) => { - const text = textContent(message.message.content) + const text = encodedContentText(message.message.content) return text.length === 0 ? Effect.void : renderAgentLine(message.agentId, `${ansi.cyan('>')} ${text}`) }, 'assistant-message': (message) => { @@ -458,7 +459,7 @@ export const makeOutputRenderer = (options?: RendererOptions): OutputRenderer => }) } - const text = textContent(message.message.content) + const text = encodedContentText(message.message.content) const textEffect = streamedAssistantText.has(message.agentId) ? Effect.void : renderAssistantText(message.agentId, text) @@ -512,70 +513,73 @@ export const makeOutputRenderer = (options?: RendererOptions): OutputRenderer => const renderEvent = (event: FoldEvent): Effect.Effect => { if (event.kind === 'log') return renderLog(event.entry) - switch (event.part.type) { - case 'text-delta': { - const agentId = event.agentId - const delta = event.part.delta - return Effect.suspend(() => { - const tag = tagFor(agentId) - const transition = streamTransition(agentId, tag) - agentsWithText.add(agentId) - streamedAssistantText.add(agentId) - const prefix = assistantLabelOpen.has(agentId) - ? Effect.void - : Effect.suspend(() => - newlineIfOpen().pipe( - Effect.andThen( - writeStdout(`${tag === null ? '' : `${tag} `}${ansi.green('[assistant]')} `), + return Match.value(event.part).pipe( + Match.discriminatorsExhaustive('type')({ + 'text-delta': ({ delta }) => { + const agentId = event.agentId + return Effect.suspend(() => { + const tag = tagFor(agentId) + const transition = streamTransition(agentId, tag) + agentsWithText.add(agentId) + streamedAssistantText.add(agentId) + const prefix = assistantLabelOpen.has(agentId) + ? Effect.void + : Effect.suspend(() => + newlineIfOpen().pipe( + Effect.andThen( + writeStdout( + `${tag === null ? '' : `${tag} `}${ansi.green('[assistant]')} `, + ), + ), ), - ), - ) - assistantLabelOpen.add(agentId) - const body = tag === null ? writeStdout(delta) : writeTaggedDelta(tag, delta, (segment) => segment) - return transition.pipe(Effect.andThen(prefix), Effect.andThen(body)) - }) - } - - case 'reasoning-delta': { - const agentId = event.agentId - const delta = event.part.delta - return Effect.suspend(() => { - const tag = tagFor(agentId) - if (tag === null) - return streamTransition(agentId, null).pipe(Effect.andThen(writeStdout(ansi.dim(delta)))) - return streamTransition(agentId, tag).pipe(Effect.andThen(writeTaggedDelta(tag, delta, ansi.dim))) - }) - } + ) + assistantLabelOpen.add(agentId) + const body = + tag === null ? writeStdout(delta) : writeTaggedDelta(tag, delta, (segment) => segment) + return transition.pipe(Effect.andThen(prefix), Effect.andThen(body)) + }) + }, + + 'reasoning-delta': ({ delta }) => { + const agentId = event.agentId + return Effect.suspend(() => { + const tag = tagFor(agentId) + if (tag === null) + return streamTransition(agentId, null).pipe(Effect.andThen(writeStdout(ansi.dim(delta)))) + return streamTransition(agentId, tag).pipe( + Effect.andThen(writeTaggedDelta(tag, delta, ansi.dim)), + ) + }) + }, + + 'tool-progress': ({ toolName, payload }) => { + const bash = decodeBashOutputDelta(payload) + if (bash !== null) { + if (verbose) + return Effect.suspend(() => { + const tag = tagFor(event.agentId) + const decorate = bash.stream === 'stderr' ? ansi.yellow : ansi.dim + return tag === null + ? writeStdout(decorate(bash.text)) + : writeTaggedDelta(tag, bash.text, decorate) + }) + + const noticeKey = `${event.toolCallId ?? 'unknown'}:${toolName}` + if (hiddenToolOutputNotices.has(noticeKey)) return Effect.void + hiddenToolOutputNotices.add(noticeKey) + return renderAgentLine( + event.agentId, + `${label(ansi, 'tool')} ${ansi.cyan(toolName)} output hidden; pass --verbose to stream it`, + ) + } - case 'tool-progress': { - const toolName = event.part.toolName - const payload = event.part.payload - const bash = decodeBashOutputDelta(payload) - if (bash !== null) { - if (verbose) - return Effect.suspend(() => { - const tag = tagFor(event.agentId) - const decorate = bash.stream === 'stderr' ? ansi.yellow : ansi.dim - return tag === null - ? writeStdout(decorate(bash.text)) - : writeTaggedDelta(tag, bash.text, decorate) - }) - - const noticeKey = `${event.toolCallId ?? 'unknown'}:${toolName}` - if (hiddenToolOutputNotices.has(noticeKey)) return Effect.void - hiddenToolOutputNotices.add(noticeKey) return renderAgentLine( event.agentId, - `${label(ansi, 'tool')} ${ansi.cyan(toolName)} output hidden; pass --verbose to stream it`, + `${label(ansi, 'tool')} ${ansi.cyan(toolName)} ${truncate(jsonText(payload), verbose ? 2000 : 300)}`, ) - } - - return renderAgentLine( - event.agentId, - `${label(ansi, 'tool')} ${ansi.cyan(toolName)} ${truncate(safeStringify(payload), verbose ? 2000 : 300)}`, - ) - } - } + }, + }), + ) } const renderFinish = (entry: AgentFinishedLogEntry): Effect.Effect => { diff --git a/packages/fold-cli/src/Run.ts b/packages/fold-cli/src/Run.ts index 5edd9df..29e42fa 100644 --- a/packages/fold-cli/src/Run.ts +++ b/packages/fold-cli/src/Run.ts @@ -1,10 +1,10 @@ import { join } from 'node:path' -import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { bootstrapFoldHome, defaultFoldHome, ensureManagedBinaries, + type ManagedBinaries, launchSession, modeForName, resumeLatestSession, @@ -16,8 +16,10 @@ import { type NoSessionToResumeError, type SessionToResumeNotFoundError, type FoldModeName, + type OutputStore, + type Photon, } from '@humanlayer/fold-agent' -import { makeCodexAuthStore } from '@humanlayer/fold-codex' +import { CodexAuthStore, layerCodexAuthStore, type CodexAuthStoreOptions } from '@humanlayer/fold-codex' import type { ActiveModel, AgentFinishedLogEntry, @@ -28,6 +30,7 @@ import type { FoldSession, } from '@humanlayer/fold-core' import { + Array as Arr, Data, Match, Predicate, @@ -38,9 +41,12 @@ import { Fiber, type FileSystem, Option, + type Path, Stream, type Scope, } from 'effect' +import type { HttpClient } from 'effect/unstable/http' +import type { ChildProcessSpawner } from 'effect/unstable/process' import { CredentialSummary, type OutputRenderer, type ResumeCommandFlag, type SessionHeader } from './Renderer' @@ -48,7 +54,7 @@ import { CredentialSummary, type OutputRenderer, type ResumeCommandFlag, type Se * What `--resume` selected: the newest session log for this project, or one exact id. Absent means a * fresh session. */ -export type ResumeTarget = { readonly _tag: 'latest' } | { readonly _tag: 'id'; readonly sessionId: SessionId } +export type ResumeTarget = Data.TaggedEnum<{ latest: {}; id: { readonly sessionId: SessionId } }> export const ResumeTarget = Data.taggedEnum() /** Shared options for opening a CLI-backed fold session. */ @@ -77,7 +83,14 @@ export type PromptRunOptions = CliSessionOptions & { } type OpenedSession = { - readonly session: FoldSession + readonly session: FoldSession< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + > readonly mode: 'new' | 'resumed' readonly logPath: string } @@ -101,7 +114,24 @@ const launchOptions = (options: CliSessionOptions) => { /** Start fresh, resume the project's newest log, or adopt one exact session id. */ const openSessionFor = ( options: CliSessionOptions, -): Effect.Effect => { +): Effect.Effect< + FoldSession< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + >, + OpenSessionError, + | Scope.Scope + | Ids + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | Photon + | HttpClient.HttpClient +> => { if (options.resume === undefined) return launchSession(launchOptions(options)) return Match.valueTags(options.resume, { @@ -112,7 +142,17 @@ const openSessionFor = ( const openSession = ( options: CliSessionOptions, -): Effect.Effect => +): Effect.Effect< + OpenedSession, + OpenSessionError, + | Scope.Scope + | Ids + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | Photon + | HttpClient.HttpClient +> => Effect.gen(function* () { const session = yield* openSessionFor(options) const logOptions: Mutable[1]>> = { cwd: options.cwd } @@ -136,27 +176,39 @@ const activeModelFromEntries = (entries: ReadonlyArray, rootAgentId: s return null } -const credentialSummary = (model: ActiveModel | null, options: CliSessionOptions): Effect.Effect => +/** Summarize a stored Codex credential: its provider's entry in the fold home's auth document. */ +const codexCredentialSummary = (providerId: string): Effect.Effect => Effect.gen(function* () { - if (model === null) return CredentialSummary.unknown({ detail: 'no active model row found in the session log' }) - - if (model.providerKind === 'codex') { - const authStoreOptions: Mutable[0]> = { providerId: model.providerId } - if (options.foldHome !== undefined) authStoreOptions.path = join(options.foldHome, 'auth.json') - const store = yield* makeCodexAuthStore(authStoreOptions).pipe(Effect.provide(NodeFileSystem.layer)) - const token = yield* store.load - if (Option.isNone(token)) { - return CredentialSummary.missing({ detail: `entry "${model.providerId}" in ${store.path}` }) - } - - const now = yield* Clock.currentTimeMillis - const expiry = token.value.isExpired(now) ? 'expired; will refresh on first request' : 'valid' - return CredentialSummary.found({ detail: `${expiry} entry "${model.providerId}" in ${store.path}` }) + const store = yield* CodexAuthStore + const token = yield* store.load + if (Option.isNone(token)) { + return CredentialSummary.missing({ detail: `entry "${providerId}" in ${store.path}` }) } - return CredentialSummary.found({ detail: `API key resolved for provider "${model.providerId}"` }) + const now = yield* Clock.currentTimeMillis + const expiry = token.value.isExpired(now) ? 'expired; will refresh on first request' : 'valid' + return CredentialSummary.found({ detail: `${expiry} entry "${providerId}" in ${store.path}` }) }) +const credentialSummary = ( + model: ActiveModel | null, + options: CliSessionOptions, +): Effect.Effect => { + if (model === null) + return Effect.succeed(CredentialSummary.unknown({ detail: 'no active model row found in the session log' })) + if (model.providerKind !== 'codex') { + return Effect.succeed( + CredentialSummary.found({ detail: `API key resolved for provider "${model.providerId}"` }), + ) + } + + const authStoreOptions: CodexAuthStoreOptions = + options.foldHome === undefined + ? { providerId: model.providerId } + : { providerId: model.providerId, path: join(options.foldHome, 'auth.json') } + return codexCredentialSummary(model.providerId).pipe(Effect.provide(layerCodexAuthStore(authStoreOptions))) +} + /** * The header's agent-mode label: non-default modes print their name, and an enabled RPI roster is * always visible as a `+rpi` suffix - including `default+rpi`, where the default mode alone would @@ -169,45 +221,46 @@ const agentModeLabel = (options: CliSessionOptions): string | undefined => { return mode === 'default' ? undefined : mode } +/** A `--name value` resume flag, present only when the value is. */ +const valueFlag = (name: string, value: string | undefined): Option.Option => + Option.map(Option.fromUndefinedOr(value), (present) => ({ name, value: present })) + +/** A bare `--name` resume flag, present only when switched on. */ +const switchFlag = (name: string, on: boolean): Option.Option => + Option.liftPredicate({ name }, () => on) + const compactResumeFlags = (autoCompact: AutoCompactConfig | undefined): ReadonlyArray => { if (autoCompact === undefined) return [] if (!autoCompact.enabled) return [{ name: 'disable-auto-compact' }] - return [ - { name: 'auto-compact' }, - ...(autoCompact.thresholdTokens === undefined - ? [] - : [{ name: 'compaction-threshold', value: String(autoCompact.thresholdTokens) }]), - ...(autoCompact.reserveTokens === undefined - ? [] - : [{ name: 'compaction-reserve-tokens', value: String(autoCompact.reserveTokens) }]), - ...(autoCompact.keepRecentTokens === undefined - ? [] - : [{ name: 'compaction-keep-recent-tokens', value: String(autoCompact.keepRecentTokens) }]), - ...(autoCompact.compactionPrompt === undefined - ? [] - : [{ name: 'compaction-prompt', value: autoCompact.compactionPrompt }]), - ] + return Arr.getSomes([ + switchFlag('auto-compact', true), + valueFlag('compaction-threshold', autoCompact.thresholdTokens?.toString()), + valueFlag('compaction-reserve-tokens', autoCompact.reserveTokens?.toString()), + valueFlag('compaction-keep-recent-tokens', autoCompact.keepRecentTokens?.toString()), + valueFlag('compaction-prompt', autoCompact.compactionPrompt), + ]) } export const resumeFlagsFor = (options: CliSessionOptions): ReadonlyArray => [ - ...(options.cwd === process.cwd() ? [] : [{ name: 'cwd', value: options.cwd }]), - ...(options.foldHome === undefined ? [] : [{ name: 'fold-home', value: options.foldHome }]), - ...(options.mode === undefined ? [] : [{ name: 'mode', value: options.mode }]), - ...(options.rpi === true ? [{ name: 'rpi' }] : []), - ...(options.profile === undefined ? [] : [{ name: 'profile', value: options.profile }]), - ...(options.modelSelection?.role === undefined ? [] : [{ name: 'role', value: options.modelSelection.role }]), - ...(options.modelSelection?.provider === undefined - ? [] - : [{ name: 'provider', value: options.modelSelection.provider }]), - ...(options.modelSelection?.model === undefined ? [] : [{ name: 'model', value: options.modelSelection.model }]), - ...(options.modelSelection?.reasoning === undefined - ? [] - : [{ name: 'reasoning', value: options.modelSelection.reasoning }]), + ...Arr.getSomes([ + valueFlag('cwd', options.cwd === process.cwd() ? undefined : options.cwd), + valueFlag('fold-home', options.foldHome), + valueFlag('mode', options.mode), + switchFlag('rpi', options.rpi === true), + valueFlag('profile', options.profile), + valueFlag('role', options.modelSelection?.role), + valueFlag('provider', options.modelSelection?.provider), + valueFlag('model', options.modelSelection?.model), + valueFlag('reasoning', options.modelSelection?.reasoning), + ]), ...compactResumeFlags(options.autoCompact), ] -const sessionHeader = (opened: OpenedSession, options: CliSessionOptions): Effect.Effect => +const sessionHeader = ( + opened: OpenedSession, + options: CliSessionOptions, +): Effect.Effect => Effect.gen(function* () { const entries = yield* opened.session.entries const model = activeModelFromEntries(entries, opened.session.rootAgentId) @@ -229,23 +282,37 @@ const sessionHeader = (opened: OpenedSession, options: CliSessionOptions): Effec }) const renderLiveEvents = ( - session: FoldSession, + session: FoldSession< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + >, renderer: OutputRenderer, ): Effect.Effect, never, Scope.Scope> => Effect.gen(function* () { const entries = yield* session.entries - const fromSeq = entries.length === 0 ? 0 : (entries.at(-1)?.seq ?? -1) + 1 - const render = session.events(fromSeq).pipe( - Stream.runForEach(renderer.renderEvent), - Effect.catchCause(() => Effect.void), - ) + const fromSeq = Arr.match(entries, { + onEmpty: () => 0, + onNonEmpty: (nonEmpty) => Arr.lastNonEmpty(nonEmpty).seq + 1, + }) + const render = session.events(fromSeq).pipe(Stream.runForEach(renderer.renderEvent), Effect.ignoreCause) const fiber = yield* Effect.forkScoped(render, { startImmediately: true }) yield* Effect.yieldNow return fiber }) const withProcessSignals = ( - session: FoldSession, + session: FoldSession< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + >, renderer: OutputRenderer, effect: Effect.Effect, ): Effect.Effect => @@ -279,25 +346,28 @@ const withProcessSignals = ( * absent), and the regenerated `config.schema.json` + `FOLD_INFO.md`. Never fails a run - a broken * home surfaces as the launch's own config error moments later. */ -const bootstrapForRun = (options: CliSessionOptions): Effect.Effect => { +const bootstrapForRun = ( + options: CliSessionOptions, +): Effect.Effect => { const bootstrapOptions: Mutable[0]>> = {} if (options.foldHome !== undefined) bootstrapOptions.foldHome = options.foldHome + // Debug level: a warning on the console would mix into prompt and JSON output, and the launch reports + // a broken home through its own config error moments later. return bootstrapFoldHome(bootstrapOptions).pipe( Effect.asVoid, - Effect.catchCause(() => Effect.void), + Effect.catchCause((cause) => Effect.logDebug('fold home bootstrap failed', cause)), ) } const forkStartupEnsures = ( options: CliSessionOptions, renderer: OutputRenderer, -): Effect.Effect => - Effect.forkDetach( +): Effect.Effect => + Effect.forkScoped( Effect.gen(function* () { const statuses = yield* ensureManagedBinaries({ foldHome: options.foldHome ?? defaultFoldHome(), requireManagedInstall: true, - suppressWarnings: true, }) yield* Effect.forEach( statuses.filter((status) => status.resolution === 'installed-now'), @@ -310,7 +380,18 @@ const forkStartupEnsures = ( export const runPrompt = ( options: PromptRunOptions, renderer: OutputRenderer, -): Effect.Effect => +): Effect.Effect< + AgentFinishedLogEntry, + OpenSessionError, + | Scope.Scope + | Ids + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | Photon + | HttpClient.HttpClient + | ManagedBinaries +> => Effect.gen(function* () { yield* bootstrapForRun(options) const opened = yield* openSession(options) diff --git a/packages/fold-cli/src/cli.ts b/packages/fold-cli/src/cli.ts index f2476fd..fde4e5d 100644 --- a/packages/fold-cli/src/cli.ts +++ b/packages/fold-cli/src/cli.ts @@ -1,9 +1,14 @@ #!/usr/bin/env node import * as NodeRuntime from '@effect/platform-node/NodeRuntime' import * as NodeServices from '@effect/platform-node/NodeServices' +import { ManagedBinaries, Photon } from '@humanlayer/fold-agent' import { layerLiveIdFactory } from '@humanlayer/fold-core' import { Effect, Layer } from 'effect' +import { FetchHttpClient } from 'effect/unstable/http' import { main } from './Commands' -main.pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, layerLiveIdFactory)), NodeRuntime.runMain) +/** Every host service fold's tools and sessions need, provided once for the whole CLI. */ +const platform = Layer.mergeAll(NodeServices.layer, layerLiveIdFactory, FetchHttpClient.layer, Photon.layer) + +main.pipe(Effect.provide(ManagedBinaries.layer.pipe(Layer.provideMerge(platform))), NodeRuntime.runMain) diff --git a/packages/fold-cli/src/tui/HostedTuiSession.ts b/packages/fold-cli/src/tui/HostedTuiSession.ts index 97755ef..87373cc 100644 --- a/packages/fold-cli/src/tui/HostedTuiSession.ts +++ b/packages/fold-cli/src/tui/HostedTuiSession.ts @@ -5,9 +5,13 @@ import { switchSessionMode, type ProfileModeName, type FoldConfig, + type OutputStore, + type Photon, } from '@humanlayer/fold-agent' -import { renderSkillContent, type ModelCatalogEntry, type SessionId, type FoldSession } from '@humanlayer/fold-core' -import { Cause, Duration, Effect, type Scope, Stream } from 'effect' +import { renderSkillContent, type FoldSession, type ModelCatalogEntry, type SessionId } from '@humanlayer/fold-core' +import { Cause, Duration, Effect, type FileSystem, Layer, type Path, type Scope, Stream } from 'effect' +import { FetchHttpClient, type HttpClient } from 'effect/unstable/http' +import type { ChildProcessSpawner } from 'effect/unstable/process' import { batch, createSignal, type Accessor } from 'solid-js' import { createStore, reconcile } from 'solid-js/store' @@ -25,7 +29,14 @@ export type HostedTuiSessionMetadata = { export type HostedTuiSession = { readonly sessionId: SessionId - readonly session: FoldSession + readonly session: FoldSession< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + > readonly cwd: string readonly profile: Accessor readonly mode: Accessor @@ -46,7 +57,14 @@ export type HostedTuiSession = { } export const makeHostedTuiSession = ( - session: FoldSession, + session: FoldSession< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + >, options: { readonly metadata: HostedTuiSessionMetadata readonly initialInputFocused: boolean @@ -166,7 +184,7 @@ export const makeHostedTuiSession = ( else setTargetNotice({ agentId, text }) }), ), - Effect.provide(NodeFileSystem.layer), + Effect.provide(Layer.merge(NodeFileSystem.layer, FetchHttpClient.layer)), ), ) } @@ -207,7 +225,7 @@ export const makeHostedTuiSession = ( }), ), Effect.catchCause((cause) => Effect.sync(() => setNotice(Cause.pretty(cause)))), - Effect.provide(NodeFileSystem.layer), + Effect.provide(Layer.merge(NodeFileSystem.layer, FetchHttpClient.layer)), ), ) } diff --git a/packages/fold-cli/src/tui/LiveSessionHost.ts b/packages/fold-cli/src/tui/LiveSessionHost.ts index d29696f..65ee71e 100644 --- a/packages/fold-cli/src/tui/LiveSessionHost.ts +++ b/packages/fold-cli/src/tui/LiveSessionHost.ts @@ -1,5 +1,5 @@ import type { SessionId } from '@humanlayer/fold-core' -import { Deferred, Effect, Exit, Fiber, Scope, Semaphore } from 'effect' +import { Data, Deferred, Effect, Exit, Fiber, Scope, Semaphore } from 'effect' import { createSignal, type Accessor } from 'solid-js' export type HostedSessionSnapshot = @@ -10,9 +10,9 @@ export type HostedSessionSnapshot = readonly status: 'RUNNING' | 'IDLE' | 'STOPPED' | 'ERROR' } -type AcquiringSession = { +type AcquiringSession = { readonly _tag: 'acquiring' - readonly result: Deferred.Deferred + readonly result: Deferred.Deferred readonly lease: Lease } @@ -30,34 +30,38 @@ type ClosingSession = { readonly done: Deferred.Deferred } -type HostedSession = AcquiringSession | LiveSession | ClosingSession +type HostedSession = AcquiringSession | LiveSession | ClosingSession type Registration = Lease -export class LiveSessionHostClosedError extends Error { - readonly _tag = 'LiveSessionHostClosedError' - constructor() { - super('Live session host is closed') +export class LiveSessionHostClosedError extends Data.TaggedError('LiveSessionHostClosedError')<{}> { + override get message(): string { + return 'Live session host is closed' } } -export type LiveSessionHost = { +export type LiveSessionHost = { readonly snapshots: Accessor> readonly values: Accessor> readonly get: (sessionId: SessionId) => A | null - readonly open: (sessionId: SessionId, acquire: Effect.Effect) => Effect.Effect + readonly open: ( + sessionId: SessionId, + acquire: Effect.Effect, + ) => Effect.Effect /** Registers a launch whose generated SessionId is not known until acquisition completes. */ - readonly register: (acquire: Effect.Effect) => Effect.Effect + readonly register: ( + acquire: Effect.Effect, + ) => Effect.Effect readonly close: (sessionId: SessionId) => Effect.Effect /** Permanently shuts down the host and releases pending and live sessions. */ readonly closeAll: Effect.Effect } -export const makeLiveSessionHost = ( +export const makeLiveSessionHost = ( parentScope: Scope.Scope, snapshot: (value: A) => HostedSessionSnapshot, -): LiveSessionHost => { - const sessions = new Map>() +): LiveSessionHost => { + const sessions = new Map>() const registrations = new Set() const mutex = Semaphore.makeUnsafe(1) let shutdown = false @@ -85,9 +89,12 @@ export const makeLiveSessionHost = record.closeDone = done return Scope.close(record.scope, Exit.void).pipe(Effect.ensuring(Deferred.succeed(done, undefined))) }) - const awaitResult = (result: Deferred.Deferred) => Deferred.await(result) + const awaitResult = (result: Deferred.Deferred) => Deferred.await(result) - const open = (sessionId: SessionId, acquire: Effect.Effect): Effect.Effect => + const open = ( + sessionId: SessionId, + acquire: Effect.Effect, + ): Effect.Effect => Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const decision = yield* locked( @@ -97,9 +104,9 @@ export const makeLiveSessionHost = if (current?._tag === 'live') return { _tag: 'live' as const, value: current.value } if (current?._tag === 'acquiring') return { _tag: 'wait' as const, result: current.result } if (current?._tag === 'closing') return { _tag: 'closing' as const, done: current.done } - const pending: AcquiringSession = { + const pending: AcquiringSession = { _tag: 'acquiring', - result: yield* Deferred.make(), + result: yield* Deferred.make(), lease: { scope: Scope.forkUnsafe(parentScope), closeDone: null }, } sessions.set(sessionId, pending) @@ -107,7 +114,7 @@ export const makeLiveSessionHost = return { _tag: 'acquire' as const, pending } }), ) - if (decision._tag === 'closed') return yield* Effect.fail(new LiveSessionHostClosedError()) + if (decision._tag === 'closed') return yield* new LiveSessionHostClosedError() if (decision._tag === 'live') return decision.value if (decision._tag === 'wait') return yield* restore(awaitResult(decision.result)) if (decision._tag === 'closing') { @@ -140,7 +147,7 @@ export const makeLiveSessionHost = }), ) if (!published) yield* closeOnce(pending.lease) - const resultExit: Exit.Exit = + const resultExit: Exit.Exit = Exit.isSuccess(exit) && published ? exit : Exit.isFailure(exit) @@ -152,7 +159,9 @@ export const makeLiveSessionHost = }), ) - const register = (acquire: Effect.Effect): Effect.Effect => + const register = ( + acquire: Effect.Effect, + ): Effect.Effect => Effect.uninterruptible( Effect.gen(function* () { const registration: Registration = { scope: Scope.forkUnsafe(parentScope), closeDone: null } diff --git a/packages/fold-cli/src/tui/ProviderAuth.ts b/packages/fold-cli/src/tui/ProviderAuth.ts index b2b3360..7c71700 100644 --- a/packages/fold-cli/src/tui/ProviderAuth.ts +++ b/packages/fold-cli/src/tui/ProviderAuth.ts @@ -1,9 +1,9 @@ import { join } from 'node:path' import { defaultFoldHome, type ConfigureProviderInput } from '@humanlayer/fold-agent' -import type { MakeCodexAuthStoreOptions } from '@humanlayer/fold-codex' -import type { MakeOpenCodeAuthStoreOptions } from '@humanlayer/fold-opencode' -import type { MakeXaiAuthStoreOptions } from '@humanlayer/fold-xai' +import type { CodexAuthStoreOptions } from '@humanlayer/fold-codex' +import type { OpenCodeAuthStoreOptions } from '@humanlayer/fold-opencode' +import type { XaiAuthStoreOptions } from '@humanlayer/fold-xai' export type OAuthProviderKind = 'codex' | 'opencode' | 'xai' @@ -26,17 +26,17 @@ export type ProviderAuthUpdate = } | { readonly _tag: 'failure'; readonly message: string } -export const codexAuthStoreOptions = (providerId: string, foldHome?: string): MakeCodexAuthStoreOptions => ({ +export const codexAuthStoreOptions = (providerId: string, foldHome?: string): CodexAuthStoreOptions => ({ providerId, path: join(foldHome ?? defaultFoldHome(), 'auth.json'), }) -export const openCodeAuthStoreOptions = (providerId: string, foldHome?: string): MakeOpenCodeAuthStoreOptions => ({ +export const openCodeAuthStoreOptions = (providerId: string, foldHome?: string): OpenCodeAuthStoreOptions => ({ providerId, path: join(foldHome ?? defaultFoldHome(), 'auth.json'), }) -export const xaiAuthStoreOptions = (providerId: string, foldHome?: string): MakeXaiAuthStoreOptions => ({ +export const xaiAuthStoreOptions = (providerId: string, foldHome?: string): XaiAuthStoreOptions => ({ providerId, path: join(foldHome ?? defaultFoldHome(), 'auth.json'), }) diff --git a/packages/fold-cli/src/tui/Shell.tsx b/packages/fold-cli/src/tui/Shell.tsx index 79ce8dc..94b2cfa 100644 --- a/packages/fold-cli/src/tui/Shell.tsx +++ b/packages/fold-cli/src/tui/Shell.tsx @@ -6,6 +6,7 @@ import { defaultFoldHome, describeModelConfiguration, ensureManagedBinaries, + type ManagedBinaries, loadViewedPatchHashes, loadFoldConfigOrNull, saveViewedPatchHash, @@ -13,21 +14,22 @@ import { type FoldConfig, type ViewedPatchHashes, } from '@humanlayer/fold-agent' -import { makeCodexAuth, makeCodexAuthStore } from '@humanlayer/fold-codex' +import { CodexAuth, CodexAuthStore, layerCodexAuth, layerCodexAuthStore } from '@humanlayer/fold-codex' import type { SessionId } from '@humanlayer/fold-core' -import { makeOpenCodeAuth, makeOpenCodeAuthStore } from '@humanlayer/fold-opencode' +import { layerOpenCodeAuth, layerOpenCodeAuthStore, OpenCodeAuth, OpenCodeAuthStore } from '@humanlayer/fold-opencode' import { ALL_FX_ON, type FxToggles } from '@humanlayer/fold-tui-theme/postfx' import { nextThemeId, type ThemeId } from '@humanlayer/fold-tui-theme/themes' -import { makeXaiAuth, makeXaiAuthStore } from '@humanlayer/fold-xai' +import { layerXaiAuth, layerXaiAuthStore, XaiAuth, XaiAuthStore } from '@humanlayer/fold-xai' import { createCliRenderer } from '@opentui/core' import { render } from '@opentui/solid' -import { Cause, Clock, Deferred, Effect, type FileSystem, Option, Schema, type Scope } from 'effect' +import { Cause, Clock, Deferred, Effect, type FileSystem, Layer, Option, Schema, type Scope } from 'effect' import { FetchHttpClient } from 'effect/unstable/http' import { batch, createEffect, createSignal, Show, type Accessor } from 'solid-js' import { TuiApp } from './App' import { loadGitSnapshot, type GitSnapshot } from './GitChanges' import type { HostedTuiSession } from './HostedTuiSession' +import type { LiveSessionHostClosedError } from './LiveSessionHost' import { openUrlInBrowser } from './OpenUrl' import { codexAuthStoreOptions, @@ -57,7 +59,7 @@ export const runTui = ( ): Effect.Effect< void, TuiRequiresTtyError | TuiRendererError | TuiInitialSessionError, - Scope.Scope | FileSystem.FileSystem + Scope.Scope | FileSystem.FileSystem | ManagedBinaries > => Effect.gen(function* () { if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) return yield* new TuiRequiresTtyError() @@ -72,7 +74,6 @@ export const runTui = ( ensureManagedBinaries({ foldHome: options.foldHome ?? defaultFoldHome(), requireManagedInstall: true, - suppressWarnings: true, }).pipe(Effect.asVoid), ) const initialConfig = bootstrapped.config @@ -154,172 +155,211 @@ export const runTui = ( setConfiguration(describeModelConfiguration(updated, options.catalog ?? [])) } if (providerKind === 'opencode') { - const store = makeOpenCodeAuthStore(openCodeAuthStoreOptions(provider, options.foldHome)) - if (action === 'status') { - update({ _tag: 'working', message: 'Checking stored OpenCode credential...' }) - const token = yield* store.load - if (Option.isNone(token)) + return yield* Effect.gen(function* () { + const store = yield* OpenCodeAuthStore + if (action === 'status') { + update({ _tag: 'working', message: 'Checking stored OpenCode credential...' }) + const token = yield* store.load + if (Option.isNone(token)) + return update({ + _tag: 'success', + message: 'No OpenCode credential stored.', + authStatus: 'logged-out', + }) + const now = yield* Clock.currentTimeMillis + const expired = token.value.isExpired(now) return update({ _tag: 'success', - message: 'No OpenCode credential stored.', + message: `OpenCode credential is ${expired ? 'expired' : 'valid'} (expires ${new Date(token.value.expires).toISOString()}).`, + authStatus: expired ? 'expired' : 'logged-in', + }) + } + const auth = yield* OpenCodeAuth + if (action === 'logout') { + yield* auth.logout + return update({ + _tag: 'success', + message: 'OpenCode credential removed.', authStatus: 'logged-out', }) - const now = yield* Clock.currentTimeMillis - const expired = token.value.isExpired(now) + } + if (action === 'browser') + return update({ + _tag: 'failure', + message: 'OpenCode supports device login only. Press D.', + }) + update({ _tag: 'working', message: 'Starting OpenCode device login...' }) + yield* auth.authenticateDevice return update({ _tag: 'success', - message: `OpenCode credential is ${expired ? 'expired' : 'valid'} (expires ${new Date(token.value.expires).toISOString()}).`, - authStatus: expired ? 'expired' : 'logged-in', + message: 'OpenCode authentication saved successfully.', + authStatus: 'logged-in', }) - } - const auth = yield* makeOpenCodeAuth({ - store, - onDeviceCode: (prompt) => - openUrlInBrowser(prompt.url).pipe( - Effect.tap((opened) => - Effect.sync(() => - update({ _tag: 'device', url: prompt.url, code: prompt.userCode, opened }), + }).pipe( + Effect.provide( + layerOpenCodeAuth({ + onDeviceCode: (prompt) => + openUrlInBrowser(prompt.url).pipe( + Effect.tap((opened) => + Effect.sync(() => + update({ + _tag: 'device', + url: prompt.url, + code: prompt.userCode, + opened, + }), + ), + ), + Effect.asVoid, ), + }).pipe( + Layer.provideMerge( + layerOpenCodeAuthStore(openCodeAuthStoreOptions(provider, options.foldHome)), ), - Effect.asVoid, ), - }).pipe(Effect.provide(FetchHttpClient.layer)) - if (action === 'logout') { - yield* auth.logout + ), + ) + } + if (providerKind === 'xai') { + return yield* Effect.gen(function* () { + const store = yield* XaiAuthStore + if (action === 'status') { + update({ _tag: 'working', message: 'Checking stored xAI credential...' }) + const token = yield* store.load + if (Option.isNone(token)) + return update({ + _tag: 'success', + message: 'No xAI credential stored.', + authStatus: 'logged-out', + }) + const now = yield* Clock.currentTimeMillis + const expired = token.value.isExpired(now) + return update({ + _tag: 'success', + message: `xAI credential is ${expired ? 'expired' : 'valid'} (expires ${new Date(token.value.expires).toISOString()}).`, + authStatus: expired ? 'expired' : 'logged-in', + }) + } + const auth = yield* XaiAuth + if (action === 'logout') { + yield* auth.logout + return update({ + _tag: 'success', + message: 'xAI credential removed.', + authStatus: 'logged-out', + }) + } + update({ _tag: 'working', message: `Starting xAI ${action} login...` }) + yield* action === 'browser' ? auth.authenticateBrowser : auth.authenticateDevice return update({ _tag: 'success', - message: 'OpenCode credential removed.', - authStatus: 'logged-out', + message: 'xAI authentication saved successfully.', + authStatus: 'logged-in', }) - } - if (action === 'browser') - return update({ _tag: 'failure', message: 'OpenCode supports device login only. Press D.' }) - update({ _tag: 'working', message: 'Starting OpenCode device login...' }) - yield* auth.authenticateDevice - return update({ - _tag: 'success', - message: 'OpenCode authentication saved successfully.', - authStatus: 'logged-in', - }) + }).pipe( + Effect.provide( + layerXaiAuth({ + onBrowserUrl: (url) => + openUrlInBrowser(url).pipe( + Effect.tap((opened) => + Effect.sync(() => update({ _tag: 'browser', url, opened })), + ), + Effect.asVoid, + ), + onDeviceCode: (prompt) => + openUrlInBrowser(prompt.browserUrl).pipe( + Effect.tap((opened) => + Effect.sync(() => + update({ + _tag: 'device', + url: prompt.browserUrl, + code: prompt.userCode, + opened, + }), + ), + ), + Effect.asVoid, + ), + }).pipe( + Layer.provideMerge( + layerXaiAuthStore(xaiAuthStoreOptions(provider, options.foldHome)), + ), + ), + ), + ) } - if (providerKind === 'xai') { - const store = yield* makeXaiAuthStore(xaiAuthStoreOptions(provider, options.foldHome)) + return yield* Effect.gen(function* () { + const store = yield* CodexAuthStore if (action === 'status') { - update({ _tag: 'working', message: 'Checking stored xAI credential...' }) + update({ _tag: 'working', message: 'Checking stored credential...' }) const token = yield* store.load if (Option.isNone(token)) return update({ _tag: 'success', - message: 'No xAI credential stored.', + message: 'No Codex credential stored.', authStatus: 'logged-out', }) const now = yield* Clock.currentTimeMillis const expired = token.value.isExpired(now) return update({ _tag: 'success', - message: `xAI credential is ${expired ? 'expired' : 'valid'} (expires ${new Date(token.value.expires).toISOString()}).`, + message: `Codex credential is ${expired ? 'expired' : 'valid'} (expires ${new Date(token.value.expires).toISOString()}).`, authStatus: expired ? 'expired' : 'logged-in', }) } - const auth = yield* makeXaiAuth({ - store, - onBrowserUrl: (url) => - openUrlInBrowser(url).pipe( - Effect.tap((opened) => Effect.sync(() => update({ _tag: 'browser', url, opened }))), - Effect.asVoid, - ), - onDeviceCode: (prompt) => - openUrlInBrowser(prompt.browserUrl).pipe( - Effect.tap((opened) => - Effect.sync(() => - update({ - _tag: 'device', - url: prompt.browserUrl, - code: prompt.userCode, - opened, - }), - ), - ), - Effect.asVoid, - ), - }).pipe(Effect.provide(FetchHttpClient.layer)) + const auth = yield* CodexAuth if (action === 'logout') { + update({ _tag: 'working', message: 'Removing stored credential...' }) yield* auth.logout return update({ _tag: 'success', - message: 'xAI credential removed.', + message: 'Codex credential removed.', authStatus: 'logged-out', }) } - update({ _tag: 'working', message: `Starting xAI ${action} login...` }) + update({ _tag: 'working', message: `Starting ${action} login...` }) yield* action === 'browser' ? auth.authenticateBrowser : auth.authenticateDevice - return update({ + update({ _tag: 'success', - message: 'xAI authentication saved successfully.', + message: 'Codex authentication saved successfully.', authStatus: 'logged-in', }) - } - const store = yield* makeCodexAuthStore(codexAuthStoreOptions(provider, options.foldHome)) - if (action === 'status') { - update({ _tag: 'working', message: 'Checking stored credential...' }) - const token = yield* store.load - if (Option.isNone(token)) - return update({ - _tag: 'success', - message: 'No Codex credential stored.', - authStatus: 'logged-out', - }) - const now = yield* Clock.currentTimeMillis - const expired = token.value.isExpired(now) - return update({ - _tag: 'success', - message: `Codex credential is ${expired ? 'expired' : 'valid'} (expires ${new Date(token.value.expires).toISOString()}).`, - authStatus: expired ? 'expired' : 'logged-in', - }) - } - const auth = yield* makeCodexAuth({ - store, - onBrowserUrl: (url) => - openUrlInBrowser(url).pipe( - Effect.tap((opened) => Effect.sync(() => update({ _tag: 'browser', url, opened }))), - Effect.asVoid, - ), - onDeviceCode: (prompt) => - openUrlInBrowser(prompt.verifyUrl).pipe( - Effect.tap((opened) => - Effect.sync(() => - update({ - _tag: 'device', - url: prompt.verifyUrl, - code: prompt.userCode, - opened, - }), + }).pipe( + Effect.provide( + layerCodexAuth({ + onBrowserUrl: (url) => + openUrlInBrowser(url).pipe( + Effect.tap((opened) => + Effect.sync(() => update({ _tag: 'browser', url, opened })), + ), + Effect.asVoid, + ), + onDeviceCode: (prompt) => + openUrlInBrowser(prompt.verifyUrl).pipe( + Effect.tap((opened) => + Effect.sync(() => + update({ + _tag: 'device', + url: prompt.verifyUrl, + code: prompt.userCode, + opened, + }), + ), + ), + Effect.asVoid, ), + }).pipe( + Layer.provideMerge( + layerCodexAuthStore(codexAuthStoreOptions(provider, options.foldHome)), ), - Effect.asVoid, ), - }).pipe(Effect.provide(FetchHttpClient.layer)) - if (action === 'logout') { - update({ _tag: 'working', message: 'Removing stored credential...' }) - yield* auth.logout - return update({ - _tag: 'success', - message: 'Codex credential removed.', - authStatus: 'logged-out', - }) - } - update({ _tag: 'working', message: `Starting ${action} login...` }) - yield* action === 'browser' ? auth.authenticateBrowser : auth.authenticateDevice - update({ - _tag: 'success', - message: 'Codex authentication saved successfully.', - authStatus: 'logged-in', - }) + ), + ) }).pipe( Effect.catchCause((cause) => Effect.sync(() => update({ _tag: 'failure', message: Cause.pretty(cause) })), ), - Effect.provide(NodeFileSystem.layer), + Effect.provide(Layer.merge(NodeFileSystem.layer, FetchHttpClient.layer)), ), ) } @@ -396,7 +436,9 @@ export const runTui = ( return route._tag === 'session' ? workspace.get(route.sessionId) : null } const activate = ( - operation: Option.Option>, + operation: Option.Option< + Effect.Effect + >, focusInput: boolean, ): void => { if (Option.isNone(operation)) return @@ -420,7 +462,7 @@ export const runTui = ( const route = router.route() const wasActive = route._tag === 'session' && route.sessionId === sessionId if (wasActive) router.showPicker() - runFork(operation.value.pipe(Effect.catchCause(() => Effect.void))) + runFork(operation.value.pipe(Effect.ignoreCause)) } yield* Effect.tryPromise({ diff --git a/packages/fold-cli/src/tui/TuiSessionWorkspace.ts b/packages/fold-cli/src/tui/TuiSessionWorkspace.ts index a78f4a1..439ce2a 100644 --- a/packages/fold-cli/src/tui/TuiSessionWorkspace.ts +++ b/packages/fold-cli/src/tui/TuiSessionWorkspace.ts @@ -1,4 +1,5 @@ import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' +import * as NodeServices from '@effect/platform-node/NodeServices' import { deleteSession, launchSession, @@ -12,15 +13,19 @@ import { type ProfileModeName, type SessionToResumeNotFoundError, type FoldConfig, + type OutputStore, + Photon, } from '@humanlayer/fold-agent' -import { layerLiveIdFactory, lookupCatalogEntry, type SessionId, type FoldSession } from '@humanlayer/fold-core' -import { Cause, Duration, Effect, type FileSystem, Match, Option, Scope } from 'effect' +import { layerLiveIdFactory, lookupCatalogEntry, type FoldSession, type SessionId } from '@humanlayer/fold-core' +import { Cause, Duration, Effect, type FileSystem, Layer, Match, Option, type Path, Scope } from 'effect' +import { FetchHttpClient, type HttpClient } from 'effect/unstable/http' +import type { ChildProcessSpawner } from 'effect/unstable/process' import { createSignal, type Accessor } from 'solid-js' import { contextUsedPercentForDisplay, contextWindowLimitForDisplay } from '../ContextWindow' import { makeHostedTuiSession, type HostedTuiSession, type HostedTuiSessionMetadata } from './HostedTuiSession' import { requestToLaunchOptions, sessionToLaunchOptions } from './LaunchRequests' -import { makeLiveSessionHost } from './LiveSessionHost' +import { type LiveSessionHostClosedError, makeLiveSessionHost } from './LiveSessionHost' import type { NewSessionRequest } from './NewSessionModal' import { projectSessionRows, type SessionRow } from './SessionListProjection' import type { TuiOptions } from './TuiSessionOptions' @@ -59,7 +64,9 @@ export type TuiSessionWorkspace = { readonly currentMode: Accessor readonly get: (sessionId: SessionId) => HostedTuiSession | null readonly openInitial: Effect.Effect - readonly open: (sessionId: SessionId) => Option.Option> + readonly open: ( + sessionId: SessionId, + ) => Option.Option> readonly create: (request: NewSessionRequest) => Option.Option> readonly delete: (sessionId: SessionId) => Option.Option> } @@ -142,19 +149,35 @@ export const makeTuiSessionWorkspace = (options: { ), ) } - const host = makeLiveSessionHost(parentScope, (hosted) => ({ + const host = makeLiveSessionHost(parentScope, (hosted) => ({ sessionId: hosted.sessionId, phase: 'live', status: hosted.state().status, })) yield* Effect.addFinalizer(() => host.closeAll) const acquire = ( - session: Effect.Effect, + session: Effect.Effect< + FoldSession< + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | OutputStore + | Photon + | HttpClient.HttpClient + >, + E, + | Scope.Scope + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | Photon + | HttpClient.HttpClient + >, metadata: HostedTuiSessionMetadata, focused: boolean, ) => session.pipe( - Effect.provide(NodeFileSystem.layer), + Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer, Photon.layer)), Effect.flatMap((value) => { const hostedOptions: Mutable[1]> = { metadata, diff --git a/packages/fold-cli/test/PromptRenderer.vi.test.ts b/packages/fold-cli/test/PromptRenderer.vi.test.ts index 7abca28..cc74b34 100644 --- a/packages/fold-cli/test/PromptRenderer.vi.test.ts +++ b/packages/fold-cli/test/PromptRenderer.vi.test.ts @@ -3,7 +3,7 @@ import { AgentId, EventId } from '@humanlayer/fold-core' import type { AgentFinishedLogEntry } from '@humanlayer/fold-core' import { Effect } from 'effect' -import { ASSISTANT_RESPONSE_BEGIN, ASSISTANT_RESPONSE_END, makePromptOutputRenderer } from '../src/index' +import { ASSISTANT_RESPONSE_BEGIN, ASSISTANT_RESPONSE_END, promptOutputRenderer } from '../src/index' const finished = (resultText: string | null): AgentFinishedLogEntry => ({ _tag: 'agent-finished', @@ -23,7 +23,7 @@ const render = (response: string | null, duplicate = false) => Effect.gen(function* () { const stdout: Array = [] const stderr: Array = [] - const renderer = makePromptOutputRenderer({ + const renderer = promptOutputRenderer({ colors: false, stdout: (text) => Effect.sync(() => void stdout.push(text)), stderr: (text) => Effect.sync(() => void stderr.push(text)), diff --git a/packages/fold-cli/test/Renderer.vi.test.ts b/packages/fold-cli/test/Renderer.vi.test.ts index a7ded5a..ab4bac8 100644 --- a/packages/fold-cli/test/Renderer.vi.test.ts +++ b/packages/fold-cli/test/Renderer.vi.test.ts @@ -3,12 +3,12 @@ import { AgentId, EventId, MessageId, SessionId, ToolCallId } from '@humanlayer/ import type { ActiveModel, ModelCatalogEntry, UsageEncoded } from '@humanlayer/fold-core' import { Effect } from 'effect' -import { makeJsonOutputRenderer, makeOutputRenderer, responseCostUsd } from '../src/index' +import { jsonOutputRenderer, humanOutputRenderer, responseCostUsd } from '../src/index' it.effect('renders the session id in the header and finish line', () => Effect.gen(function* () { const chunks: Array = [] - const renderer = makeOutputRenderer({ + const renderer = humanOutputRenderer({ colors: false, stdout: (text) => Effect.sync(() => { @@ -90,7 +90,7 @@ it.effect('renders the session id in the header and finish line', () => it.effect('json renderer emits only log rows in concise mode and finish is not duplicated', () => Effect.gen(function* () { const chunks: Array = [] - const renderer = makeJsonOutputRenderer({ + const renderer = jsonOutputRenderer({ mode: 'json-concise', stdout: (text) => Effect.sync(() => { @@ -133,7 +133,7 @@ it.effect('json renderer emits only log rows in concise mode and finish is not d it.effect('json renderer includes deltas in verbose mode', () => Effect.gen(function* () { const chunks: Array = [] - const renderer = makeJsonOutputRenderer({ + const renderer = jsonOutputRenderer({ mode: 'json-verbose', stdout: (text) => Effect.sync(() => { @@ -160,7 +160,7 @@ it.effect('json renderer includes deltas in verbose mode', () => it.effect('renders profile-based resume command when the session used --profile', () => Effect.gen(function* () { const chunks: Array = [] - const renderer = makeOutputRenderer({ + const renderer = humanOutputRenderer({ colors: false, stdout: (text) => Effect.sync(() => { @@ -241,7 +241,7 @@ const gptTestCatalog: ReadonlyArray = [ it.effect('with a catalog entry the usage table shows a real cost and the catalog context window', () => Effect.gen(function* () { const chunks: Array = [] - const renderer = makeOutputRenderer({ + const renderer = humanOutputRenderer({ colors: false, catalog: gptTestCatalog, stdout: (text) => @@ -315,7 +315,7 @@ it.effect('with a catalog entry the usage table shows a real cost and the catalo it.effect('tags every subagent line with its bracket label and keeps interleaved streams attributed', () => Effect.gen(function* () { const chunks: Array = [] - const renderer = makeOutputRenderer({ + const renderer = humanOutputRenderer({ colors: false, stdout: (text) => Effect.sync(() => { diff --git a/packages/fold-cli/test/SessionFlags.vi.test.ts b/packages/fold-cli/test/SessionFlags.vi.test.ts index 04ef54f..356afd6 100644 --- a/packages/fold-cli/test/SessionFlags.vi.test.ts +++ b/packages/fold-cli/test/SessionFlags.vi.test.ts @@ -44,7 +44,7 @@ it.effect('omits mode when the flag is absent', () => Effect.gen(function* () { const options = yield* sessionOptionsFromFlags(baseFlags) - expect('mode' in options).toBe(false) + expect(options).not.toHaveProperty('mode') }), ) @@ -69,7 +69,7 @@ it.effect('omits rpi when the flag is absent', () => Effect.gen(function* () { const options = yield* sessionOptionsFromFlags(baseFlags) - expect('rpi' in options).toBe(false) + expect(options).not.toHaveProperty('rpi') }), ) @@ -79,7 +79,7 @@ it.effect('lowers --profile into the session options and omits it when absent', const without = yield* sessionOptionsFromFlags(baseFlags) expect(withProfile.profile).toBe('ultraclaude') - expect('profile' in without).toBe(false) + expect(without).not.toHaveProperty('profile') }), ) diff --git a/packages/fold-cli/test/fixtures/TuiCompactionFixture.tsx b/packages/fold-cli/test/fixtures/TuiCompactionFixture.tsx index 310ebb5..7999da7 100644 --- a/packages/fold-cli/test/fixtures/TuiCompactionFixture.tsx +++ b/packages/fold-cli/test/fixtures/TuiCompactionFixture.tsx @@ -8,7 +8,7 @@ import { createSignal } from 'solid-js' import { TuiApp } from '../../src/tui/App' import { makeSessionStateFromEntries } from '../../src/tui/SessionState' -const rootAgentId = Schema.decodeUnknownSync(AgentId)('agent_aaaaaaaaaaaaaaaaaaaaaaaa') +const rootAgentId = Schema.decodeSync(AgentId)('agent_aaaaaaaaaaaaaaaaaaaaaaaa') const entry = (input: Record) => Schema.decodeUnknownSync(LogEntry)({ ...input, eventId: EventId.create() }) const initialEntries = [ diff --git a/packages/fold-cli/test/fixtures/TuiInterruptedFixture.tsx b/packages/fold-cli/test/fixtures/TuiInterruptedFixture.tsx index 41a655c..b218c33 100644 --- a/packages/fold-cli/test/fixtures/TuiInterruptedFixture.tsx +++ b/packages/fold-cli/test/fixtures/TuiInterruptedFixture.tsx @@ -7,7 +7,7 @@ import { Schema } from 'effect' import { TuiApp } from '../../src/tui/App' import { makeSessionStateFromEntries } from '../../src/tui/SessionState' -const rootAgentId = Schema.decodeUnknownSync(AgentId)('agent_aaaaaaaaaaaaaaaaaaaaaaaa') +const rootAgentId = Schema.decodeSync(AgentId)('agent_aaaaaaaaaaaaaaaaaaaaaaaa') const entry = (input: Record) => Schema.decodeUnknownSync(LogEntry)({ ...input, eventId: EventId.create() }) const entries = [ diff --git a/packages/fold-cli/test/fixtures/TuiMarkdownFixture.tsx b/packages/fold-cli/test/fixtures/TuiMarkdownFixture.tsx index 8ff46f9..c81ca66 100644 --- a/packages/fold-cli/test/fixtures/TuiMarkdownFixture.tsx +++ b/packages/fold-cli/test/fixtures/TuiMarkdownFixture.tsx @@ -7,7 +7,7 @@ import { Schema } from 'effect' import { TuiApp } from '../../src/tui/App' import { makeSessionStateFromEntries } from '../../src/tui/SessionState' -const rootAgentId = Schema.decodeUnknownSync(AgentId)('agent_aaaaaaaaaaaaaaaaaaaaaaaa') +const rootAgentId = Schema.decodeSync(AgentId)('agent_aaaaaaaaaaaaaaaaaaaaaaaa') const entry = (input: Record) => Schema.decodeUnknownSync(LogEntry)({ ...input, eventId: EventId.create() }) const entries = [ diff --git a/packages/fold-cli/test/fixtures/TuiProviderManagementFixture.tsx b/packages/fold-cli/test/fixtures/TuiProviderManagementFixture.tsx index b28863b..720ceca 100644 --- a/packages/fold-cli/test/fixtures/TuiProviderManagementFixture.tsx +++ b/packages/fold-cli/test/fixtures/TuiProviderManagementFixture.tsx @@ -2,6 +2,7 @@ import type { ModelConfiguration } from '@humanlayer/fold-agent' /** @jsxImportSource @opentui/solid */ import { createCliRenderer } from '@opentui/core' import { render } from '@opentui/solid' +import { constVoid } from 'effect/Function' import { createSignal, Show } from 'solid-js' import { ProviderConfigPage } from '../../src/tui/ProviderConfigPage' @@ -62,9 +63,9 @@ await render( sessions={sessions} notice={notice} opening={() => false} - onOpen={() => {}} - onDelete={() => {}} - onNew={() => {}} + onOpen={constVoid} + onDelete={constVoid} + onNew={constVoid} onOpenProviders={() => setProvidersOpen(true)} onQuit={() => renderer.destroy()} /> @@ -81,7 +82,7 @@ await render( authStatus: action === 'logout' ? 'logged-out' : 'logged-in', }) } - onInitialize={() => {}} + onInitialize={constVoid} onCopyUrl={() => true} onConfigure={(input, update) => { setConfiguration((current) => ({ diff --git a/packages/fold-cli/test/fixtures/TuiSessionPickerFixture.tsx b/packages/fold-cli/test/fixtures/TuiSessionPickerFixture.tsx index a81b50c..3786ad1 100644 --- a/packages/fold-cli/test/fixtures/TuiSessionPickerFixture.tsx +++ b/packages/fold-cli/test/fixtures/TuiSessionPickerFixture.tsx @@ -7,8 +7,8 @@ import { createSignal } from 'solid-js' import { SessionPicker } from '../../src/tui/SessionPicker' -const firstId = Schema.decodeUnknownSync(SessionId)('sess_abcdefghijklmnopqrstuvwx') -const secondId = Schema.decodeUnknownSync(SessionId)('sess_bcdefghijklmnopqrstuvwxy') +const firstId = Schema.decodeSync(SessionId)('sess_abcdefghijklmnopqrstuvwx') +const secondId = Schema.decodeSync(SessionId)('sess_bcdefghijklmnopqrstuvwxy') const initialSessions = [ { sessionId: firstId, diff --git a/packages/fold-cli/test/fixtures/TuiToolInspectFixture.tsx b/packages/fold-cli/test/fixtures/TuiToolInspectFixture.tsx index b65f199..26bc68b 100644 --- a/packages/fold-cli/test/fixtures/TuiToolInspectFixture.tsx +++ b/packages/fold-cli/test/fixtures/TuiToolInspectFixture.tsx @@ -7,7 +7,7 @@ import { Schema } from 'effect' import { TuiApp } from '../../src/tui/App' import { makeSessionStateFromEntries } from '../../src/tui/SessionState' -const rootAgentId = Schema.decodeUnknownSync(AgentId)('agent_aaaaaaaaaaaaaaaaaaaaaaaa') +const rootAgentId = Schema.decodeSync(AgentId)('agent_aaaaaaaaaaaaaaaaaaaaaaaa') const entry = (input: Record) => Schema.decodeUnknownSync(LogEntry)({ ...input, eventId: EventId.create() }) const resultMessageIds = [ diff --git a/packages/fold-cli/test/tui/LiveSessionHost.vi.test.ts b/packages/fold-cli/test/tui/LiveSessionHost.vi.test.ts index 4eaa280..115b0b1 100644 --- a/packages/fold-cli/test/tui/LiveSessionHost.vi.test.ts +++ b/packages/fold-cli/test/tui/LiveSessionHost.vi.test.ts @@ -1,11 +1,12 @@ import { SessionId } from '@humanlayer/fold-core' -import { Deferred, Effect, Exit, Fiber, Schema, Scope } from 'effect' +import { Data, Deferred, Effect, Exit, Fiber, Schema, Scope } from 'effect' import { describe, expect, it } from 'vitest' import { makeLiveSessionHost } from '../../src/tui/LiveSessionHost' -const id = (suffix: string) => - Schema.decodeUnknownSync(SessionId)(`sess_${suffix.replace(/[^a-z0-9]/g, '').padEnd(24, 'x')}`) +const id = (suffix: string) => Schema.decodeSync(SessionId)(`sess_${suffix.replace(/[^a-z0-9]/g, '').padEnd(24, 'x')}`) +class AcquireFailed extends Data.TaggedError('AcquireFailed') {} + const snapshot = (value: { sessionId: SessionId; status: 'IDLE' }) => ({ ...value, phase: 'live' as const }) describe('LiveSessionHost', () => { @@ -136,9 +137,12 @@ describe('LiveSessionHost', () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const host = makeLiveSessionHost(yield* Scope.Scope, snapshot) + const host = makeLiveSessionHost<{ sessionId: SessionId; status: 'IDLE' }, AcquireFailed>( + yield* Scope.Scope, + snapshot, + ) for (const [sessionId, acquire] of [ - [id('failure'), Effect.fail('nope')], + [id('failure'), Effect.fail(new AcquireFailed())], [id('mismatch'), Effect.succeed({ sessionId: id('other'), status: 'IDLE' as const })], ] as const) { const first = yield* Effect.forkScoped(host.open(sessionId, acquire)) diff --git a/packages/fold-cli/test/tui/SessionListProjection.vi.test.ts b/packages/fold-cli/test/tui/SessionListProjection.vi.test.ts index b4c3810..09d65a2 100644 --- a/packages/fold-cli/test/tui/SessionListProjection.vi.test.ts +++ b/packages/fold-cli/test/tui/SessionListProjection.vi.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest' import { projectSessionRows } from '../../src/tui/SessionListProjection' const summary = (suffix: string, status: SessionSummary['status']) => ({ - sessionId: Schema.decodeUnknownSync(SessionId)(`sess_${suffix.padEnd(24, 'x')}`), + sessionId: Schema.decodeSync(SessionId)(`sess_${suffix.padEnd(24, 'x')}`), path: `/tmp/${suffix}.jsonl`, mtimeMs: 1, title: suffix, diff --git a/packages/fold-cli/test/tui/SessionPicker.vi.test.ts b/packages/fold-cli/test/tui/SessionPicker.vi.test.ts index 9f548ed..8af1221 100644 --- a/packages/fold-cli/test/tui/SessionPicker.vi.test.ts +++ b/packages/fold-cli/test/tui/SessionPicker.vi.test.ts @@ -6,7 +6,7 @@ import { relativeSessionTime, shortSessionId } from '../../src/tui/SessionPicker describe('TUI session picker formatting', () => { it('uses a compact display id without losing the typed full id at the data boundary', () => { - const sessionId = Schema.decodeUnknownSync(SessionId)('sess_abcdefghijklmnopqrstuvwx') + const sessionId = Schema.decodeSync(SessionId)('sess_abcdefghijklmnopqrstuvwx') expect(shortSessionId(sessionId)).toBe('sess_abcdef') }) diff --git a/packages/fold-cli/test/tui/SessionState.vi.test.ts b/packages/fold-cli/test/tui/SessionState.vi.test.ts index 7b238ee..59e0239 100644 --- a/packages/fold-cli/test/tui/SessionState.vi.test.ts +++ b/packages/fold-cli/test/tui/SessionState.vi.test.ts @@ -10,8 +10,8 @@ import { reduceSessionEvents, } from '../../src/tui/SessionState' -const rootAgentId = Schema.decodeUnknownSync(AgentId)('agent_aaaaaaaaaaaaaaaaaaaaaaaa') -const childAgentId = Schema.decodeUnknownSync(AgentId)('agent_bbbbbbbbbbbbbbbbbbbbbbbb') +const rootAgentId = Schema.decodeSync(AgentId)('agent_aaaaaaaaaaaaaaaaaaaaaaaa') +const childAgentId = Schema.decodeSync(AgentId)('agent_bbbbbbbbbbbbbbbbbbbbbbbb') const entry = (input: Record) => Schema.decodeUnknownSync(LogEntry)({ ...input, eventId: EventId.create(), version: 1 }) diff --git a/packages/fold-cli/test/tui/TuiConfigBootstrap.vi.test.ts b/packages/fold-cli/test/tui/TuiConfigBootstrap.vi.test.ts index b61c63d..66b245c 100644 --- a/packages/fold-cli/test/tui/TuiConfigBootstrap.vi.test.ts +++ b/packages/fold-cli/test/tui/TuiConfigBootstrap.vi.test.ts @@ -1,6 +1,6 @@ import { expect, it } from '@effect/vitest' import { describeModelConfiguration } from '@humanlayer/fold-agent' -import { Effect, FileSystem, Layer } from 'effect' +import { Effect, FileSystem } from 'effect' import { memoryFileFor, memoryFileSystem } from '../../../fold-agent/test/TestHelpers' import { providerManagementRows } from '../../src/tui/ProviderConfigState' @@ -15,7 +15,7 @@ it.effect('bootstraps and loads a fresh fold home before deriving provider manag Effect.gen(function* () { const fs = memoryFileSystem({}) const result = yield* bootstrapTuiConfig({ foldHome: '/fresh/.fold' }).pipe( - Effect.provide(Layer.succeed(FileSystem.FileSystem, fs)), + Effect.provideService(FileSystem.FileSystem, fs), ) expect(result.notice).toBeNull() @@ -46,7 +46,7 @@ it.effect('does not rewrite an old commented config while virtual provider rows }\n` const fs = memoryFileSystem({ '/old/.fold/config.jsonc': oldConfig }) const result = yield* bootstrapTuiConfig({ foldHome: '/old/.fold' }).pipe( - Effect.provide(Layer.succeed(FileSystem.FileSystem, fs)), + Effect.provideService(FileSystem.FileSystem, fs), ) expect(result.notice).toBeNull() @@ -72,7 +72,7 @@ it.effect('surfaces bootstrap failure while canonical virtual rows remain availa const fs = { ...base, writeFileString: () => Effect.die(new Error('fixture write failure')) } const result = yield* bootstrapTuiConfig({ foldHome: '/blocked' }).pipe( // oxlint-disable-next-line typescript/consistent-type-assertions - Effect.provide(Layer.succeed(FileSystem.FileSystem, fs as FileSystem.FileSystem)), + Effect.provideService(FileSystem.FileSystem, fs as FileSystem.FileSystem), ) expect(result.config).toBeNull() diff --git a/packages/fold-cli/test/tui/TuiRouter.vi.test.ts b/packages/fold-cli/test/tui/TuiRouter.vi.test.ts index 8e6f089..c029447 100644 --- a/packages/fold-cli/test/tui/TuiRouter.vi.test.ts +++ b/packages/fold-cli/test/tui/TuiRouter.vi.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest' import { makeTuiRouter } from '../../src/tui/TuiRouter' -const sessionId = (suffix: string) => Schema.decodeUnknownSync(SessionId)(`sess_${suffix.padEnd(24, 'x')}`) +const sessionId = (suffix: string) => Schema.decodeSync(SessionId)(`sess_${suffix.padEnd(24, 'x')}`) describe('TuiRouter', () => { it('performs synchronous picker and session transitions', () => { diff --git a/packages/fold-cli/test/tui/TuiSessionWorkspace.vi.test.ts b/packages/fold-cli/test/tui/TuiSessionWorkspace.vi.test.ts index 12d6cbe..d1bde6d 100644 --- a/packages/fold-cli/test/tui/TuiSessionWorkspace.vi.test.ts +++ b/packages/fold-cli/test/tui/TuiSessionWorkspace.vi.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest' import { makeTuiRouter } from '../../src/tui/TuiRouter' import { makeTuiSessionWorkspace } from '../../src/tui/TuiSessionWorkspace' -const sessionId = Schema.decodeUnknownSync(SessionId)('sess_workspacexxxxxxxxxxxxxxx') +const sessionId = Schema.decodeSync(SessionId)('sess_workspacexxxxxxxxxxxxxxx') describe('TuiSessionWorkspace', () => { it('admits one operation synchronously without changing the route', async () => { diff --git a/packages/fold-codex/package.json b/packages/fold-codex/package.json index f008d7d..bd9c093 100644 --- a/packages/fold-codex/package.json +++ b/packages/fold-codex/package.json @@ -25,7 +25,6 @@ "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", - "@humanlayer/fold-agent": "workspace:*", "@humanlayer/fold-vitest-config": "workspace:*", "effect": "catalog:", "typescript": "catalog:", diff --git a/packages/fold-codex/src/AuthStore.ts b/packages/fold-codex/src/AuthStore.ts index f9bbc96..5e03a76 100644 --- a/packages/fold-codex/src/AuthStore.ts +++ b/packages/fold-codex/src/AuthStore.ts @@ -1,16 +1,16 @@ /** * File-backed Codex credential store: one provider-keyed JSON document (default `~/.fold/auth.json`) * holding OAuth tokens only (D23). Field names are agentlayer-compatible (`access`/`refresh`/`expires`/ - * `accountId`), so existing entries copy across verbatim. Reads degrade to "no credentials" on missing - * or malformed data - the document may hold other providers' entries, so a bad codex entry is skipped, - * never clobbered; writes merge over the existing document and force `0600` permissions. The FileSystem - * is a default-or-override seam like fold-agent tools: tests pass an implementation, everyone else gets - * the Node platform filesystem. + * `accountId`), so existing entries copy across verbatim. The document may hold other providers' + * entries, so it decodes as provider-keyed JSON and only our entry decodes as a token. A missing file is + * an empty document. `load` degrades to "no credentials" (with a logged warning) on an unreadable or + * corrupt document or a bad codex entry; `save`/`clear` fail on an unreadable or corrupt document rather + * than clobber it. Writes merge over the existing document and force `0600` permissions. */ import { homedir } from 'node:os' import { dirname, join } from 'node:path' -import { Effect, FileSystem, Option, Schema } from 'effect' +import { Context, Effect, FileSystem, Layer, Option, Schema } from 'effect' /** Milliseconds before nominal expiry a token is already treated as expired (clanka parity). */ export const TOKEN_EXPIRY_BUFFER_MS = 30_000 @@ -23,7 +23,7 @@ export class CodexTokenData extends Schema.Class('fold/CodexToke type: Schema.Literal('oauth'), access: Schema.String, refresh: Schema.String, - expires: Schema.Number, + expires: Schema.Finite, accountId: Schema.optional(Schema.String), }) { /** True when the token is expired - or within the safety buffer of expiring - at `nowMs`. */ @@ -32,15 +32,15 @@ export class CodexTokenData extends Schema.Class('fold/CodexToke } } -/** Auth store persistence failure (reads never fail - they degrade to absent credentials). */ +/** Auth store failure: the document could not be read, is not provider-keyed JSON, or could not be written. */ export class CodexAuthStoreError extends Schema.TaggedError()('CodexAuthStoreError', { - reason: Schema.Literals(['WriteFailed']), + reason: Schema.Literals(['ReadFailed', 'InvalidDocument', 'WriteFailed']), message: Schema.String, cause: Schema.optional(Schema.Defect()), }) {} /** The credential store one CodexAuth instance persists through. */ -export type CodexAuthStore = { +export type CodexAuthStoreService = { /** Absolute path of the backing JSON document (used in error messages and guidance). */ readonly path: string readonly load: Effect.Effect> @@ -48,96 +48,135 @@ export type CodexAuthStore = { readonly clear: Effect.Effect } -/** Options for {@link makeCodexAuthStore}. */ -export type MakeCodexAuthStoreOptions = { +/** The credential store CodexAuth persists through; {@link layerCodexAuthStore} provides the file-backed one. */ +export class CodexAuthStore extends Context.Service()('fold/CodexAuthStore') {} + +/** Options for {@link layerCodexAuthStore}. */ +export type CodexAuthStoreOptions = { /** Path of the auth document. Defaults to `~/.fold/auth.json`. */ readonly path?: string /** Key of this provider's entry in the document. Defaults to `codex`. */ readonly providerId?: string } -/** The auth document is provider-keyed; entries other than ours are opaque and preserved verbatim. */ -const AuthDocument = Schema.Record(Schema.String, Schema.Unknown) +/** The auth document: JSON entries keyed by provider id. Entries other than ours are preserved verbatim. */ +export const CodexAuthDocument = Schema.Record(Schema.String, Schema.Json) +export type CodexAuthDocument = typeof CodexAuthDocument.Type -const decodeDocument = Schema.decodeUnknownOption(Schema.fromJsonString(AuthDocument)) +const emptyDocument: CodexAuthDocument = {} -const decodeToken = Schema.decodeUnknownOption(CodexTokenData) +const decodeDocument = Schema.decodeEffect(Schema.fromJsonString(CodexAuthDocument)) -const encodeToken = (token: CodexTokenData): Record => { - const encoded: Record = { - type: token.type, - access: token.access, - refresh: token.refresh, - expires: token.expires, - } - if (token.accountId !== undefined) encoded['accountId'] = token.accountId - return encoded -} +const encodeDocument = Schema.encodeEffect(Schema.fromJsonString(CodexAuthDocument, { space: 2 })) -/** Build a file-backed Codex credential store. */ -export const makeCodexAuthStore = ( - options?: MakeCodexAuthStoreOptions, -): Effect.Effect => - Effect.map(FileSystem.FileSystem, (fs) => { - const path = options?.path ?? defaultAuthStorePath() - const providerId = options?.providerId ?? 'codex' - - const readDocument: Effect.Effect> = fs.readFileString(path).pipe( - Effect.flatMap((content) => { - const document = decodeDocument(content) - return Option.isSome(document) - ? Effect.succeed(document.value) - : Effect.logWarning(`Auth store ${path} is not a JSON object; treating it as empty`).pipe( - Effect.as>({}), - ) - }), - // A missing (or unreadable) document is simply "no credentials stored yet". - Effect.catch(() => Effect.succeed>({})), - ) - - const writeDocument = (document: Record): Effect.Effect => - Effect.gen(function* () { - yield* fs.makeDirectory(dirname(path), { recursive: true }) - yield* fs.writeFileString(path, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 }) - // writeFileString's mode only applies on creation; force 0600 on pre-existing documents too. - yield* fs.chmod(path, 0o600) - }).pipe( +/** Our entry's JSON codec: decodes a document value into a token and encodes a token back to JSON. */ +const CodexTokenEntry = Schema.toCodecJson(CodexTokenData) + +const decodeTokenEntry = Schema.decodeOption(CodexTokenEntry) + +const encodeTokenEntry = Schema.encodeEffect(CodexTokenEntry) + +/** A file-backed Codex credential store. */ +export const layerCodexAuthStore = ( + options?: CodexAuthStoreOptions, +): Layer.Layer => + Layer.effect( + CodexAuthStore, + Effect.map(FileSystem.FileSystem, (fs): CodexAuthStoreService => { + const path = options?.path ?? defaultAuthStorePath() + const providerId = options?.providerId ?? 'codex' + + // A missing document is simply "no credentials stored yet"; any other read failure is real. + const readDocument: Effect.Effect = fs.readFileString(path).pipe( + Effect.asSome, + Effect.catchReason('PlatformError', 'NotFound', () => Effect.succeed(Option.none())), Effect.mapError( (cause) => new CodexAuthStoreError({ - reason: 'WriteFailed', - message: `Failed to write the auth store at ${path}`, + reason: 'ReadFailed', + message: `Failed to read the auth store at ${path}`, cause, }), ), + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(emptyDocument), + onSome: (text) => + decodeDocument(text).pipe( + Effect.mapError( + (cause) => + new CodexAuthStoreError({ + reason: 'InvalidDocument', + message: `Auth store ${path} is not a JSON object of provider entries`, + cause, + }), + ), + ), + }), + ), ) - const load = Effect.gen(function* () { - const document = yield* readDocument - const entry = document[providerId] - if (entry === undefined) return Option.none() + const writeDocument = (document: CodexAuthDocument): Effect.Effect => + Effect.gen(function* () { + yield* fs.makeDirectory(dirname(path), { recursive: true }) + const text = yield* encodeDocument(document) + yield* fs.writeFileString(path, `${text}\n`, { mode: 0o600 }) + // writeFileString's mode only applies on creation; force 0600 on pre-existing documents too. + yield* fs.chmod(path, 0o600) + }).pipe( + Effect.mapError( + (cause) => + new CodexAuthStoreError({ + reason: 'WriteFailed', + message: `Failed to write the auth store at ${path}`, + cause, + }), + ), + ) + + const load = Effect.gen(function* () { + const document = yield* readDocument + const entry = document[providerId] + if (entry === undefined) return Option.none() - const token = decodeToken(entry) - if (Option.isNone(token)) { - yield* Effect.logWarning(`Ignoring invalid "${providerId}" entry in ${path}`) - } + const token = decodeTokenEntry(entry) + if (Option.isNone(token)) { + yield* Effect.logWarning(`Ignoring invalid "${providerId}" entry in ${path}`) + } - return token - }).pipe(Effect.withSpan('fold.codexAuthStore.load')) + return token + }).pipe( + Effect.catchTag('CodexAuthStoreError', (error) => + Effect.logWarning(`${error.message}; treating it as holding no credentials`, error.cause).pipe( + Effect.as(Option.none()), + ), + ), + Effect.withSpan('fold.codexAuthStore.load'), + ) - const save = (token: CodexTokenData) => - Effect.gen(function* () { + const save = Effect.fn('fold.codexAuthStore.save')(function* (token: CodexTokenData) { const document = yield* readDocument - yield* writeDocument({ ...document, [providerId]: encodeToken(token) }) + const entry = yield* encodeTokenEntry(token).pipe( + Effect.mapError( + (cause) => + new CodexAuthStoreError({ + reason: 'WriteFailed', + message: `Failed to encode the "${providerId}" entry for ${path}`, + cause, + }), + ), + ) + yield* writeDocument({ ...document, [providerId]: entry }) return token - }).pipe(Effect.withSpan('fold.codexAuthStore.save')) + }) - const clear = Effect.gen(function* () { - const document = yield* readDocument - if (document[providerId] === undefined) return - const { [providerId]: _removed, ...rest } = document - yield* writeDocument(rest) - }).pipe(Effect.withSpan('fold.codexAuthStore.clear')) - - return { path, load, save, clear } - }) + const clear = Effect.gen(function* () { + const document = yield* readDocument + if (document[providerId] === undefined) return + const { [providerId]: _removed, ...rest } = document + yield* writeDocument(rest) + }).pipe(Effect.withSpan('fold.codexAuthStore.clear')) + + return { path, load, save, clear } + }), + ) diff --git a/packages/fold-codex/src/CodexAuth.ts b/packages/fold-codex/src/CodexAuth.ts index 03f32f6..ad892a5 100644 --- a/packages/fold-codex/src/CodexAuth.ts +++ b/packages/fold-codex/src/CodexAuth.ts @@ -5,21 +5,21 @@ * interactive flow (fold-codex is an SDK; the device/browser flows are the explicit `authenticate*` * calls a CLI wires up). Unlike clanka, a failed refresh keeps the stored credential: refresh failures * are often transient, and silently discarding a refresh token would force a re-login. The exported - * {@link withCodexAuth} wraps an HttpClient so every model request carries `Authorization` and + * {@link codexAuthenticatedClient} wraps an HttpClient so every model request carries `Authorization` and * `ChatGPT-Account-Id` plus the agentlayer-mined `originator`/`User-Agent`/`session_id` identity * headers; auth failures surface as transport errors on the client's normal error channel. */ import { arch, platform, release } from 'node:os' import * as NodeCrypto from '@effect/platform-node/NodeCrypto' -import { Clock, Context, Effect, Option, Semaphore } from 'effect' +import { Clock, Context, Effect, Layer, Option, Semaphore } from 'effect' import { HttpClient, HttpClientError, HttpClientRequest } from 'effect/unstable/http' -import { type CodexAuthStore, type CodexTokenData, makeCodexAuthStore } from './AuthStore' +import { CodexAuthStore, type CodexAuthStoreError, type CodexTokenData } from './AuthStore' import type { BrowserFlowOptions, DeviceCodePrompt } from './OAuthFlows' import { CodexAuthError, - makeIssuerHttpClient, + issuerHttpClient, preserveAccountId, refreshAccessToken, runBrowserFlow, @@ -50,10 +50,8 @@ export type CodexAuthService = { /** CodexAuth service tag. */ export class CodexAuth extends Context.Service()('fold/CodexAuth') {} -/** Options for {@link makeCodexAuth}. */ -export type MakeCodexAuthOptions = { - /** Credential store. Defaults to the `codex` entry of `~/.fold/auth.json`. */ - readonly store?: CodexAuthStore +/** Options for {@link layerCodexAuth}. */ +export type CodexAuthOptions = { /** Presents the device-flow prompt. Defaults to logging the URL + code. */ readonly onDeviceCode?: (prompt: DeviceCodePrompt) => Effect.Effect /** Presents the browser-flow authorization URL. Defaults to logging it. */ @@ -68,88 +66,101 @@ const defaultOnDeviceCode = (prompt: DeviceCodePrompt): Effect.Effect => const defaultOnBrowserUrl = (url: string): Effect.Effect => Effect.log(`To authenticate Codex, open this URL in your browser:\n${url}`) -/** Build a CodexAuth service over the ambient HttpClient. */ -export const makeCodexAuth = Effect.fnUntraced(function* (options?: MakeCodexAuthOptions) { - const store = options?.store ?? (yield* makeCodexAuthStore()) - const issuerClient = makeIssuerHttpClient(yield* HttpClient.HttpClient) - const semaphore = Semaphore.makeUnsafe(1) - - let currentToken = yield* store.load - - const storeFailed = (cause: unknown) => - new CodexAuthError({ - reason: 'StoreFailed', - message: `Failed to persist Codex credentials to ${store.path}`, - cause, - }) - - const saveToken = (token: CodexTokenData): Effect.Effect => - store.save(token).pipe( - Effect.mapError(storeFailed), - Effect.tap(() => - Effect.sync(() => { - currentToken = Option.some(token) - }), - ), - ) - - const clearToken = store.clear.pipe( - Effect.mapError(storeFailed), - Effect.tap(() => - Effect.sync(() => { - currentToken = Option.none() - }), - ), - ) +/** CodexAuth over the provided credential store and HttpClient. */ +export const layerCodexAuth = ( + options?: CodexAuthOptions, +): Layer.Layer => + Layer.effect( + CodexAuth, + Effect.gen(function* () { + const store = yield* CodexAuthStore + const issuerClient = issuerHttpClient(yield* HttpClient.HttpClient) + const semaphore = Semaphore.makeUnsafe(1) + + let currentToken = yield* store.load + + const storeFailed = (cause: CodexAuthStoreError) => + new CodexAuthError({ + reason: 'StoreFailed', + message: `Failed to persist Codex credentials to ${store.path}`, + cause, + }) - const getNoLock = Effect.uninterruptibleMask( - Effect.fnUntraced(function* (restore) { - const now = yield* Clock.currentTimeMillis - if (Option.isSome(currentToken) && !currentToken.value.isExpired(now)) { - return currentToken.value - } + const saveToken = (token: CodexTokenData): Effect.Effect => + store.save(token).pipe( + Effect.mapError(storeFailed), + Effect.tap(() => + Effect.sync(() => { + currentToken = Option.some(token) + }), + ), + ) + + const clearToken = store.clear.pipe( + Effect.mapError(storeFailed), + Effect.tap(() => + Effect.sync(() => { + currentToken = Option.none() + }), + ), + ) - if (Option.isNone(currentToken)) { - return yield* new CodexAuthError({ - reason: 'NotAuthenticated', - message: - `No Codex credentials found in ${store.path}. ` + - 'Authenticate with the device or browser flow, or copy an existing "codex" entry into the store.', - }) + const getNoLock = Effect.uninterruptibleMask( + Effect.fnUntraced(function* (restore) { + const now = yield* Clock.currentTimeMillis + if (Option.isSome(currentToken) && !currentToken.value.isExpired(now)) { + return currentToken.value + } + + if (Option.isNone(currentToken)) { + return yield* new CodexAuthError({ + reason: 'NotAuthenticated', + message: + `No Codex credentials found in ${store.path}. ` + + 'Authenticate with the device or browser flow, or copy an existing "codex" entry into the store.', + }) + } + + const refreshed = yield* restore(refreshAccessToken(issuerClient, currentToken.value.refresh)) + return yield* saveToken(preserveAccountId(refreshed, currentToken.value.accountId)) + }), + ) + + const runFlow = (flow: Effect.Effect) => + Effect.uninterruptibleMask((restore) => restore(flow).pipe(Effect.flatMap(saveToken))) + + const service: CodexAuthService = { + get: semaphore.withPermit(getNoLock).pipe(Effect.withSpan('fold.codexAuth.get')), + authenticateDevice: semaphore + .withPermit( + runFlow( + runDeviceFlow({ + client: issuerClient, + onCode: options?.onDeviceCode ?? defaultOnDeviceCode, + }), + ), + ) + .pipe(Effect.withSpan('fold.codexAuth.authenticateDevice')), + authenticateBrowser: semaphore + .withPermit( + runFlow( + runBrowserFlow({ + client: issuerClient, + onUrl: options?.onBrowserUrl ?? defaultOnBrowserUrl, + ...options?.browser, + }).pipe(Effect.provide(NodeCrypto.layer)), + ), + ) + .pipe(Effect.withSpan('fold.codexAuth.authenticateBrowser')), + logout: semaphore + .withPermit(Effect.uninterruptible(clearToken)) + .pipe(Effect.withSpan('fold.codexAuth.logout')), } - const refreshed = yield* restore(refreshAccessToken(issuerClient, currentToken.value.refresh)) - return yield* saveToken(preserveAccountId(refreshed, currentToken.value.accountId)) + return service }), ) - const runFlow = (flow: Effect.Effect) => - Effect.uninterruptibleMask((restore) => restore(flow).pipe(Effect.flatMap(saveToken))) - - const service: CodexAuthService = { - get: semaphore.withPermit(getNoLock).pipe(Effect.withSpan('fold.codexAuth.get')), - authenticateDevice: semaphore - .withPermit( - runFlow(runDeviceFlow({ client: issuerClient, onCode: options?.onDeviceCode ?? defaultOnDeviceCode })), - ) - .pipe(Effect.withSpan('fold.codexAuth.authenticateDevice')), - authenticateBrowser: semaphore - .withPermit( - runFlow( - runBrowserFlow({ - client: issuerClient, - onUrl: options?.onBrowserUrl ?? defaultOnBrowserUrl, - ...options?.browser, - }).pipe(Effect.provide(NodeCrypto.layer)), - ), - ) - .pipe(Effect.withSpan('fold.codexAuth.authenticateBrowser')), - logout: semaphore.withPermit(Effect.uninterruptible(clearToken)).pipe(Effect.withSpan('fold.codexAuth.logout')), - } - - return service -}) - /** Identity headers attached to every Codex model request. */ export type CodexIdentityOptions = { /** Overrides the default Codex-CLI-shaped User-Agent. */ @@ -186,24 +197,25 @@ const applyTokenHeaders = ( * needed) and carries the Codex identity headers. Auth failures surface as `TransportError`s on the * client's normal error channel, so downstream provider error mapping stays uniform. */ -export const withCodexAuth = ( +export const codexAuthenticatedClient = ( client: HttpClient.HttpClient, - auth: CodexAuthService, identity?: CodexIdentityOptions, -): HttpClient.HttpClient => - client.pipe( - HttpClient.mapRequestEffect((request) => - auth.get.pipe( - Effect.map((token) => applyTokenHeaders(request, token, identity)), - Effect.mapError( - (cause) => - new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ - request, - cause, - description: `Codex authentication failed: ${cause.message}`, +): Effect.Effect => + Effect.map(CodexAuth, (auth) => + client.pipe( + HttpClient.mapRequestEffect((request) => + auth.get.pipe( + Effect.map((token) => applyTokenHeaders(request, token, identity)), + Effect.mapError( + (cause) => + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + cause, + description: `Codex authentication failed: ${cause.message}`, + }), }), - }), + ), ), ), ), diff --git a/packages/fold-codex/src/CodexModel.ts b/packages/fold-codex/src/CodexModel.ts index 4c404ff..da19162 100644 --- a/packages/fold-codex/src/CodexModel.ts +++ b/packages/fold-codex/src/CodexModel.ts @@ -1,4 +1,3 @@ -import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * The fold-codex model descriptor: clanka's shape over the official `@humanlayer/effect-ai-openai` provider * pointed at the ChatGPT Codex backend (D23). The stock OpenAiLanguageModel runs unchanged; all Codex @@ -18,20 +17,21 @@ import { OpenAiClient, OpenAiLanguageModel } from '@humanlayer/effect-ai-openai' import type * as OpenAiSchema from '@humanlayer/effect-ai-openai/OpenAiSchema' import { customModel, resolveCodexReasoning } from '@humanlayer/fold-core' import type { ReasoningLevel, FoldModel } from '@humanlayer/fold-core' -import { Match, Context, Duration, Effect, Layer, Option, Schedule, Schema, Stream } from 'effect' -import type { Scope } from 'effect' -import { AiError } from 'effect/unstable/ai' -import type { LanguageModel } from 'effect/unstable/ai' -import { FetchHttpClient, HttpClient } from 'effect/unstable/http' +import { Array as Arr, Match, Context, Duration, Effect, Layer, Option, Schedule, Schema, Stream } from 'effect' +import type { FileSystem, Scope } from 'effect' +import { AiError, LanguageModel } from 'effect/unstable/ai' +import { HttpClient } from 'effect/unstable/http' import type { HttpClientResponse } from 'effect/unstable/http' -import type { CodexAuthStore } from './AuthStore' +import { layerCodexAuthStore, type CodexAuthStoreOptions } from './AuthStore' import type { CodexIdentityOptions } from './CodexAuth' -import { makeCodexAuth, withCodexAuth } from './CodexAuth' -import type { CodexHardeningOptions, CodexRetryOptions, StreamRetryInfo } from './Hardening' +import { codexAuthenticatedClient, layerCodexAuth } from './CodexAuth' +import type { CodexHardeningOptions, CodexRetryOptions, CodexStreamError, StreamRetryInfo } from './Hardening' import { CODEX_ERROR_MODULE, - codexAcquisitionStallError, + CodexFirstEventStall, + codexRetryAfter, + codexStallToAiError, defaultCodexHardening, isCodexRetryableBeforeFirstEvent, withFirstEventRetry, @@ -48,8 +48,8 @@ export const DEFAULT_REQUEST_RETRY_TIMES = 3 export const DEFAULT_CODEX_MODEL_ID = 'gpt-6.1-sol' type ResponsesPayload = Omit -type ResponseBody = typeof OpenAiSchema.Response.Type -type ResponseEvent = typeof OpenAiSchema.ResponseStreamEvent.Type +type ResponseBody = OpenAiSchema.Response +type ResponseEvent = OpenAiSchema.ResponseStreamEvent type EventStream = Stream.Stream type MutableCodexRetryOptions = { -readonly [Key in keyof CodexRetryOptions]: CodexRetryOptions[Key] } @@ -80,7 +80,7 @@ export const liftLeadingSystemIntoInstructions = (payload: ResponsesPayload): Re if (Option.isNone(decoded)) break leading.push(decoded.value.content) } - if (leading.length === 0) return payload + if (Arr.isArrayEmpty(leading)) return payload return { ...payload, instructions: leading.join('\n\n'), input: input.slice(leading.length) } } @@ -113,14 +113,16 @@ type ResponseFold = { */ export const decorateCodexClient = (inner: OpenAiClient.Service, options: CodexRetryOptions): OpenAiClient.Service => { // `min` caps the infinite exponential delay; `max` intersects it with the finite retry counter. - const retryDelaySchedule: Schedule.Schedule = Schedule.min([ + const retryDelaySchedule: Schedule.Schedule = Schedule.min([ Schedule.exponential(Duration.millis(options.firstEventRetryBaseDelayMs)), Schedule.spaced(Duration.millis(options.firstEventRetryMaxDelayMs)), ]).pipe(Schedule.jittered, (schedule) => Schedule.max([schedule, Schedule.recurs(options.firstEventTimeoutRetries)]), ) const retrySchedule = Schedule.passthrough(retryDelaySchedule).pipe( - Schedule.modifyDelay(({ output, duration }) => Effect.succeed(output.retryAfter ?? duration)), + Schedule.modifyDelay(({ output, duration }) => + Effect.succeed(Option.getOrElse(codexRetryAfter(output), () => duration)), + ), ) // One request attempt, bounded by the first-event timeout: a request that gets no response at all @@ -130,7 +132,8 @@ export const decorateCodexClient = (inner: OpenAiClient.Service, options: CodexR Effect.suspend(() => inner.createResponseStream(payload)).pipe( Effect.timeoutOrElse({ duration: Duration.millis(options.firstEventTimeoutMs), - orElse: () => Effect.fail(codexAcquisitionStallError(options.firstEventTimeoutMs)), + orElse: () => + Effect.fail(new CodexFirstEventStall({ phase: 'request', timeoutMs: options.firstEventTimeoutMs })), }), ) @@ -138,15 +141,16 @@ export const decorateCodexClient = (inner: OpenAiClient.Service, options: CodexR payload: ResponsesPayload, ): Effect.Effect => { const transformed = liftLeadingSystemIntoInstructions(payload) + type HardenedStream = Stream.Stream // Attempt 0 acquires eagerly (its HttpClientResponse is the tuple's response); retryable acquisition // failures retry in-effect - nothing has streamed yet, so a re-send cannot duplicate anything. return acquireOnce(transformed).pipe( Effect.retry({ while: isCodexRetryableBeforeFirstEvent, schedule: retrySchedule }), Effect.map(([response, firstStream]) => { - let pending: EventStream | null = firstStream + let pending: HardenedStream | null = firstStream - const makeAttempt = (): EventStream => { + const makeAttempt = (): HardenedStream => { if (pending !== null) { const stream = pending pending = null @@ -158,11 +162,21 @@ export const decorateCodexClient = (inner: OpenAiClient.Service, options: CodexR return Stream.unwrap(Effect.map(acquireOnce(transformed), ([, stream]) => stream)) } - const hardened = withFirstEventRetry(() => makeAttempt().pipe(withStallTimeouts(options)), options) + // Stalls stay typed through the retry policy; the OpenAI client contract carries only AiError. + const hardened: EventStream = withFirstEventRetry( + () => makeAttempt().pipe(withStallTimeouts(options)), + options, + ).pipe( + Stream.catchTags({ + CodexFirstEventStall: (stall) => Stream.fail(codexStallToAiError(stall)), + CodexIdleStall: (stall) => Stream.fail(codexStallToAiError(stall)), + }), + ) const result: readonly [HttpClientResponse.HttpClientResponse, EventStream] = [response, hardened] return result }), + Effect.catchTag('CodexFirstEventStall', (stall) => Effect.fail(codexStallToAiError(stall))), ) } @@ -198,8 +212,9 @@ export const decorateCodexClient = (inner: OpenAiClient.Service, options: CodexR // storage); the finished items arrive as `response.output_item.done` events, so // graft them in. A terminal response that does carry output (standard OpenAI // behavior) wins as-is. - const body: ResponseBody = - terminal.value.output.length > 0 ? terminal.value : { ...terminal.value, output: items } + const body: ResponseBody = Arr.isReadonlyArrayNonEmpty(terminal.value.output) + ? terminal.value + : { ...terminal.value, output: items } const result: readonly [ResponseBody, HttpClientResponse.HttpClientResponse] = [ body, @@ -224,8 +239,11 @@ export type CodexModelOptions = { readonly providerId?: string /** Override the backend base URL (testing/proxies). Defaults to the ChatGPT Codex backend. */ readonly apiUrl?: string - /** Credential store override. Defaults to the `codex` entry of `~/.fold/auth.json`. */ - readonly store?: CodexAuthStore + /** + * The auth document holding this provider's credential, under the `providerId` entry. Defaults to + * `~/.fold/auth.json`. + */ + readonly authStorePath?: string /** Identity headers (`originator`/`User-Agent`/`session_id`) sent on model requests. */ readonly identity?: CodexIdentityOptions /** Maximum transport retry attempts. Provider response retries use {@link CodexHardeningOptions.firstEventTimeoutRetries}. */ @@ -236,67 +254,76 @@ export type CodexModelOptions = { readonly onStreamRetry?: (info: StreamRetryInfo) => Effect.Effect } +/** The credential store for this model: its provider's entry in its auth document. */ +const authStoreOptionsFor = (options: CodexModelOptions): CodexAuthStoreOptions => + options.authStorePath === undefined + ? { providerId: options.providerId ?? 'codex' } + : { providerId: options.providerId ?? 'codex', path: options.authStorePath } + +/** This model's CodexAuth over its provider's credential store, on the host's HttpClient and FileSystem. */ +const authLayerFor = (options: CodexModelOptions) => + layerCodexAuth().pipe(Layer.provide(layerCodexAuthStore(authStoreOptionsFor(options)))) + /** - * Build the hardened Codex LanguageModel service. Self-contained: constructs its own fetch-backed - * HttpClient, CodexAuth over the credential store, and the decorated OpenAiClient against the Codex - * backend; the LanguageModel on top is the stock OpenAI provider. + * Build the hardened Codex LanguageModel service. Self-contained: provides its own fetch-backed + * HttpClient, CodexAuth over this provider's credential store, and the decorated OpenAiClient against + * the Codex backend; the LanguageModel on top is the stock OpenAI provider. */ export const makeCodexLanguageModel = ( options: CodexModelOptions, -): Effect.Effect => - Effect.gen(function* () { - const httpContext = yield* Layer.build(FetchHttpClient.layer) - const baseClient = Context.get(httpContext, HttpClient.HttpClient) - - const authOptions: { store?: CodexAuthStore } = {} - if (options.store !== undefined) authOptions.store = options.store - const auth = yield* makeCodexAuth(authOptions).pipe(Effect.provideService(HttpClient.HttpClient, baseClient)) - - // retryTransient sits below the auth wrapper: transport retries reuse the injected headers and never - // re-enter (or retry) the auth path itself. Status responses are mapped to AiError above this seam, - // where the first-event retry can honor a provider Retry-After rather than retrying a 429 immediately. - const modelClient = withCodexAuth( - baseClient.pipe( - HttpClient.retryTransient({ - retryOn: 'errors-only', - times: options.requestRetryTimes ?? DEFAULT_REQUEST_RETRY_TIMES, - }), - ), - auth, - options.identity, - ) - - const clientContext = yield* Layer.build(OpenAiClient.layer({ apiUrl: options.apiUrl ?? CODEX_API_URL })).pipe( - Effect.provideService(HttpClient.HttpClient, modelClient), - ) - const stockClient = Context.get(clientContext, OpenAiClient.OpenAiClient) - - const hardening: MutableCodexRetryOptions = { ...defaultCodexHardening, ...options.hardening } - if (options.onStreamRetry !== undefined) hardening.onStreamRetry = options.onStreamRetry - const codexClient = decorateCodexClient(stockClient, hardening) - - const reasoning = resolveCodexReasoning(options.reasoning ?? 'off') - const reasoningConfig = Match.valueTags(reasoning, { - disabled: () => ({}), - effort: ({ effort, summary }) => ({ reasoning: { effort, summary } }), - }) - - return yield* OpenAiLanguageModel.make({ - model: options.model ?? DEFAULT_CODEX_MODEL_ID, - config: { - // The ChatGPT backend does no server-side response storage (clanka parity). - store: false, - ...reasoningConfig, - }, - }).pipe(Effect.provideService(OpenAiClient.OpenAiClient, codexClient)) - }).pipe(Effect.provide(NodeFileSystem.layer)) +): Effect.Effect => + Layer.build( + Layer.effect( + LanguageModel.LanguageModel, + Effect.gen(function* () { + const baseClient = yield* HttpClient.HttpClient + + // retryTransient sits below the auth wrapper: transport retries reuse the injected headers and never + // re-enter (or retry) the auth path itself. Status responses are mapped to AiError above this seam, + // where the first-event retry can honor a provider Retry-After rather than retrying a 429 immediately. + const modelClient = yield* codexAuthenticatedClient( + baseClient.pipe( + HttpClient.retryTransient({ + retryOn: 'errors-only', + times: options.requestRetryTimes ?? DEFAULT_REQUEST_RETRY_TIMES, + }), + ), + options.identity, + ) + + const clientContext = yield* Layer.build( + OpenAiClient.layer({ apiUrl: options.apiUrl ?? CODEX_API_URL }), + ).pipe(Effect.provideService(HttpClient.HttpClient, modelClient)) + const stockClient = Context.get(clientContext, OpenAiClient.OpenAiClient) + + const hardening: MutableCodexRetryOptions = { ...defaultCodexHardening, ...options.hardening } + if (options.onStreamRetry !== undefined) hardening.onStreamRetry = options.onStreamRetry + const codexClient = decorateCodexClient(stockClient, hardening) + + const reasoning = resolveCodexReasoning(options.reasoning ?? 'off') + const reasoningConfig = Match.valueTags(reasoning, { + disabled: () => ({}), + effort: ({ effort, summary }) => ({ reasoning: { effort, summary } }), + }) + + return yield* OpenAiLanguageModel.make({ + model: options.model ?? DEFAULT_CODEX_MODEL_ID, + config: { + // The ChatGPT backend does no server-side response storage (clanka parity). + store: false, + ...reasoningConfig, + }, + }).pipe(Effect.provideService(OpenAiClient.OpenAiClient, codexClient)) + }), + ).pipe(Layer.provide(authLayerFor(options))), + ).pipe(Effect.map((context) => Context.get(context, LanguageModel.LanguageModel))) /** * Describe a model served by the ChatGPT Codex backend using stored Codex OAuth credentials. Plugs * into `startSession`/`switchModel` like any other model descriptor; the loop's per-request reasoning * and model-id binding work unchanged because the provider on top is the stock OpenAI one. */ -export const codexModel = (options: CodexModelOptions): FoldModel => { +export const codexModel = (options: CodexModelOptions): FoldModel => { const level = options.reasoning ?? 'off' return customModel({ diff --git a/packages/fold-codex/src/Hardening.ts b/packages/fold-codex/src/Hardening.ts index f7f1b73..e9e81cd 100644 --- a/packages/fold-codex/src/Hardening.ts +++ b/packages/fold-codex/src/Hardening.ts @@ -9,15 +9,12 @@ * producer latency per pull: the deadline arms when the consumer asks for the next event and clears * when one arrives, so consumer-side processing time never counts against the stream. */ -import { Duration, Effect, Random, Stream } from 'effect' +import { Data, Duration, Effect, Match, Option, Random, Stream } from 'effect' import { AiError } from 'effect/unstable/ai' /** `AiError.module` value marking errors minted by this package. */ export const CODEX_ERROR_MODULE = 'fold-codex' -const FIRST_EVENT_METHOD = 'streamText.firstEventTimeout' -const IDLE_METHOD = 'streamText.idleTimeout' - /** Stall-timeout and retry configuration for one Codex model. */ export type CodexHardeningOptions = { /** Max wait for the first stream event after the request is issued. */ @@ -39,45 +36,87 @@ export const defaultCodexHardening: CodexHardeningOptions = { eventIdleTimeoutMs: 120_000, } +/** + * Nothing arrived within the first-event window: either the request got no response (`request`) or its + * stream produced no event (`stream`). Retryable - nothing has reached the consumer yet. + */ +export class CodexFirstEventStall extends Data.TaggedError('CodexFirstEventStall')<{ + readonly phase: 'request' | 'stream' + readonly timeoutMs: number +}> { + override get message(): string { + return this.phase === 'request' + ? `No response received within ${this.timeoutMs}ms of sending the request (codex first-event timeout)` + : `No stream event received within ${this.timeoutMs}ms of the request (codex first-event timeout)` + } +} + +/** The stream went quiet mid-flight after partial output. Never retried. */ +export class CodexIdleStall extends Data.TaggedError('CodexIdleStall')<{ + readonly timeoutMs: number +}> { + override get message(): string { + return `No stream event received for ${this.timeoutMs}ms mid-stream (codex idle timeout)` + } +} + +/** The stall failures this package's timeouts raise. */ +export type CodexStall = CodexFirstEventStall | CodexIdleStall + +/** Every failure a hardened Codex stream can raise before the provider boundary. */ +export type CodexStreamError = AiError.AiError | CodexStall + /** One retry notification: `attempt` is the attempt about to run (1-based over the retries budget). */ export type StreamRetryInfo = { readonly attempt: number readonly delayMs: number - readonly error: AiError.AiError + readonly error: CodexStreamError } -const stallError = (method: string, description: string): AiError.AiError => - AiError.make({ - module: CODEX_ERROR_MODULE, - method, - reason: new AiError.InternalProviderError({ description }), - }) - /** - * The first-event stall error for the request-acquisition phase (request sent, no response yet). - * Classified identically to a stream first-event stall so both phases share one retry policy. + * A failure is safe to repeat only before the model has emitted any stream event: a first-event stall + * or a retryable provider error. Idle stalls are excluded because a fresh request could duplicate + * content or repeat a tool call that has already reached the agent runtime. */ -export const codexAcquisitionStallError = (timeoutMs: number): AiError.AiError => - stallError( - FIRST_EVENT_METHOD, - `No response received within ${timeoutMs}ms of sending the request (codex first-event timeout)`, +export const isCodexRetryableBeforeFirstEvent: (error: CodexStreamError) => boolean = + Match.type().pipe( + Match.tagsExhaustive({ + AiError: (error) => error.isRetryable, + CodexFirstEventStall: () => true, + CodexIdleStall: () => false, + }), ) -/** True for the first-event stall errors this package mints (the only retryable stall class). */ -export const isCodexFirstEventStall = (error: unknown): error is AiError.AiError => - 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 => - AiError.isAiError(error) && error.module === CODEX_ERROR_MODULE && error.method === IDLE_METHOD +/** The provider's Retry-After delay, when the failure carries one. */ +export const codexRetryAfter: (error: CodexStreamError) => Option.Option = + Match.type().pipe( + Match.tagsExhaustive({ + AiError: (error) => Option.fromUndefinedOr(error.retryAfter), + CodexFirstEventStall: () => Option.none(), + CodexIdleStall: () => Option.none(), + }), + ) /** - * 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. + * Lower a stall to the `AiError` the OpenAI client contract carries. The `method` keeps first-event and + * idle stalls distinguishable downstream. */ -export const isCodexRetryableBeforeFirstEvent = (error: unknown): error is AiError.AiError => - AiError.isAiError(error) && error.isRetryable && !isCodexIdleStall(error) +export const codexStallToAiError: (stall: CodexStall) => AiError.AiError = Match.type().pipe( + Match.tagsExhaustive({ + CodexFirstEventStall: (stall) => + AiError.make({ + module: CODEX_ERROR_MODULE, + method: 'streamText.firstEventTimeout', + reason: new AiError.InternalProviderError({ description: stall.message }), + }), + CodexIdleStall: (stall) => + AiError.make({ + module: CODEX_ERROR_MODULE, + method: 'streamText.idleTimeout', + reason: new AiError.InternalProviderError({ description: stall.message }), + }), + }), +) /** * Bound the stream's producer latency: the first event must arrive within `firstEventTimeoutMs` and @@ -87,22 +126,20 @@ export const isCodexRetryableBeforeFirstEvent = (error: unknown): error is AiErr */ export const withStallTimeouts = (options: Pick) => - (self: Stream.Stream): Stream.Stream => + (self: Stream.Stream): Stream.Stream => Stream.transformPull(self, (pull, _scope) => Effect.sync(() => { let seenFirstEvent = false return Effect.suspend(() => { - const timeoutMs = seenFirstEvent ? options.eventIdleTimeoutMs : options.firstEventTimeoutMs - const method = seenFirstEvent ? IDLE_METHOD : FIRST_EVENT_METHOD - const description = seenFirstEvent - ? `No stream event received for ${timeoutMs}ms mid-stream (codex idle timeout)` - : `No stream event received within ${timeoutMs}ms of the request (codex first-event timeout)` + const stall: CodexStall = seenFirstEvent + ? new CodexIdleStall({ timeoutMs: options.eventIdleTimeoutMs }) + : new CodexFirstEventStall({ phase: 'stream', timeoutMs: options.firstEventTimeoutMs }) return pull.pipe( Effect.timeoutOrElse({ - duration: Duration.millis(timeoutMs), - orElse: () => Effect.fail(stallError(method, description)), + duration: Duration.millis(stall.timeoutMs), + orElse: () => Effect.fail(stall), }), Effect.map((chunk) => { seenFirstEvent = true @@ -117,9 +154,9 @@ export const withStallTimeouts = export const firstEventRetryDelayMs = ( options: Pick, attempt: number, - error?: AiError.AiError, + retryAfter: Option.Option = Option.none(), ): Effect.Effect => { - if (error?.retryAfter !== undefined) return Effect.succeed(Duration.toMillis(error.retryAfter)) + if (Option.isSome(retryAfter)) return Effect.succeed(Duration.toMillis(retryAfter.value)) const max = options.firstEventRetryMaxDelayMs const target = Math.min(options.firstEventRetryBaseDelayMs * 2 ** attempt, max) @@ -144,7 +181,7 @@ const defaultOnStreamRetry = (info: StreamRetryInfo): Effect.Effect => * Retry-After takes precedence over the fallback jittered-exponential delay. Every mid-stream failure * propagates immediately. */ -export const withFirstEventRetry = ( +export const withFirstEventRetry = ( makeAttempt: () => Stream.Stream, options: CodexRetryOptions, ): Stream.Stream => { @@ -171,7 +208,7 @@ export const withFirstEventRetry = ( } return Stream.unwrap( - firstEventRetryDelayMs(options, n, error).pipe( + firstEventRetryDelayMs(options, n, codexRetryAfter(error)).pipe( Effect.tap((delayMs) => onStreamRetry({ attempt: n + 1, delayMs, error })), Effect.flatMap((delayMs) => Effect.sleep(Duration.millis(delayMs))), Effect.map(() => attempt(n + 1)), @@ -185,9 +222,12 @@ export const withFirstEventRetry = ( return attempt(0) } -/** Stall timeouts + first-event retry composed: the full Codex hardening pipeline for one request. */ -export const hardenCodexStream = ( +/** + * Stall timeouts + first-event retry composed: the full Codex hardening pipeline for one request. + * Stalls stay typed; lower them with {@link codexStallToAiError} at the provider boundary. + */ +export const hardenCodexStream = ( makeAttempt: () => Stream.Stream, options: CodexRetryOptions, -): Stream.Stream => - withFirstEventRetry(() => makeAttempt().pipe(withStallTimeouts(options)), options) +): Stream.Stream => + withFirstEventRetry(() => makeAttempt().pipe(withStallTimeouts(options)), options) diff --git a/packages/fold-codex/src/OAuthFlows.ts b/packages/fold-codex/src/OAuthFlows.ts index 10d9795..0ce1c4c 100644 --- a/packages/fold-codex/src/OAuthFlows.ts +++ b/packages/fold-codex/src/OAuthFlows.ts @@ -60,7 +60,7 @@ const TokenResponse = Schema.Struct({ id_token: Schema.optional(Schema.String), access_token: Schema.String, refresh_token: Schema.String, - expires_in: Schema.optional(Schema.Number), + expires_in: Schema.optional(Schema.Finite), }) type TokenResponse = typeof TokenResponse.Type @@ -79,45 +79,20 @@ type CodexAuthErrorInput = { // --- JWT account-id extraction (clanka port) -------------------------------------------------------- -/** The id/access-token claims Codex account resolution reads. */ -export type CodexJwtClaims = { - readonly chatgpt_account_id?: string - readonly 'https://api.openai.com/auth'?: { readonly chatgpt_account_id?: string } - 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 }] +/** + * The id/access-token claims Codex account resolution reads. Every claim is optional; other claims in + * the token are ignored. + */ +export const CodexJwtClaims = 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 }))), +}) +export type CodexJwtClaims = typeof CodexJwtClaims.Type - return Option.some(claims) -} +const decodeJwtClaimsJson = Schema.decodeOption(Schema.fromJsonString(CodexJwtClaims)) const decodeJwtPayload = (token: string): Option.Option => { const parts = token.split('.') @@ -131,7 +106,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(decodeJwtClaimsJson)) /** ChatGPT account id lookup order: direct claim, namespaced claim, first organization. */ export const extractAccountIdFromClaims = (claims: CodexJwtClaims): Option.Option => { @@ -196,7 +171,7 @@ export const preserveAccountId = (token: CodexTokenData, fallback: string | unde * The HttpClient every auth request goes through: issuer-relative URLs, non-2xx as errors, transient * failures retried (clanka's schedule - exponential from 150ms capped at 5s, 5 retries / 6 attempts). */ -export const makeIssuerHttpClient = (client: HttpClient.HttpClient): HttpClient.HttpClient => +export const issuerHttpClient = (client: HttpClient.HttpClient): HttpClient.HttpClient => client.pipe( HttpClient.mapRequest(HttpClientRequest.prependUrl(CODEX_ISSUER)), HttpClient.filterStatusOk, @@ -221,7 +196,7 @@ const deviceFlowError = (message: string, cause?: unknown) => authError('DeviceF const browserFlowError = (message: string, cause?: unknown) => authError('BrowserFlowFailed', message, cause) -/** Refresh an access token through the issuer. The client must come from {@link makeIssuerHttpClient}. */ +/** Refresh an access token through the issuer. The client must come from {@link issuerHttpClient}. */ export const refreshAccessToken = Effect.fn('fold.codexAuth.refreshAccessToken')(function* ( client: HttpClient.HttpClient, refresh: string, @@ -278,7 +253,7 @@ export type DeviceCodePrompt = { /** Options for {@link runDeviceFlow}. */ export type DeviceFlowOptions = { - /** Issuer-scoped client from {@link makeIssuerHttpClient}. */ + /** Issuer-scoped client from {@link issuerHttpClient}. */ readonly client: HttpClient.HttpClient /** Presents the verification URL + user code (CLI prints it, a TUI renders it, ...). */ readonly onCode: (prompt: DeviceCodePrompt) => Effect.Effect @@ -402,7 +377,7 @@ const errorHtml = (error: string): string => ` /** Options for {@link runBrowserFlow}. */ export type BrowserFlowOptions = { - /** Issuer-scoped client from {@link makeIssuerHttpClient}. */ + /** Issuer-scoped client from {@link issuerHttpClient}. */ readonly client: HttpClient.HttpClient /** Given the authorization URL to present/open (fold never launches a browser itself). */ readonly onUrl: (url: string) => Effect.Effect @@ -428,38 +403,40 @@ export const runBrowserFlow = Effect.fn('fold.codexAuth.browserFlow')(function* const code = yield* Effect.scoped( Effect.gen(function* () { + const effectContext = yield* Effect.context() + const callback = yield* Deferred.make() - const handleRequest = (rawUrl: string): { status: number; contentType: string; body: string } => { + const handleRequest = (rawUrl: string) => { const url = new URL(rawUrl, `http://${hostname}:${port}`) if (url.pathname === '/auth/callback') { const error = url.searchParams.get('error') if (error !== null) { const message = url.searchParams.get('error_description') ?? error - Effect.runSync(Deferred.fail(callback, browserFlowError(message))) + Effect.runSyncWith(effectContext)(Deferred.fail(callback, browserFlowError(message))) return { status: 200, contentType: 'text/html', body: errorHtml(message) } } const receivedCode = url.searchParams.get('code') if (receivedCode === null) { const message = 'Missing authorization code' - Effect.runSync(Deferred.fail(callback, browserFlowError(message))) + Effect.runSyncWith(effectContext)(Deferred.fail(callback, browserFlowError(message))) return { status: 400, contentType: 'text/html', body: errorHtml(message) } } if (url.searchParams.get('state') !== state) { const message = 'Invalid state - potential CSRF attack' - Effect.runSync(Deferred.fail(callback, browserFlowError(message))) + Effect.runSyncWith(effectContext)(Deferred.fail(callback, browserFlowError(message))) return { status: 400, contentType: 'text/html', body: errorHtml(message) } } - Effect.runSync(Deferred.succeed(callback, receivedCode)) + Effect.runSyncWith(effectContext)(Deferred.succeed(callback, receivedCode)) return { status: 200, contentType: 'text/html', body: successHtml } } if (url.pathname === '/cancel') { - Effect.runSync(Deferred.fail(callback, browserFlowError('Login cancelled'))) + Effect.runSyncWith(effectContext)(Deferred.fail(callback, browserFlowError('Login cancelled'))) return { status: 200, contentType: 'text/plain', body: 'Login cancelled' } } diff --git a/packages/fold-codex/test/AuthStore.vi.test.ts b/packages/fold-codex/test/AuthStore.vi.test.ts index 32de54c..427a796 100644 --- a/packages/fold-codex/test/AuthStore.vi.test.ts +++ b/packages/fold-codex/test/AuthStore.vi.test.ts @@ -4,15 +4,21 @@ import { join } from 'node:path' import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { describe, expect, it } from '@effect/vitest' -import { Effect, Option, Schema } from 'effect' +import { Effect, Layer, Option, Schema } from 'effect' -import { CodexTokenData, makeCodexAuthStore, TOKEN_EXPIRY_BUFFER_MS } from '../src/index' +import { + CodexAuthDocument, + CodexAuthStore, + CodexTokenData, + layerCodexAuthStore, + TOKEN_EXPIRY_BUFFER_MS, +} from '../src/index' const tempStorePath = (): string => join(mkdtempSync(join(tmpdir(), 'fold-codex-store-')), 'auth.json') -const decodeDocument = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))) +const decodeDocument = Schema.decodeUnknownOption(Schema.fromJsonString(CodexAuthDocument)) -const readDocument = (path: string): Record => { +const readDocument = (path: string): CodexAuthDocument => { const document = decodeDocument(readFileSync(path, 'utf8')) if (Option.isNone(document)) throw new Error(`invalid auth document at ${path}`) return document.value @@ -29,29 +35,33 @@ const sampleToken = new CodexTokenData({ describe('CodexAuthStore', () => { it.effect('load returns none for a missing store', () => Effect.gen(function* () { - const store = yield* makeCodexAuthStore({ path: tempStorePath() }) + const store = yield* CodexAuthStore const loaded = yield* store.load expect(Option.isNone(loaded)).toBe(true) - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe( + Effect.provide(layerCodexAuthStore({ path: tempStorePath() }).pipe(Layer.provide(NodeFileSystem.layer))), + ), ) it.effect('save/load round-trips and forces 0600 permissions', () => Effect.gen(function* () { const path = tempStorePath() - const store = yield* makeCodexAuthStore({ path }) - - yield* store.save(sampleToken) - const loaded = yield* store.load - - expect(Option.isSome(loaded)).toBe(true) - if (Option.isSome(loaded)) { - expect(loaded.value.access).toBe('access-token-1') - expect(loaded.value.refresh).toBe('refresh-token-1') - expect(loaded.value.expires).toBe(1_000_000) - expect(loaded.value.accountId).toBe('acct_123') - } - - expect(statSync(path).mode & 0o777).toBe(0o600) + yield* Effect.gen(function* () { + const store = yield* CodexAuthStore + + yield* store.save(sampleToken) + const loaded = yield* store.load + + expect(Option.isSome(loaded)).toBe(true) + if (Option.isSome(loaded)) { + expect(loaded.value.access).toBe('access-token-1') + expect(loaded.value.refresh).toBe('refresh-token-1') + expect(loaded.value.expires).toBe(1_000_000) + expect(loaded.value.accountId).toBe('acct_123') + } + + expect(statSync(path).mode & 0o777).toBe(0o600) + }).pipe(Effect.provide(layerCodexAuthStore({ path }))) }).pipe(Effect.provide(NodeFileSystem.layer)), ) @@ -60,12 +70,14 @@ describe('CodexAuthStore', () => { const path = tempStorePath() writeFileSync(path, JSON.stringify({ anthropic: { type: 'api', key: 'sk-other' } })) - const store = yield* makeCodexAuthStore({ path }) - yield* store.save(sampleToken) + yield* Effect.gen(function* () { + const store = yield* CodexAuthStore + yield* store.save(sampleToken) - const document = readDocument(path) - expect(document['anthropic']).toEqual({ type: 'api', key: 'sk-other' }) - expect(document['codex']).toMatchObject({ access: 'access-token-1' }) + const document = readDocument(path) + expect(document['anthropic']).toEqual({ type: 'api', key: 'sk-other' }) + expect(document['codex']).toMatchObject({ access: 'access-token-1' }) + }).pipe(Effect.provide(layerCodexAuthStore({ path }))) }).pipe(Effect.provide(NodeFileSystem.layer)), ) @@ -74,16 +86,18 @@ describe('CodexAuthStore', () => { const path = tempStorePath() writeFileSync(path, JSON.stringify({ anthropic: { type: 'api', key: 'sk-other' } })) - const store = yield* makeCodexAuthStore({ path }) - yield* store.save(sampleToken) - yield* store.clear + yield* Effect.gen(function* () { + const store = yield* CodexAuthStore + yield* store.save(sampleToken) + yield* store.clear - const document = readDocument(path) - expect(document['codex']).toBeUndefined() - expect(document['anthropic']).toEqual({ type: 'api', key: 'sk-other' }) + const document = readDocument(path) + expect(document['codex']).toBeUndefined() + expect(document['anthropic']).toEqual({ type: 'api', key: 'sk-other' }) - const loaded = yield* store.load - expect(Option.isNone(loaded)).toBe(true) + const loaded = yield* store.load + expect(Option.isNone(loaded)).toBe(true) + }).pipe(Effect.provide(layerCodexAuthStore({ path }))) }).pipe(Effect.provide(NodeFileSystem.layer)), ) @@ -92,11 +106,42 @@ describe('CodexAuthStore', () => { const path = tempStorePath() writeFileSync(path, 'not json at all {') - const store = yield* makeCodexAuthStore({ path }) - const loaded = yield* store.load + yield* Effect.gen(function* () { + const store = yield* CodexAuthStore + const loaded = yield* store.load - expect(Option.isNone(loaded)).toBe(true) - expect(readFileSync(path, 'utf8')).toBe('not json at all {') + expect(Option.isNone(loaded)).toBe(true) + expect(readFileSync(path, 'utf8')).toBe('not json at all {') + }).pipe(Effect.provide(layerCodexAuthStore({ path }))) + }).pipe(Effect.provide(NodeFileSystem.layer)), + ) + + it.effect('save and clear refuse to overwrite a corrupt document', () => + Effect.gen(function* () { + const path = tempStorePath() + writeFileSync(path, 'not json at all {') + + yield* Effect.gen(function* () { + const store = yield* CodexAuthStore + const saveError = yield* Effect.flip(store.save(sampleToken)) + const clearError = yield* Effect.flip(store.clear) + + expect(saveError.reason).toBe('InvalidDocument') + expect(clearError.reason).toBe('InvalidDocument') + expect(readFileSync(path, 'utf8')).toBe('not json at all {') + }).pipe(Effect.provide(layerCodexAuthStore({ path }))) + }).pipe(Effect.provide(NodeFileSystem.layer)), + ) + + it.effect('a token without an account id encodes without the key', () => + Effect.gen(function* () { + const path = tempStorePath() + yield* Effect.gen(function* () { + const store = yield* CodexAuthStore + yield* store.save(new CodexTokenData({ type: 'oauth', access: 'a', refresh: 'r', expires: 1 })) + + expect(readDocument(path)['codex']).toEqual({ type: 'oauth', access: 'a', refresh: 'r', expires: 1 }) + }).pipe(Effect.provide(layerCodexAuthStore({ path }))) }).pipe(Effect.provide(NodeFileSystem.layer)), ) @@ -105,9 +150,11 @@ describe('CodexAuthStore', () => { const path = tempStorePath() writeFileSync(path, JSON.stringify({ codex: { type: 'api', key: 'wrong-shape' } })) - const store = yield* makeCodexAuthStore({ path }) - const loaded = yield* store.load - expect(Option.isNone(loaded)).toBe(true) + yield* Effect.gen(function* () { + const store = yield* CodexAuthStore + const loaded = yield* store.load + expect(Option.isNone(loaded)).toBe(true) + }).pipe(Effect.provide(layerCodexAuthStore({ path }))) }).pipe(Effect.provide(NodeFileSystem.layer)), ) diff --git a/packages/fold-codex/test/CodexAuth.vi.test.ts b/packages/fold-codex/test/CodexAuth.vi.test.ts index 6e1a1ff..7ce7db6 100644 --- a/packages/fold-codex/test/CodexAuth.vi.test.ts +++ b/packages/fold-codex/test/CodexAuth.vi.test.ts @@ -8,13 +8,14 @@ import { Effect, Layer, Option, Predicate } from 'effect' import { FetchHttpClient, type HttpClient } from 'effect/unstable/http' import { + CodexAuth, + CodexAuthStore, CodexTokenData, extractAccountIdFromToken, - makeCodexAuth, - makeCodexAuthStore, + layerCodexAuth, + layerCodexAuthStore, parseJwtClaims, } from '../src/index' -import type { CodexAuthStore } from '../src/index' const tempStorePath = (): string => join(mkdtempSync(join(tmpdir(), 'fold-codex-auth-')), 'auth.json') @@ -49,13 +50,25 @@ const scriptedFetchLayer = ( const jsonResponse = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) -const storeWith = (token?: CodexTokenData): Effect.Effect => +/** A temp auth document, holding `token` under the `codex` entry when one is given. */ +const seededStorePath = (token?: CodexTokenData): Effect.Effect => Effect.gen(function* () { - const store = yield* makeCodexAuthStore({ path: tempStorePath() }) - if (token !== undefined) yield* Effect.orDie(store.save(token)) - return store + const path = tempStorePath() + if (token !== undefined) { + yield* Effect.gen(function* () { + const store = yield* CodexAuthStore + yield* Effect.orDie(store.save(token)) + }).pipe(Effect.provide(layerCodexAuthStore({ path }))) + } + return path }).pipe(Effect.provide(NodeFileSystem.layer)) +/** CodexAuth and its credential store over the document at `path`, talking to the scripted `network`. */ +const authLayer = (path: string, network: Layer.Layer) => { + const store = layerCodexAuthStore({ path }).pipe(Layer.provide(NodeFileSystem.layer)) + return Layer.merge(layerCodexAuth().pipe(Layer.provide([store, network])), store) +} + describe('JWT account id extraction', () => { it('reads the direct claim first', () => { const token = jwtWith({ chatgpt_account_id: 'acct_direct' }) @@ -100,13 +113,15 @@ describe('CodexAuth.get', () => { const network = scriptedFetchLayer(() => { throw new Error('no network call expected') }) - const store = yield* storeWith(validToken) - const auth = yield* makeCodexAuth({ store }).pipe(Effect.provide(network.layer)) - - const token = yield* auth.get - expect(token.access).toBe('valid-access') - expect(network.requests).toHaveLength(0) - }).pipe(Effect.provide(NodeFileSystem.layer)), + const path = yield* seededStorePath(validToken) + yield* Effect.gen(function* () { + const auth = yield* CodexAuth + + const token = yield* auth.get + expect(token.access).toBe('valid-access') + expect(network.requests).toHaveLength(0) + }).pipe(Effect.provide(authLayer(path, network.layer))) + }), ) it.effect('fails NotAuthenticated when the store is empty', () => @@ -114,14 +129,17 @@ describe('CodexAuth.get', () => { const network = scriptedFetchLayer(() => { throw new Error('no network call expected') }) - const store = yield* storeWith() - const auth = yield* makeCodexAuth({ store }).pipe(Effect.provide(network.layer)) - - const result = yield* auth.get.pipe(Effect.flip) - expect(result._tag).toBe('CodexAuthError') - expect(result.reason).toBe('NotAuthenticated') - expect(result.message).toContain(store.path) - }).pipe(Effect.provide(NodeFileSystem.layer)), + const path = yield* seededStorePath() + yield* Effect.gen(function* () { + const auth = yield* CodexAuth + const store = yield* CodexAuthStore + + const result = yield* auth.get.pipe(Effect.flip) + expect(result._tag).toBe('CodexAuthError') + expect(result.reason).toBe('NotAuthenticated') + expect(result.message).toContain(store.path) + }).pipe(Effect.provide(authLayer(path, network.layer))) + }), ) it.effect('refreshes an expired token, persists it, and preserves the account id', () => @@ -140,19 +158,22 @@ describe('CodexAuth.get', () => { expires_in: 3600, }) }) - const store = yield* storeWith(expiredToken) - const auth = yield* makeCodexAuth({ store }).pipe(Effect.provide(network.layer)) - - const token = yield* auth.get - expect(token.access).toBe('fresh-access') - expect(token.refresh).toBe('fresh-refresh') - expect(token.expires).toBe(3_600_000) - expect(token.accountId).toBe('acct_old') - - const persisted = yield* store.load - expect(Option.isSome(persisted)).toBe(true) - if (Option.isSome(persisted)) expect(persisted.value.access).toBe('fresh-access') - }).pipe(Effect.provide(NodeFileSystem.layer)), + const path = yield* seededStorePath(expiredToken) + yield* Effect.gen(function* () { + const auth = yield* CodexAuth + const store = yield* CodexAuthStore + + const token = yield* auth.get + expect(token.access).toBe('fresh-access') + expect(token.refresh).toBe('fresh-refresh') + expect(token.expires).toBe(3_600_000) + expect(token.accountId).toBe('acct_old') + + const persisted = yield* store.load + expect(Option.isSome(persisted)).toBe(true) + if (Option.isSome(persisted)) expect(persisted.value.access).toBe('fresh-access') + }).pipe(Effect.provide(authLayer(path, network.layer))) + }), ) it.effect('extracts the account id from a refreshed id_token', () => @@ -165,12 +186,14 @@ describe('CodexAuth.get', () => { id_token: jwtWith({ chatgpt_account_id: 'acct_new' }), }), ) - const store = yield* storeWith(expiredToken) - const auth = yield* makeCodexAuth({ store }).pipe(Effect.provide(network.layer)) - - const token = yield* auth.get - expect(token.accountId).toBe('acct_new') - }).pipe(Effect.provide(NodeFileSystem.layer)), + const path = yield* seededStorePath(expiredToken) + yield* Effect.gen(function* () { + const auth = yield* CodexAuth + + const token = yield* auth.get + expect(token.accountId).toBe('acct_new') + }).pipe(Effect.provide(authLayer(path, network.layer))) + }), ) it.effect('single-flights concurrent refreshes', () => @@ -178,30 +201,35 @@ describe('CodexAuth.get', () => { const network = scriptedFetchLayer(() => jsonResponse({ access_token: 'fresh-access', refresh_token: 'fresh-refresh', expires_in: 3600 }), ) - const store = yield* storeWith(expiredToken) - const auth = yield* makeCodexAuth({ store }).pipe(Effect.provide(network.layer)) - - const [first, second] = yield* Effect.all([auth.get, auth.get], { concurrency: 2 }) - expect(first.access).toBe('fresh-access') - expect(second.access).toBe('fresh-access') - expect(network.requests).toHaveLength(1) - }).pipe(Effect.provide(NodeFileSystem.layer)), + const path = yield* seededStorePath(expiredToken) + yield* Effect.gen(function* () { + const auth = yield* CodexAuth + + const [first, second] = yield* Effect.all([auth.get, auth.get], { concurrency: 2 }) + expect(first.access).toBe('fresh-access') + expect(second.access).toBe('fresh-access') + expect(network.requests).toHaveLength(1) + }).pipe(Effect.provide(authLayer(path, network.layer))) + }), ) it.effect('a failed refresh surfaces RefreshFailed and keeps the stored credential', () => Effect.gen(function* () { // 400 is not transient, so the issuer client does not retry and the failure is immediate. const network = scriptedFetchLayer(() => jsonResponse({ error: 'invalid_grant' }, 400)) - const store = yield* storeWith(expiredToken) - const auth = yield* makeCodexAuth({ store }).pipe(Effect.provide(network.layer)) - - const result = yield* auth.get.pipe(Effect.flip) - expect(result.reason).toBe('RefreshFailed') - - const persisted = yield* store.load - expect(Option.isSome(persisted)).toBe(true) - if (Option.isSome(persisted)) expect(persisted.value.refresh).toBe('stale-refresh') - }).pipe(Effect.provide(NodeFileSystem.layer)), + const path = yield* seededStorePath(expiredToken) + yield* Effect.gen(function* () { + const auth = yield* CodexAuth + const store = yield* CodexAuthStore + + const result = yield* auth.get.pipe(Effect.flip) + expect(result.reason).toBe('RefreshFailed') + + const persisted = yield* store.load + expect(Option.isSome(persisted)).toBe(true) + if (Option.isSome(persisted)) expect(persisted.value.refresh).toBe('stale-refresh') + }).pipe(Effect.provide(authLayer(path, network.layer))) + }), ) it.effect('logout clears the stored credential', () => @@ -209,15 +237,18 @@ describe('CodexAuth.get', () => { const network = scriptedFetchLayer(() => { throw new Error('no network call expected') }) - const store = yield* storeWith(validToken) - const auth = yield* makeCodexAuth({ store }).pipe(Effect.provide(network.layer)) - - yield* auth.logout - const persisted = yield* store.load - expect(Option.isNone(persisted)).toBe(true) - - const result = yield* auth.get.pipe(Effect.flip) - expect(result.reason).toBe('NotAuthenticated') - }).pipe(Effect.provide(NodeFileSystem.layer)), + const path = yield* seededStorePath(validToken) + yield* Effect.gen(function* () { + const auth = yield* CodexAuth + const store = yield* CodexAuthStore + + yield* auth.logout + const persisted = yield* store.load + expect(Option.isNone(persisted)).toBe(true) + + const result = yield* auth.get.pipe(Effect.flip) + expect(result.reason).toBe('NotAuthenticated') + }).pipe(Effect.provide(authLayer(path, network.layer))) + }), ) }) diff --git a/packages/fold-codex/test/CodexModel.vi.test.ts b/packages/fold-codex/test/CodexModel.vi.test.ts index 06f3f43..b7cb93d 100644 --- a/packages/fold-codex/test/CodexModel.vi.test.ts +++ b/packages/fold-codex/test/CodexModel.vi.test.ts @@ -16,7 +16,7 @@ import { import type { CodexRetryOptions } from '../src/index' type ResponsesPayload = Omit -type ResponseEvent = typeof OpenAiSchema.ResponseStreamEvent.Type +type ResponseEvent = OpenAiSchema.ResponseStreamEvent type EventStream = Stream.Stream // The unknown-event fallback in the stream-event union makes plain tagged objects valid events. @@ -182,7 +182,7 @@ describe('decorateCodexClient', () => { const attempt = yield* Ref.updateAndGet(attempts, (count) => count + 1) if (attempt === 1) { yield* Deferred.succeed(firstAttempt, undefined) - return yield* Effect.fail(rateLimitError) + return yield* rateLimitError } return [httpResponse, Stream.make(tick('one'))] as const }), diff --git a/packages/fold-codex/test/CodexModelCredentials.vi.test.ts b/packages/fold-codex/test/CodexModelCredentials.vi.test.ts new file mode 100644 index 0000000..ad67df8 --- /dev/null +++ b/packages/fold-codex/test/CodexModelCredentials.vi.test.ts @@ -0,0 +1,67 @@ +/** + * A Codex model reads the credential saved under its own provider name in its own auth document - the + * same entry `foldcode auth codex login --provider --fold-home ` writes. + */ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' +import { expect, it } from '@effect/vitest' +import { Effect, Layer, Predicate } from 'effect' +import { FetchHttpClient } from 'effect/unstable/http' + +import { CodexAuthStore, CodexTokenData, layerCodexAuthStore, makeCodexLanguageModel } from '../src/index' + +const terminalSse = `data: ${JSON.stringify({ + type: 'response.completed', + response: { id: 'resp_credentials', model: 'gpt-5.5', created_at: 1, output: [] }, + sequence_number: 1, +})}\n\n` + +const isWebRequest = (input: string | URL | Request): input is Request => Predicate.hasProperty(input, 'url') + +it.effect('uses the credential saved under its provider name in its auth document', () => { + const authorizations: Array = [] + const capturingFetch: typeof fetch = Object.assign( + (input: string | URL | Request, init?: RequestInit) => { + const request = isWebRequest(input) ? input : new Request(String(input), init) + authorizations.push(request.headers.get('authorization')) + return Promise.resolve( + new Response(terminalSse, { status: 200, headers: { 'content-type': 'text/event-stream' } }), + ) + }, + { preconnect: fetch.preconnect }, + ) + + return Effect.gen(function* () { + // Only a `work` entry exists: the default `codex` entry is absent. + const authStorePath = join(mkdtempSync(join(tmpdir(), 'fold-codex-credentials-')), 'auth.json') + yield* Effect.gen(function* () { + const store = yield* CodexAuthStore + yield* store.save( + new CodexTokenData({ + type: 'oauth', + access: 'work-access', + refresh: 'work-refresh', + expires: Number.MAX_SAFE_INTEGER, + }), + ) + }).pipe(Effect.provide(layerCodexAuthStore({ path: authStorePath, providerId: 'work' }))) + + const model = yield* makeCodexLanguageModel({ + providerId: 'work', + authStorePath, + model: 'gpt-5.5', + apiUrl: 'https://codex.credentials.test/backend-api/codex', + requestRetryTimes: 0, + }) + yield* model.generateText({ prompt: 'hi' }) + + expect(authorizations).toEqual(['Bearer work-access']) + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(FetchHttpClient.layer, NodeFileSystem.layer)), + Effect.provideService(FetchHttpClient.Fetch, capturingFetch), + ) +}) diff --git a/packages/fold-codex/test/Hardening.vi.test.ts b/packages/fold-codex/test/Hardening.vi.test.ts index 1a7f146..c400f9f 100644 --- a/packages/fold-codex/test/Hardening.vi.test.ts +++ b/packages/fold-codex/test/Hardening.vi.test.ts @@ -1,17 +1,14 @@ import { describe, expect, it } from '@effect/vitest' -import { Deferred, Duration, Effect, Fiber, Stream } from 'effect' +import { Deferred, Duration, Effect, Fiber, Option, Predicate, Stream } from 'effect' import { TestClock } from 'effect/testing' import { AiError } from 'effect/unstable/ai' -import { - firstEventRetryDelayMs, - hardenCodexStream, - isCodexFirstEventStall, - isCodexIdleStall, - withStallTimeouts, -} from '../src/index' +import { firstEventRetryDelayMs, hardenCodexStream, withStallTimeouts } from '../src/index' import type { CodexRetryOptions, StreamRetryInfo } from '../src/index' +const isCodexFirstEventStall = Predicate.isTagged('CodexFirstEventStall') +const isCodexIdleStall = Predicate.isTagged('CodexIdleStall') + // Real-clock tests (it.live) with tiny durations: stall timeouts never fire under the TestClock. const fastOptions = (overrides?: Partial): CodexRetryOptions => ({ firstEventTimeoutMs: 60, @@ -146,7 +143,11 @@ describe('hardenCodexStream', () => { it.live('does not retry ordinary provider failures', () => Effect.gen(function* () { let attempts = 0 - const boom = new Error('provider exploded') + const boom = AiError.make({ + module: 'test', + method: 'streamText', + reason: new AiError.InvalidRequestError({ description: 'provider rejected the request' }), + }) const stream = hardenCodexStream(() => { attempts += 1 return Stream.fail(boom) @@ -218,11 +219,7 @@ describe('firstEventRetryDelayMs', () => { const delay = yield* firstEventRetryDelayMs( { firstEventRetryBaseDelayMs: 1000, firstEventRetryMaxDelayMs: 10_000 }, 0, - AiError.make({ - module: 'test', - method: 'streamText', - reason: new AiError.RateLimitError({ retryAfter: Duration.seconds(16) }), - }), + Option.some(Duration.seconds(16)), ) expect(delay).toBe(16_000) diff --git a/packages/fold-core/examples/AnthropicAgent.ts b/packages/fold-core/examples/AnthropicAgent.ts index 8ed7df3..08cee61 100644 --- a/packages/fold-core/examples/AnthropicAgent.ts +++ b/packages/fold-core/examples/AnthropicAgent.ts @@ -7,6 +7,7 @@ * Run: ANTHROPIC_API_KEY=... bun packages/fold-core/examples/AnthropicAgent.ts */ import { Console, Effect, Schema } from 'effect' +import { FetchHttpClient } from 'effect/unstable/http' import { anthropicModel, defineAgent, defineTool, startSession } from '../src/index' @@ -41,7 +42,7 @@ const makeProgram = (apiKey: string) => yield* Console.log(`finished: ${finished.outcome}`) yield* Console.log(`result: ${finished.resultText ?? '(no text)'}`) yield* Console.log(`log: ${entries.map((entry) => entry._tag).join(' -> ')}`) - }).pipe(Effect.scoped) + }).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer)) if (apiKey === undefined || apiKey === '') { console.error('Set ANTHROPIC_API_KEY to run this example.') diff --git a/packages/fold-core/examples/AutoCompactAgent.ts b/packages/fold-core/examples/AutoCompactAgent.ts index d324f0a..8f4d316 100644 --- a/packages/fold-core/examples/AutoCompactAgent.ts +++ b/packages/fold-core/examples/AutoCompactAgent.ts @@ -11,6 +11,7 @@ * Run: OPENAI_API_KEY=... bun packages/fold-core/examples/AutoCompactAgent.ts */ import { Predicate, Console, Effect } from 'effect' +import { FetchHttpClient } from 'effect/unstable/http' import { defineAgent, openaiModel, startSession, type CompactionLogEntry } from '../src/index' @@ -77,7 +78,7 @@ const makeProgram = (key: string) => yield* Console.log('\nsend 3: the session keeps running on the compacted context...') const third = yield* session.send('And what were we told about the dedupe window?') yield* Console.log(` -> ${third.resultText ?? '(no text)'}`) - }).pipe(Effect.scoped) + }).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer)) if (apiKey === undefined || apiKey === '') { console.error('Set OPENAI_API_KEY to run this example.') diff --git a/packages/fold-core/examples/ModelSwitch.ts b/packages/fold-core/examples/ModelSwitch.ts index 0379d26..5b17380 100644 --- a/packages/fold-core/examples/ModelSwitch.ts +++ b/packages/fold-core/examples/ModelSwitch.ts @@ -11,6 +11,7 @@ * Run: OPENAI_API_KEY=... ANTHROPIC_API_KEY=... bun packages/fold-core/examples/ModelSwitch.ts */ import { Console, Effect, Schema } from 'effect' +import { FetchHttpClient } from 'effect/unstable/http' import { anthropicModel, defineAgent, defineTool, openaiModel, startSession } from '../src/index' @@ -60,7 +61,7 @@ const makeProgram = (openAiKey: string, anthropicKey: string) => const entries = yield* session.entries yield* Console.log(`log: ${entries.map((entry) => entry._tag).join(' -> ')}`) - }).pipe(Effect.scoped) + }).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer)) if (openAiKey === undefined || openAiKey === '' || anthropicKey === undefined || anthropicKey === '') { console.error('Set OPENAI_API_KEY and ANTHROPIC_API_KEY to run this example.') diff --git a/packages/fold-core/examples/OpenaiAgent.ts b/packages/fold-core/examples/OpenaiAgent.ts index 76a04f1..e3fe044 100644 --- a/packages/fold-core/examples/OpenaiAgent.ts +++ b/packages/fold-core/examples/OpenaiAgent.ts @@ -6,6 +6,7 @@ * Run: OPENAI_API_KEY=... bun packages/fold-core/examples/OpenaiAgent.ts */ import { Console, Effect, Schema } from 'effect' +import { FetchHttpClient } from 'effect/unstable/http' import { defineAgent, defineTool, openaiModel, startSession } from '../src/index' @@ -40,7 +41,7 @@ const makeProgram = (apiKey: string) => yield* Console.log(`finished: ${finished.outcome}`) yield* Console.log(`result: ${finished.resultText ?? '(no text)'}`) yield* Console.log(`log: ${entries.map((entry) => entry._tag).join(' -> ')}`) - }).pipe(Effect.scoped) + }).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer)) if (apiKey === undefined || apiKey === '') { console.error('Set OPENAI_API_KEY to run this example.') diff --git a/packages/fold-core/examples/SkillsAgent.ts b/packages/fold-core/examples/SkillsAgent.ts index 01f13f4..d43587c 100644 --- a/packages/fold-core/examples/SkillsAgent.ts +++ b/packages/fold-core/examples/SkillsAgent.ts @@ -8,6 +8,7 @@ * Run: ANTHROPIC_API_KEY=... bun packages/fold-core/examples/SkillsAgent.ts */ import { Console, Effect } from 'effect' +import { FetchHttpClient } from 'effect/unstable/http' import { anthropicModel, defineAgent, skillsFromData, skillTool, startSession } from '../src/index' @@ -50,7 +51,7 @@ const makeProgram = (apiKey: string) => yield* Console.log(`finished: ${finished.outcome}`) yield* Console.log(`result:\n${finished.resultText ?? '(no text)'}`) yield* Console.log(`log: ${entries.map((entry) => entry._tag).join(' -> ')}`) - }).pipe(Effect.scoped) + }).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer)) if (apiKey === undefined || apiKey === '') { console.error('Set ANTHROPIC_API_KEY to run this example.') diff --git a/packages/fold-core/src/AgentEvents/AgentEventsService.ts b/packages/fold-core/src/AgentEvents/AgentEventsService.ts index 22922f2..0db942d 100644 --- a/packages/fold-core/src/AgentEvents/AgentEventsService.ts +++ b/packages/fold-core/src/AgentEvents/AgentEventsService.ts @@ -16,7 +16,7 @@ import type { AgentId, ToolCallId } from '../Ids' export type DeltaPart = | { readonly type: 'text-delta'; readonly id: string; readonly delta: string } | { readonly type: 'reasoning-delta'; readonly id: string; readonly delta: string } - | { readonly type: 'tool-progress'; readonly toolName: string; readonly payload: typeof Schema.Json.Type } + | { readonly type: 'tool-progress'; readonly toolName: string; readonly payload: Schema.Json } /** One event on the merged session stream: a durable log row or an ephemeral delta. */ export type FoldEvent = diff --git a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts index 8564b8c..c89046a 100644 --- a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts +++ b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts @@ -16,7 +16,7 @@ import { LanguageModel, Prompt, type Response, type Tool, type Toolkit } from 'e import { AgentEvents } from '../AgentEvents/AgentEventsService' import { CompactionArchiveAccess } from '../Compaction/CompactionArchiveAccess' import { isContextOverflowError } from '../Compaction/CompactionEngine' -import { Compaction, type CompactionService, type CompactionTrigger } from '../Compaction/CompactionService' +import { Compaction, type CompactionTrigger } from '../Compaction/CompactionService' import { EventLog } from '../EventLog/EventLogService' import { LogEntryInputs, @@ -30,7 +30,6 @@ import { import { usageFromResponseUsage } from '../EventLog/Usage' import { HookRunner } from '../HookRunner/HookRunnerService' import { Ids, type AgentId, type ToolCallId } from '../Ids' -import { ModelCatalog } from '../Model/ModelCatalog' import { ModelRequestSettings } from '../Model/ModelRequestSettings' import { buildPrompt, providerToolCallIdKey, foldPartOptionsKey } from '../Model/RequestBuilder' import { messagesForAgent, runtimeForAgent } from '../Projection/Projection' @@ -40,6 +39,7 @@ import { observeDoomLoop, StopConditions, type DoomLoopState, + type ToolCallFingerprintInput, } from '../StopConditions/StopConditions' import { SystemPrompt } from '../SystemPrompt/SystemPromptService' import { StopController, type StopControllerService } from '../ToolRuntime/ToolContextServices' @@ -59,36 +59,19 @@ const encodeSystemMessage = Schema.encodeUnknownSync(Prompt.SystemMessage) const anthropicEphemeralCacheControl = { type: 'ephemeral' } as const -type Mutable = { -readonly [Key in keyof T]: T[Key] } - const leadingSystemMessageFor = (content: string, cacheBreakpoint: boolean): Prompt.SystemMessage => { - const input: Mutable[0]> = { content } - if (cacheBreakpoint) { - input.options = { anthropic: { cacheControl: anthropicEphemeralCacheControl } } - } - - return Prompt.systemMessage(input) + const options = cacheBreakpoint ? { anthropic: { cacheControl: anthropicEphemeralCacheControl } } : undefined + return Prompt.systemMessage({ content, options }) } const encodeUserMessage = Schema.encodeUnknownSync(Prompt.UserMessage) const encodeAssistantMessage = Schema.encodeUnknownSync(Prompt.AssistantMessage) /** Result of one private model/tool turn. */ -type TurnResult = { readonly _tag: 'finished'; readonly entry: AgentFinishedLogEntry } | { readonly _tag: 'continue' } +type TurnResult = Data.TaggedEnum<{ finished: { readonly entry: AgentFinishedLogEntry }; continue: {} }> const TurnResult = Data.taggedEnum() type CompactionEnvelope = Pick -/** Derive a short human-readable message from a model provider failure. */ -const describeModelError = (error: unknown): string => { - if (Predicate.isError(error)) return error.message - - try { - return JSON.stringify(error) - } catch { - return String(error) - } -} - /** Concatenate the text parts of an assistant message, or null when it produced no text. */ const assistantResultText = (message: Prompt.AssistantMessage): string | null => { const text = message.content.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('') @@ -125,21 +108,11 @@ export const liveAgentRuntimeLayer: Layer.Layer< const languageModel = yield* LanguageModel.LanguageModel const agentEvents = yield* AgentEvents const sessionControls = yield* SessionControls - // Defaulted reference (D11): resolves the session-installed live policy, or the disabled no-op. - const installedCompaction = yield* Compaction + // Defaulted reference (D11): resolves the provisioned live policy, or the disabled no-op. The + // provisioning builds it over this runtime's own LanguageModel and the session ModelCatalog. + const compaction = yield* Compaction // Defaulted reference: host-specific post-compaction archive/log access guidance. const compactionArchiveAccess = yield* CompactionArchiveAccess - // Defaulted reference (D15): the session catalog is captured HERE, at layer construction under - // the session services, and re-provided around every compaction call - run effects execute on - // caller fibers whose context lacks session services, so the compaction checks would otherwise - // resolve the Reference's empty default instead of the installed catalog. - const modelCatalog = yield* ModelCatalog - const compaction: CompactionService = { - enabled: installedCompaction.enabled, - shouldCompact: (input) => - installedCompaction.shouldCompact(input).pipe(Effect.provideService(ModelCatalog, modelCatalog)), - plan: (input) => installedCompaction.plan(input).pipe(Effect.provideService(ModelCatalog, modelCatalog)), - } const stopConditions = yield* StopConditions const appendToEventLog = (input: LogEntryInput): Effect.Effect => @@ -206,8 +179,10 @@ export const liveAgentRuntimeLayer: Layer.Layer< const foldId = yield* ids.makeToolCallId content.push( Prompt.toolCallPart({ - ...part, id: foldId, + name: part.name, + params: part.params, + providerExecuted: part.providerExecuted, options: { ...part.options, [foldPartOptionsKey]: { [providerToolCallIdKey]: part.id } }, }), ) @@ -239,7 +214,7 @@ export const liveAgentRuntimeLayer: Layer.Layer< trigger, additionalInstructions, }) - .pipe(Effect.provideService(LanguageModel.LanguageModel, languageModel), Effect.result) + .pipe(Effect.result) if (Result.isFailure(planned)) { yield* appendToEventLog( @@ -262,7 +237,7 @@ export const liveAgentRuntimeLayer: Layer.Layer< trigger, }) - const compactionInput: Mutable[0]> = { + const compactionEntry = { agentId: input.agentId, parentAgentId: input.parentAgentId, toolCallId: input.toolCallId, @@ -272,15 +247,56 @@ export const liveAgentRuntimeLayer: Layer.Layer< replacesThroughSeq: planned.success.replacesThroughSeq, tokensBefore: planned.success.tokensBefore, } - if (postCompactionInstructions !== null) { - compactionInput.postCompactionInstructions = postCompactionInstructions - } - const entry = yield* appendToEventLog(LogEntryInputs['compaction'](compactionInput)) + const entry = yield* appendToEventLog( + LogEntryInputs['compaction']( + postCompactionInstructions === null + ? compactionEntry + : { ...compactionEntry, postCompactionInstructions }, + ), + ) if (Predicate.isTagged(entry, 'compaction')) return entry return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending compaction`)) }) + /** Settle one assistant message's tool calls, then decide whether the run stops or continues. */ + const settleToolCalls = ( + input: RunAgentInput, + persistedAssistant: Prompt.AssistantMessage, + toolCalls: ReadonlyArray, + doomLoopRef: Ref.Ref, + ): Effect.Effect => + Effect.gen(function* () { + const doomLoop = observeDoomLoop(stopConditions, yield* Ref.get(doomLoopRef), toolCalls) + yield* Ref.set(doomLoopRef, doomLoop.state) + + const settlement = yield* toolRuntime.settle({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + assistantMessage: persistedAssistant, + }) + + if (settlement.stopRequested) { + const entry = yield* appendFinished(input, 'stopped', null, 'a tool or hook requested a stop') + return TurnResult.finished({ entry }) + } + + // D9: a session-wide stop lets the in-flight batch finish and its results land (above), + // then ends the run here - no further LLM call. + const sessionStopAfterBatch = yield* sessionControls.sessionStopReason + if (sessionStopAfterBatch !== null) { + const entry = yield* appendFinished(input, 'stopped', null, sessionStopAfterBatch) + return TurnResult.finished({ entry }) + } + + if (doomLoop.reason !== null) { + const entry = yield* appendFinished(input, 'stopped', null, doomLoop.reason) + return TurnResult.finished({ entry }) + } + + return TurnResult.continue() + }) + /** Run one model turn: build the request, call the model, persist, and settle tool calls. */ const runTurn = ( input: RunAgentInput, @@ -380,7 +396,7 @@ export const liveAgentRuntimeLayer: Layer.Layer< ) if (Result.isFailure(modelParts)) { - const message = describeModelError(modelParts.failure) + const message = modelParts.failure.message // Reactive overflow path (D11): when the provider says the request exceeded the context // window, compact and restart the turn - once per run. A recovered attempt writes no @@ -447,37 +463,10 @@ export const liveAgentRuntimeLayer: Layer.Layer< const toolCalls = persistedAssistant.content.flatMap((part) => part.type === 'tool-call' ? [{ name: part.name, params: part.params }] : [], ) - const hasToolCalls = toolCalls.length > 0 + const hasToolCalls = Arr.isArrayNonEmpty(toolCalls) if (hasToolCalls) { - const doomLoop = observeDoomLoop(stopConditions, yield* Ref.get(doomLoopRef), toolCalls) - yield* Ref.set(doomLoopRef, doomLoop.state) - - const settlement = yield* toolRuntime.settle({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - assistantMessage: persistedAssistant, - }) - - if (settlement.stopRequested) { - const entry = yield* appendFinished(input, 'stopped', null, 'a tool or hook requested a stop') - return TurnResult.finished({ entry }) - } - - // D9: a session-wide stop lets the in-flight batch finish and its results land (above), - // then ends the run here - no further LLM call. - const sessionStopAfterBatch = yield* sessionControls.sessionStopReason - if (sessionStopAfterBatch !== null) { - const entry = yield* appendFinished(input, 'stopped', null, sessionStopAfterBatch) - return TurnResult.finished({ entry }) - } - - if (doomLoop.reason !== null) { - const entry = yield* appendFinished(input, 'stopped', null, doomLoop.reason) - return TurnResult.finished({ entry }) - } - - return TurnResult.continue() + return yield* settleToolCalls(input, persistedAssistant, toolCalls, doomLoopRef) } yield* Ref.set(doomLoopRef, initialDoomLoopState) @@ -502,7 +491,7 @@ export const liveAgentRuntimeLayer: Layer.Layer< // D8: follow-ups queued while this agent was running drain exactly where the run would // complete naturally - each becomes an ordinary user-message and the run continues. const followUps = yield* sessionControls.drainFollowUps(input.agentId) - if (followUps.length > 0) { + if (Arr.isReadonlyArrayNonEmpty(followUps)) { for (const text of followUps) { yield* appendUserMessage(input, text) } @@ -519,18 +508,13 @@ export const liveAgentRuntimeLayer: Layer.Layer< readonly parentAgentId: AgentId | null readonly toolCallId: ToolCallId | null readonly model: ActiveModel - readonly systemPrompt: string | ReadonlyArray | null + readonly systemPrompt: ReadonlyArray | null } /** Compose and append one epoch's leading system-message block set (agent start and model switch). */ const appendLeadingSystemMessage = (input: LeadingSystemMessageInput): Effect.Effect => Effect.gen(function* () { - const agentBlocks = - input.systemPrompt === null - ? [] - : typeof input.systemPrompt === 'string' - ? [input.systemPrompt] - : input.systemPrompt + const agentBlocks = input.systemPrompt ?? [] const blocks = yield* systemPrompt.compose({ model: input.model, agentBlocks }) @@ -556,7 +540,7 @@ export const liveAgentRuntimeLayer: Layer.Layer< // prompt block set once for the starting model; both are recorded durably. const resolvedToolset = yield* toolsetResolver.resolve({ model: input.model }) - const agentStartedInput: Mutable[0]> = { + const agentStarted = { agentId: input.agentId, parentAgentId: input.parentAgentId, toolCallId: input.toolCallId, @@ -567,10 +551,12 @@ export const liveAgentRuntimeLayer: Layer.Layer< fork: input.fork, agentType: input.agentType, } - if (input.promptCacheKey != null) { - agentStartedInput.promptCacheKey = input.promptCacheKey - } - const entry = yield* appendToEventLog(LogEntryInputs['agent_started'](agentStartedInput)) + const promptCacheKey = input.promptCacheKey ?? null + const entry = yield* appendToEventLog( + LogEntryInputs['agent_started']( + promptCacheKey === null ? agentStarted : { ...agentStarted, promptCacheKey }, + ), + ) // A fork appends no leading system message: its projection folds the forked-from agent's // history, leading blocks included, keeping the fork's prompt prefix byte-identical for diff --git a/packages/fold-core/src/AgentRuntime/AgentRuntimeService.ts b/packages/fold-core/src/AgentRuntime/AgentRuntimeService.ts index fa28298..02661df 100644 --- a/packages/fold-core/src/AgentRuntime/AgentRuntimeService.ts +++ b/packages/fold-core/src/AgentRuntime/AgentRuntimeService.ts @@ -34,7 +34,7 @@ export type StartAgentInput = { readonly model: ActiveModel readonly promptCacheKey?: string | null /** One leading system block, an ordered set of blocks (one system message each), or null for none. */ - readonly systemPrompt: string | ReadonlyArray | null + readonly systemPrompt: ReadonlyArray | null } /** Input for running one started agent through a full user-message-to-finished run. */ @@ -56,7 +56,7 @@ export type SwitchModelInput = { readonly toolCallId: ToolCallId | null readonly model: ActiveModel /** The agent's own prompt blocks, recomposed with the new family's base prompt. */ - readonly systemPrompt: string | ReadonlyArray | null + readonly systemPrompt: ReadonlyArray | null readonly reason: string | null } diff --git a/packages/fold-core/src/Api/AgentDefinition.ts b/packages/fold-core/src/Api/AgentDefinition.ts index d5a3c7c..646ef12 100644 --- a/packages/fold-core/src/Api/AgentDefinition.ts +++ b/packages/fold-core/src/Api/AgentDefinition.ts @@ -4,30 +4,49 @@ * can run against any event log backend, and the same definition's prompt blocks are recomposed when the * session switches models (D17). */ +import { Match } from 'effect' + import type { AutoCompactConfig } from '../Compaction/CompactionService' import type { HookConfig } from '../HookRunner/Types' import type { ModelFamily } from '../Model/ModelFamily' import type { StopConditionConfig } from '../StopConditions/StopConditions' import type { FoldModel } from './ModelDescriptor' -import type { FoldTool } from './ToolDefinition' +import { toolsNeedingAll, type FoldTool, type FoldToolServices } from './ToolDefinition' + +/** A leading system prompt as a host writes it: one block or an ordered set of blocks. */ +export type SystemPromptInput = string | ReadonlyArray + +/** + * Normalize a host-written system prompt to its ordered block list. Called once where a descriptor is + * received (session start, model switch, registry build), so everything downstream sees blocks only. + */ +export const systemPromptBlocks = (systemPrompt: SystemPromptInput | undefined): ReadonlyArray => + Match.value(systemPrompt).pipe( + Match.when(Match.undefined, (): ReadonlyArray => []), + Match.when(Match.string, (block): ReadonlyArray => [block]), + Match.orElse((blocks) => blocks), + ) -/** Configuration for one agent, as plain data. Built with {@link defineAgent}. */ -export type AgentDefinition = { +/** + * Configuration for one agent, as plain data. Built with {@link defineAgent}. `R` is every host service + * its tools need; `startSession` requires them from its caller. + */ +export type AgentDefinition = { /** Optional display name, recorded in `session_started` meta. */ readonly name?: string /** The model the agent starts on. Sessions can switch later with `FoldSession.switchModel`. */ - readonly model: FoldModel + readonly model: FoldModel /** Stable provider cache-affinity key. Forked children derive and persist their own key from this one. */ readonly promptCacheKey?: string /** The agent's own leading system prompt: one block or an ordered set of blocks. */ - readonly systemPrompt?: string | ReadonlyArray + readonly systemPrompt?: SystemPromptInput /** * Tools installed for this agent, from {@link defineTool} and the system-tool factories: skills * come from `skillTool(source)` and subagent dispatch from `subagentTool([...definitions])`, both * ordinary members of this array (round-five ruling; the former `skills` field is removed - * migrate `skills: src` to `tools: [..., skillTool(src)]`). */ - readonly tools?: ReadonlyArray + readonly tools?: ReadonlyArray> /** Hook configuration, run by this agent's HookRunner (D16). */ readonly hooks?: HookConfig /** @@ -46,5 +65,19 @@ export type AgentDefinition = { readonly stopConditions?: StopConditionConfig } -/** Define one agent. Identity today; the single place agent-config validation lands later. */ -export const defineAgent = (definition: AgentDefinition): AgentDefinition => definition +/** {@link AgentDefinition} as written by a caller: its tools keep their own types. */ +export type AgentDefinitionInput, RM> = Omit & { + readonly model: FoldModel + readonly tools?: ReadonlyArray +} + +/** + * Define one agent. The agent needs its model's services plus the union of its tools'. The single place + * agent-config validation lands later. + */ +export const defineAgent = = FoldTool, RM = never>( + definition: AgentDefinitionInput, +): AgentDefinition | RM> => { + const { tools, ...rest } = definition + return tools === undefined ? rest : { ...rest, tools: toolsNeedingAll(tools) } +} diff --git a/packages/fold-core/src/Api/EventLogDescriptor.ts b/packages/fold-core/src/Api/EventLogDescriptor.ts index 31a766f..cc0dedb 100644 --- a/packages/fold-core/src/Api/EventLogDescriptor.ts +++ b/packages/fold-core/src/Api/EventLogDescriptor.ts @@ -5,19 +5,24 @@ * SQLite/Durable Object backends) contribute an EventLog service implementation without any layer * appearing in a public signature. */ -import { Data, type Effect, type FileSystem, type Scope } from 'effect' +import { Data, Effect, type Scope } from 'effect' import type { EventLogService } from '../EventLog/EventLogService' -/** Where one session's durable event log lives. Built with {@link memoryEventLog} or {@link eventLogSource}. */ -export type FoldEventLog = - | { readonly _tag: 'memory' } - | { - readonly _tag: 'source' - readonly make: Effect.Effect - } +/** + * Where one session's durable event log lives. Built with {@link memoryEventLog} or {@link eventLogSource}. + * `R` is the host services the backend needs (a filesystem for JSONL); `startSession` requires them. + */ +export type FoldEventLog = Data.TaggedEnum<{ + memory: {} + source: { readonly make: Effect.Effect } +}> + +interface FoldEventLogDefinition extends Data.TaggedEnum.WithGenerics<1> { + readonly taggedEnum: FoldEventLog +} -const FoldEventLog = Data.taggedEnum() +const FoldEventLog = Data.taggedEnum() /** Keep the session log in memory: fast, isolated, and gone when the session scope closes. */ export const memoryEventLog = (): FoldEventLog => FoldEventLog.memory() @@ -27,6 +32,6 @@ export const memoryEventLog = (): FoldEventLog => FoldEventLog.memory() * the session scope; construction failures are treated as infrastructure defects. Resuming an existing * log is this seam too: an implementation that loads prior entries replays them into the session. */ -export const eventLogSource = ( - make: Effect.Effect, -): FoldEventLog => FoldEventLog.source({ make }) +export const eventLogSource = ( + make: Effect.Effect, +): FoldEventLog => FoldEventLog.source({ make: Effect.orDie(make) }) diff --git a/packages/fold-core/src/Api/ModelDescriptor.ts b/packages/fold-core/src/Api/ModelDescriptor.ts index 512c5ce..796c941 100644 --- a/packages/fold-core/src/Api/ModelDescriptor.ts +++ b/packages/fold-core/src/Api/ModelDescriptor.ts @@ -7,31 +7,29 @@ import { Data, Redacted } from 'effect' import type { Effect, Scope } from 'effect' import type { LanguageModel } from 'effect/unstable/ai' +import type { HttpClient } from 'effect/unstable/http' import type { ActiveModel, OpenAiReasoningSummary, ReasoningLevel } from '../EventLog/Schemas' import { resolveAnthropicThinking, resolveOpenAiReasoning } from '../Model/ModelRequestSettings' +import { anthropicLanguageModel, openAiCompatibleLanguageModel } from './ProviderModels' /** - * How the LanguageModel service for a model is obtained: a known provider connection (credentials and - * base URL as data), or a custom service-implementation Effect - the extension seam for scripted test - * models and future provider packages (layers are never accepted as domain-API arguments). + * Which provider a model talks to, as data: a known provider connection (credentials and base URL), or + * a custom implementation - the extension seam for scripted test models and provider packages. How the + * LanguageModel is built lives on the model's `make`. */ -export type FoldModelProvider = - | { - readonly _tag: 'openai-compatible' - readonly apiKey: Redacted.Redacted - readonly apiKeyHeader?: string | null - readonly baseUrl: string | null - } - | { - readonly _tag: 'anthropic' - readonly apiKey: Redacted.Redacted - readonly baseUrl: string | null - } - | { - readonly _tag: 'custom' - readonly make: Effect.Effect - } +export type FoldModelProvider = Data.TaggedEnum<{ + 'openai-compatible': { + readonly apiKey: Redacted.Redacted + readonly apiKeyHeader?: string | null + readonly baseUrl: string | null + } + anthropic: { + readonly apiKey: Redacted.Redacted + readonly baseUrl: string | null + } + custom: {} +}> const FoldModelProvider = Data.taggedEnum() @@ -40,13 +38,18 @@ const FoldModelProvider = Data.taggedEnum() * provider connection used to reach it. Built with {@link openaiModel}, {@link anthropicModel}, or * {@link customModel}; consumed by `startSession` and `FoldSession.switchModel`. */ -export type FoldModel = { +export type FoldModel = { readonly activeModel: ActiveModel readonly provider: FoldModelProvider + /** + * Builds the model's LanguageModel service in the runtime's scope. `R` is the host services it needs + * (an HttpClient for the built-in providers); `startSession` requires them from its caller. + */ + readonly make: Effect.Effect } 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' @@ -79,8 +82,11 @@ export type AnthropicModelOptions = ProviderModelOptionsBase & { } /** Describe a model served by any OpenAI-compatible endpoint. */ -export const openaiModel = (options: ProviderModelOptions): FoldModel => { +export const openaiModel = (options: ProviderModelOptions): FoldModel => { const level = options.reasoning ?? 'off' + const apiKey = redact(options.apiKey) + const apiKeyHeader = options.apiKeyHeader ?? null + const baseUrl = options.baseUrl ?? null return { activeModel: { @@ -92,18 +98,17 @@ export const openaiModel = (options: ProviderModelOptions): FoldModel => { reasoning: resolveOpenAiReasoning(level), reasoningSummary: options.reasoningSummary, }, - provider: FoldModelProvider['openai-compatible']({ - apiKey: redact(options.apiKey), - apiKeyHeader: options.apiKeyHeader ?? null, - baseUrl: options.baseUrl ?? null, - }), + provider: FoldModelProvider['openai-compatible']({ apiKey, apiKeyHeader, baseUrl }), + make: openAiCompatibleLanguageModel({ modelId: options.model, apiKey, apiKeyHeader, baseUrl }), } } /** Describe a model served by any Anthropic-compatible endpoint. */ -export const anthropicModel = (options: AnthropicModelOptions): FoldModel => { +export const anthropicModel = (options: AnthropicModelOptions): FoldModel => { const level = options.reasoning ?? 'off' const model = options.model ?? DEFAULT_ANTHROPIC_MODEL_ID + const apiKey = redact(options.apiKey) + const baseUrl = options.baseUrl ?? null return { activeModel: { @@ -114,20 +119,31 @@ export const anthropicModel = (options: AnthropicModelOptions): FoldModel => { requestedReasoningLevel: level, thinking: resolveAnthropicThinking(level, model), }, - provider: FoldModelProvider.anthropic({ apiKey: redact(options.apiKey), baseUrl: options.baseUrl ?? null }), + provider: FoldModelProvider.anthropic({ apiKey, baseUrl }), + make: anthropicLanguageModel({ modelId: model, apiKey, baseUrl }), } } /** Options for {@link customModel}. */ -export type CustomModelOptions = { +export type CustomModelOptions = { /** The resolved model snapshot recorded in the durable log. */ readonly activeModel: ActiveModel - /** Builds the LanguageModel service implementation - the escape hatch for tests and custom providers. */ - readonly make: Effect.Effect + /** + * Builds the LanguageModel service implementation - the escape hatch for tests and custom providers. + * Anything it needs from the host besides `Scope` becomes the model's `R`. + */ + readonly make: Effect.Effect } -/** Describe a model backed by a caller-supplied LanguageModel implementation. */ -export const customModel = (options: CustomModelOptions): FoldModel => ({ +/** + * Describe a model backed by a caller-supplied LanguageModel implementation. The model needs whatever + * `make` needs besides the `Scope` every runtime already supplies. + */ +export const customModel = (options: CustomModelOptions): FoldModel> => ({ activeModel: options.activeModel, - provider: FoldModelProvider.custom({ make: options.make }), + provider: FoldModelProvider.custom(), + // SAFETY: `Scope | R` and `Scope | Exclude` are the same set of services; TypeScript cannot + // see that for a generic R. + // oxlint-disable-next-line typescript/consistent-type-assertions, automation/no-type-assertion, effecttsgo/unsafe-effect-type-assertion + make: options.make as Effect.Effect>, }) diff --git a/packages/fold-core/src/Api/ProviderModels.ts b/packages/fold-core/src/Api/ProviderModels.ts new file mode 100644 index 0000000..5b95216 --- /dev/null +++ b/packages/fold-core/src/Api/ProviderModels.ts @@ -0,0 +1,95 @@ +/** + * This file builds the LanguageModel service for the two built-in provider connections - any + * OpenAI-compatible endpoint and any Anthropic-compatible endpoint - over the HttpClient the host + * provides. `openaiModel`/`anthropicModel` store the result as their descriptor's `make`. + */ +import { AnthropicClient, AnthropicLanguageModel } from '@humanlayer/effect-ai-anthropic' +import { OpenAiClient, OpenAiLanguageModel } from '@humanlayer/effect-ai-openai' +import { Context, Effect, Layer, type Redacted, type Scope, Stream } from 'effect' +import { LanguageModel } from 'effect/unstable/ai' +import { HttpClient } from 'effect/unstable/http' + +const anthropicDecoderModelFor = (modelId: string): string | null => { + const id = modelId.toLowerCase() + if (id.includes('opus')) return id === 'claude-opus-4-6' ? null : 'claude-opus-4-6' + if (id.includes('sonnet')) return id === 'claude-sonnet-4-6' ? null : 'claude-sonnet-4-6' + if (id.includes('haiku')) return id === 'claude-haiku-4-5' ? null : 'claude-haiku-4-5' + if (id.includes('fable') || id.includes('mythos')) return 'claude-opus-4-6' + return null +} + +/** + * The beta Anthropic SDK decodes streamed `message.model` against a generated literal union that can + * lag behind real model ids accepted by the API. Rewrite only that metadata field in the raw SSE bytes + * to a decoder-known sibling; the actual request still uses the configured model id. + */ +const relaxAnthropicResponseModel = (modelId: string): ((client: HttpClient.HttpClient) => HttpClient.HttpClient) => { + const decoderModel = anthropicDecoderModelFor(modelId) + if (decoderModel === null) return (client) => client + + const needle = `"model":"${modelId}"` + const replacement = `"model":"${decoderModel}"` + + return (client) => + HttpClient.transformResponse(client, (responseEffect) => + Effect.map(responseEffect, (response) => { + const stream = response.stream.pipe( + Stream.decodeText, + Stream.map((chunk) => chunk.replaceAll(needle, replacement)), + Stream.encodeText, + ) + + // This preserves the HttpClientResponse instance and only overrides the streaming body getter. + // The Anthropic generated client only needs `stream` for createMessageStream. + return new Proxy(response, { + get: (target, property, receiver) => + // oxlint-disable-next-line anti-slop/no-reflect-get -- a Proxy trap forwards every other property unchanged + property === 'stream' ? stream : Reflect.get(target, property, receiver), + }) + }), + ) +} + +/** The session-fixed services every provisioned runtime closes over (one instance each per session). */ +/** Build the LanguageModel for one layer in the caller's scope. */ +const languageModelFrom = ( + layer: Layer.Layer, +): Effect.Effect => + Layer.build(layer).pipe(Effect.map((context) => Context.get(context, LanguageModel.LanguageModel))) + +/** The LanguageModel for an OpenAI-compatible endpoint, over the host's HttpClient. */ +export const openAiCompatibleLanguageModel = (connection: { + readonly modelId: string + readonly apiKey: Redacted.Redacted + readonly apiKeyHeader: string | null + readonly baseUrl: string | null +}): Effect.Effect => { + const clientOptions = { + apiKey: connection.apiKey, + apiKeyHeader: connection.apiKeyHeader ?? undefined, + apiUrl: connection.baseUrl ?? undefined, + } + + return languageModelFrom( + OpenAiLanguageModel.layer({ model: connection.modelId }).pipe(Layer.provide(OpenAiClient.layer(clientOptions))), + ) +} + +/** The LanguageModel for an Anthropic-compatible endpoint, over the host's HttpClient. */ +export const anthropicLanguageModel = (connection: { + readonly modelId: string + readonly apiKey: Redacted.Redacted + readonly baseUrl: string | null +}): Effect.Effect => { + const clientOptions = { + apiKey: connection.apiKey, + apiUrl: connection.baseUrl ?? undefined, + transformClient: relaxAnthropicResponseModel(connection.modelId), + } + + return languageModelFrom( + AnthropicLanguageModel.layer({ model: connection.modelId }).pipe( + Layer.provide(AnthropicClient.layer(clientOptions)), + ), + ) +} diff --git a/packages/fold-core/src/Api/Provisioning.ts b/packages/fold-core/src/Api/Provisioning.ts index d9e277d..a5ad329 100644 --- a/packages/fold-core/src/Api/Provisioning.ts +++ b/packages/fold-core/src/Api/Provisioning.ts @@ -1,162 +1,66 @@ /** * This file owns agent-runtime provisioning - the one place a (model, tools, hooks) configuration - * becomes a fully wired AgentRuntime over a session's shared services. `startSession` provisions the - * root agent's runtime here (once at start, again on every model switch), and the Subagents service - * provisions each dispatched subagent's runtime here; both therefore share the same EventLog, Ids, - * AgentEvents spine, SystemPrompt, ModelRequestSettings, and ToolEventSink by construction, while each - * provision gets its own installed Toolset, family resolver, HookRunner, ToolRuntime, and provider - * LanguageModel layer. + * becomes a fully wired AgentRuntime. `startSession` provisions the root agent's runtime here (once at + * start, again on every model switch), and the subagent operations provision each subagent's runtime + * here; both run inside the session's shared services, so every runtime shares the same EventLog, Ids, + * AgentEvents spine, SystemPrompt, ModelRequestSettings, and ToolEventSink, while each provision gets its + * own installed Toolset, family resolver, HookRunner, ToolRuntime, and provider LanguageModel layer. * * Two invariants live here and nowhere else: * - Every provision builds with a fresh `Layer.makeMemoMap`. v4 memoizes module-level layers by * reference per memo map, so reusing a map would silently hand a new provision a previous * provision's Toolset/ToolRuntime/AgentRuntime (the SessionIsolation regression). - * - Every provision builds into the caller's ambient Scope. The facade provides the session scope for - * root-agent provisions; Subagents provisions inside the dispatch call's scope, so a subagent's - * provider HTTP client releases when its dispatch returns instead of leaking for the session's - * lifetime. + * - Every provision builds into the caller's ambient Scope. `startSession` provides the session scope + * for root-agent provisions; a subagent provisions inside its dispatch call's scope, so its provider + * HTTP client releases when its dispatch returns instead of leaking for the session's lifetime. */ -import { AnthropicClient, AnthropicLanguageModel } from '@humanlayer/effect-ai-anthropic' -import { OpenAiClient, OpenAiLanguageModel } from '@humanlayer/effect-ai-openai' -import { Context, Effect, Layer, Match, Stream } from 'effect' +import { Array as Arr, Context, Effect, Layer } from 'effect' import type { Scope } from 'effect' import { LanguageModel, Toolkit } from 'effect/unstable/ai' import type { Tool } from 'effect/unstable/ai' -import { FetchHttpClient, HttpClient } from 'effect/unstable/http' -import type { AgentEvents } from '../AgentEvents/AgentEventsService' import { liveAgentRuntimeLayer } from '../AgentRuntime/AgentRuntimeLayer' -import { AgentRuntime, type AgentRuntimeService } from '../AgentRuntime/AgentRuntimeService' -import type { EventLog } from '../EventLog/EventLogService' -import { makeHookRunner } from '../HookRunner/HookRunnerLayer' +import { AgentRuntime } from '../AgentRuntime/AgentRuntimeService' +import { compactionLayerFor } from '../Compaction/CompactionLayer' +import { layerHookRunner } from '../HookRunner/HookRunnerLayer' import type { HookConfig } from '../HookRunner/Types' -import type { Ids } from '../Ids' -import type { ModelRequestSettings } from '../Model/ModelRequestSettings' -import type { SessionControls } from '../Session/SessionControls' -import type { Subagents } from '../Subagents/SubagentsService' -import type { SystemPrompt } from '../SystemPrompt/SystemPromptService' -import type { ToolEventSink } from '../ToolRuntime/ToolContextServices' +import { SessionAgents } from '../Subagents/SessionAgents' import { liveToolRuntimeLayer } from '../ToolRuntime/ToolRuntimeLayer' import { toolsetLayerFromToolkit } from '../ToolRuntime/ToolsetFactory' -import { makeToolsetResolver } from '../ToolRuntime/ToolsetResolverLayer' +import { layerToolsetResolver } from '../ToolRuntime/ToolsetResolverLayer' import type { FoldModel } from './ModelDescriptor' import type { RealizedFoldTool, FoldTool } from './ToolDefinition' -type Mutable = { -readonly [Key in keyof T]: T[Key] } - -const anthropicDecoderModelFor = (modelId: string): string | null => { - const id = modelId.toLowerCase() - if (id.includes('opus')) return id === 'claude-opus-4-6' ? null : 'claude-opus-4-6' - if (id.includes('sonnet')) return id === 'claude-sonnet-4-6' ? null : 'claude-sonnet-4-6' - if (id.includes('haiku')) return id === 'claude-haiku-4-5' ? null : 'claude-haiku-4-5' - if (id.includes('fable') || id.includes('mythos')) return 'claude-opus-4-6' - return null -} - -/** - * The beta Anthropic SDK decodes streamed `message.model` against a generated literal union that can - * lag behind real model ids accepted by the API. Rewrite only that metadata field in the raw SSE bytes - * to a decoder-known sibling; the actual request still uses the configured model id. - */ -const relaxAnthropicResponseModel = (modelId: string): ((client: HttpClient.HttpClient) => HttpClient.HttpClient) => { - const decoderModel = anthropicDecoderModelFor(modelId) - if (decoderModel === null) return (client) => client - - const needle = `"model":"${modelId}"` - const replacement = `"model":"${decoderModel}"` - - return (client) => - HttpClient.transformResponse(client, (responseEffect) => - Effect.map(responseEffect, (response) => { - const stream = response.stream.pipe( - Stream.decodeText, - Stream.map((chunk) => chunk.replaceAll(needle, replacement)), - Stream.encodeText, - ) - - // SAFETY: this preserves the HttpClientResponse instance and only overrides the streaming body - // getter. The Anthropic generated client only needs `stream` for createMessageStream. - // oxlint-disable-next-line typescript/consistent-type-assertions - return new Proxy(response, { - get: (target, property, receiver) => - property === 'stream' ? stream : Reflect.get(target, property, receiver), - }) - }), - ) -} - -/** The session-fixed services every provisioned runtime closes over (one instance each per session). */ -export type SessionProvisioningServices = - | EventLog - | Ids - | AgentEvents - | SystemPrompt - | ModelRequestSettings - | ToolEventSink - | Subagents - | SessionControls - -/** Lower a model descriptor to the LanguageModel layer for its provider connection. */ -export const languageModelLayerFor = (model: FoldModel): Layer.Layer => { - const provider = model.provider - - return Match.valueTags(provider, { - 'openai-compatible': (connection) => { - const clientOptions: Mutable[0]> = { apiKey: connection.apiKey } - if (connection.apiKeyHeader !== null) { - clientOptions.apiKeyHeader = connection.apiKeyHeader - } - if (connection.baseUrl !== null) { - clientOptions.apiUrl = connection.baseUrl - } - const clientLayer = OpenAiClient.layer(clientOptions).pipe(Layer.provide(FetchHttpClient.layer)) - - return OpenAiLanguageModel.layer({ model: model.activeModel.modelId }).pipe(Layer.provide(clientLayer)) - }, - anthropic: (connection) => { - const clientOptions: Mutable[0]> = { - apiKey: connection.apiKey, - transformClient: relaxAnthropicResponseModel(model.activeModel.modelId), - } - if (connection.baseUrl !== null) { - clientOptions.apiUrl = connection.baseUrl - } - const clientLayer = AnthropicClient.layer(clientOptions).pipe(Layer.provide(FetchHttpClient.layer)) - - return AnthropicLanguageModel.layer({ model: model.activeModel.modelId }).pipe(Layer.provide(clientLayer)) - }, - custom: (connection) => Layer.effect(LanguageModel.LanguageModel, connection.make), - }) -} +/** The LanguageModel layer for a model: its `make`, run in the layer's scope. */ +export const languageModelLayerFor = ( + model: FoldModel, +): Layer.Layer> => + Layer.effect(LanguageModel.LanguageModel, model.make) /** Assemble realized tool descriptors into the installed Toolset layer for one provisioned runtime. */ export const toolsetLayerFor = (tools: ReadonlyArray) => { const toolkit = Toolkit.make(...tools.map((foldTool) => foldTool.tool)) - // SAFETY: the dispatch table is keyed by tool name over erased handlers; each handler is only ever - // invoked with params decoded by its own tool's parameters schema (see defineTool). This mirrors - // the sanctioned dynamic-dispatch assertion in ToolsetFactory. - // oxlint-disable-next-line typescript/consistent-type-assertions - const handlers = Object.fromEntries( + const handlers: Toolkit.HandlersFrom> = Object.fromEntries( tools.map((foldTool) => [foldTool.name, foldTool.handler]), - ) as Toolkit.HandlersFrom> + ) return toolsetLayerFromToolkit(toolkit).pipe(Layer.provide(toolkit.toLayer(handlers))) } /** Fail fast (as a defect) when two tool descriptors claim the same name. */ -export const validateToolNames = (tools: ReadonlyArray): Effect.Effect => { +export const validateToolNames = (tools: ReadonlyArray>): Effect.Effect => { const duplicates = [ ...new Set(tools.map((tool) => tool.name).filter((name, index, names) => names.indexOf(name) !== index)), ] - return duplicates.length === 0 + return Arr.isArrayEmpty(duplicates) ? Effect.void : Effect.die(new Error(`duplicate tool names: ${duplicates.join(', ')}`)) } /** One agent's runtime configuration: which provider to talk to, with which tools and hooks. */ export type ProvisionAgentRuntimeInput = { - readonly model: FoldModel + readonly model: FoldModel /** * The tools installed for this agent, already realized (session-initialized values resolved to * their contributions); the family resolver picks the advertised subset per turn. @@ -166,51 +70,47 @@ export type ProvisionAgentRuntimeInput = { readonly hooks: HookConfig } -/** Builds a fully-wired AgentRuntime for one agent over the shared session services. */ -export type AgentProvisionerService = { - /** - * Provision one agent runtime into the ambient Scope: installed Toolset + family resolver + this - * agent's HookRunner + ToolRuntime + the model's provider LanguageModel layer, built with a fresh - * memo map over the session-fixed services. - */ - readonly provisionAgentRuntime: ( - input: ProvisionAgentRuntimeInput, - ) => Effect.Effect -} - -/** AgentProvisioner service tag (the interim D15 AgentModels seam, shared by facade and Subagents). */ -export class AgentProvisioner extends Context.Service()( - 'fold/AgentProvisioner', -) {} - /** - * Build the provisioner over one session's shared services. The facade constructs this once per - * session, right after building `sessionServicesLayer`, and hands it to the Subagents service. + * Provision one agent runtime into the ambient Scope: installed Toolset + family resolver + this + * agent's HookRunner + ToolRuntime + the model's provider LanguageModel layer, built with a fresh memo + * map. Everything else - the session's shared services and the host's - comes from the surroundings, so + * this runs wherever those are present: `startSession` for the root agent, and the subagent operations, + * which run inside a tool call. */ -export const makeAgentProvisioner = ( - sessionServicesLayer: Layer.Layer, -): AgentProvisionerService => ({ - provisionAgentRuntime: (input: ProvisionAgentRuntimeInput) => - Effect.gen(function* () { - const scope = yield* Effect.scope - const memoMap = yield* Layer.makeMemoMap - const toolsetLayer = toolsetLayerFor(input.tools) - const epochServicesLayer = Layer.mergeAll( - toolsetLayer, - makeToolsetResolver().pipe(Layer.provide(toolsetLayer)), - makeHookRunner(input.hooks).pipe(Layer.provide(sessionServicesLayer)), - ) - const toolRuntimeLayer = liveToolRuntimeLayer.pipe( - Layer.provideMerge(Layer.mergeAll(sessionServicesLayer, epochServicesLayer)), - ) - const context = yield* Layer.buildWithMemoMap( - liveAgentRuntimeLayer.pipe( - Layer.provide(Layer.mergeAll(toolRuntimeLayer, languageModelLayerFor(input.model))), +export const provisionAgentRuntime = Effect.fnUntraced(function* (input: ProvisionAgentRuntimeInput) { + const { autoCompact } = yield* SessionAgents + const scope = yield* Effect.scope + const memoMap = yield* Layer.makeMemoMap + // Tool handlers run with the context their toolkit was built in (Effect AI merges it under each call's + // per-call services), so building here gives every handler the session's and the host's services. + const toolsetLayer = toolsetLayerFor(input.tools) + // SAFETY: every model reaching a session came through startSession, resumeSession, switchModel, or + // setProfile, whose types require the model's services to be among the session's host services - and + // the host services are part of the surroundings this runs in. + // oxlint-disable-next-line typescript/consistent-type-assertions, automation/no-type-assertion + const model = input.model as FoldModel + const languageModelLayer = languageModelLayerFor(model) + const epochServicesLayer = Layer.mergeAll( + toolsetLayer, + layerToolsetResolver().pipe(Layer.provide(toolsetLayer)), + layerHookRunner(input.hooks), + ) + // Compaction summarizes with this runtime's own model and resolves limits through the session + // ModelCatalog; both are captured when the layer is built. + const compactionLayer = compactionLayerFor(autoCompact).pipe(Layer.provide(languageModelLayer)) + const context = yield* Layer.buildWithMemoMap( + liveAgentRuntimeLayer.pipe( + Layer.provide( + Layer.mergeAll( + liveToolRuntimeLayer.pipe(Layer.provideMerge(epochServicesLayer)), + languageModelLayer, + compactionLayer, ), - memoMap, - scope, - ) + ), + ), + memoMap, + scope, + ) - return Context.get(context, AgentRuntime) - }), + return Context.get(context, AgentRuntime) }) diff --git a/packages/fold-core/src/Api/StartSession.ts b/packages/fold-core/src/Api/StartSession.ts index 0ff0958..6f3f387 100644 --- a/packages/fold-core/src/Api/StartSession.ts +++ b/packages/fold-core/src/Api/StartSession.ts @@ -2,7 +2,7 @@ * This file implements `startSession` and `resumeSession` - the ergonomic composition roots of the * public API. Callers describe an agent (model, prompt, tools, hooks) and optionally an event log * backend; this file lowers those descriptors into the internal service graph (EventLog, Ids, - * AgentEvents, SystemPrompt, ModelRequestSettings, SessionControls, the Subagents engine, and + * AgentEvents, SystemPrompt, ModelRequestSettings, SessionControls, SessionAgents, and * per-provision Toolset + resolver + HookRunner + ToolRuntime + AgentRuntime, plus the Session facade) * and returns a running session handle. Per the composition-root ruling, this is the only place * descriptors become layers; no public signature accepts or returns one. @@ -28,24 +28,23 @@ * send. */ import { + Array as Arr, Predicate, Cause, Context, Effect, Exit, Fiber, - FileSystem, Layer, Match, - Path, Ref, Schema, Scope, Semaphore, Stream, + Struct, } from 'effect' import { Prompt } from 'effect/unstable/ai' -import { ChildProcessSpawner } from 'effect/unstable/process' import { toolEventSinkLayerFromAgentEvents, liveAgentEventsLayer } from '../AgentEvents/AgentEventsLayer' import type { FoldEvent } from '../AgentEvents/AgentEventsService' @@ -55,11 +54,10 @@ import { noopCompactionArchiveAccess, type CompactionArchiveAccessService, } from '../Compaction/CompactionArchiveAccess' -import { compactionServiceFor } from '../Compaction/CompactionLayer' -import { Compaction } from '../Compaction/CompactionService' import { layerInMemoryEventLogWithIds } from '../EventLog/EventLogLayerMemory' import { EventLog, type EventLogService } from '../EventLog/EventLogService' import { + ActiveModel, LogEntryInputs, type AgentFinishedLogEntry, type AssistantMessageLogEntry, @@ -74,7 +72,8 @@ import { liveModelRequestSettingsLayer } from '../Model/ModelRequestSettings' import { runtimeForAgent } from '../Projection/Projection' import { AgentNotRunningError } from '../Session/Errors' import { - makeProfiles, + isProfileRole, + layerProfiles, profileModelFor, Profiles, type ProfileRole, @@ -82,13 +81,13 @@ import { type SessionProfiles, } from '../Session/Profiles' import { - makeSessionControls, + layerSessionControls, SessionControls, type SessionControlsService, type SteeringMode, } from '../Session/SessionControls' import { liveSessionLayer } from '../Session/SessionLayer' -import { Session, type SessionService, type StartSessionInput, type StartedSession } from '../Session/SessionService' +import { Session, type SessionService, type StartedSession } from '../Session/SessionService' import type { SkillSourceService } from '../Skills/SkillSource' import { StopConditions } from '../StopConditions/StopConditions' import { agentIdsFromEntries, resolveAgentIdRef } from '../Subagents/AgentIdRef' @@ -97,27 +96,29 @@ import { collectAgentDefinitions, type CollectedAgentDefinitions, } from '../Subagents/AgentRegistry' -import { SubagentNotFoundError } from '../Subagents/Errors' -import { makeSubagents, type RealizedAgentTools, type RootAgentSnapshot } from '../Subagents/SubagentsLayer' -import { Subagents, type SubagentsService } from '../Subagents/SubagentsService' -import { makeSystemPrompt } from '../SystemPrompt/SystemPromptLayer' +import { type SubagentBusyError, SubagentNotFoundError } from '../Subagents/Errors' +import { SessionAgents, type RealizedAgentTools, type RootAgentSnapshot } from '../Subagents/SessionAgents' +import { continueSubagent, type ContinueSubagentInput } from '../Subagents/SubagentEngine' +import { layerSystemPrompt } from '../SystemPrompt/SystemPromptLayer' import { SystemPrompt, type SystemPromptService } from '../SystemPrompt/SystemPromptService' -import type { AgentDefinition } from './AgentDefinition' +import { systemPromptBlocks, type AgentDefinition, type SystemPromptInput } from './AgentDefinition' import { memoryEventLog, type FoldEventLog } from './EventLogDescriptor' import type { FoldModel } from './ModelDescriptor' -import { AgentProvisioner, makeAgentProvisioner, validateToolNames } from './Provisioning' -import type { PlatformServices, RealizedFoldTool, SessionToolContribution, FoldTool } from './ToolDefinition' +import { provisionAgentRuntime, validateToolNames } from './Provisioning' +import type { RealizedFoldTool, SessionToolContribution, FoldTool } from './ToolDefinition' -type Mutable = { -readonly [Key in keyof T]: T[Key] } - -/** Options for {@link startSession}. */ -export type StartSessionOptions = { - readonly agent: AgentDefinition +/** + * Options for {@link startSession}. `RA` is the host services the agent (its model and tools) needs, `RL` + * the ones the log backend needs, and `RP` the ones the profile models need; the session requires all + * of them from its caller. + */ +export type StartSessionOptions = { + readonly agent: AgentDefinition /** Event log backend for the session. Defaults to in-memory. */ - readonly log?: FoldEventLog + readonly log?: FoldEventLog /** Host working directory recorded on `session_started`; omit on hosts without a filesystem. */ readonly cwd?: string - readonly meta?: Readonly> + readonly meta?: Readonly> /** * Pre-minted session id, for hosts that name the log location by session id (D5 layout). Defaults * to a freshly minted id. @@ -130,7 +131,7 @@ export type StartSessionOptions = { * role the roster names (`orchestrator` falls back to `smart`, D25); optional when every subagent * binds a concrete model. Rebind mid-session with {@link FoldSession.setProfile}. */ - readonly profiles?: SessionProfiles + readonly profiles?: SessionProfiles /** * Model catalog entries installed session-wide (D15): compaction resolves context windows through * them, and future consumers (cost projection, pickers) share the same data. Omitted means the @@ -142,10 +143,10 @@ export type StartSessionOptions = { } /** Options for {@link resumeSession}: the same agent configuration, over an existing log. */ -export type ResumeSessionOptions = { - readonly agent: AgentDefinition +export type ResumeSessionOptions = { + readonly agent: AgentDefinition /** The existing event log to adopt; the session continues exactly where the log left off. */ - readonly log: FoldEventLog + readonly log: FoldEventLog /** How queued steering messages drain at a turn boundary (D8). Defaults to one-at-a-time. */ readonly steering?: SteeringMode /** @@ -153,7 +154,7 @@ export type ResumeSessionOptions = { * role the roster names (`orchestrator` falls back to `smart`, D25); optional when every subagent * binds a concrete model. Rebind mid-session with {@link FoldSession.setProfile}. */ - readonly profiles?: SessionProfiles + readonly profiles?: SessionProfiles /** * Model catalog entries installed session-wide (D15): compaction resolves context windows through * them, and future consumers (cost projection, pickers) share the same data. Omitted means the @@ -165,18 +166,18 @@ export type ResumeSessionOptions = { } /** Options for {@link FoldSession.switchModel}. Omitted fields keep the session's current configuration. */ -export type SwitchModelOptions = { +export type SwitchModelOptions = { readonly reason?: string /** Replace the agent's own leading prompt blocks from this epoch on. */ - readonly systemPrompt?: string | ReadonlyArray - /** Replace the installed tools from this epoch on. */ - readonly tools?: ReadonlyArray + readonly systemPrompt?: SystemPromptInput + /** Replace the installed tools from this epoch on. They may need only services the session already has. */ + readonly tools?: ReadonlyArray> /** * Atomically replace the complete role-to-model map in the same commit as this root epoch switch. * Candidate subagent types are validated against this map; it is published only after the durable * transition succeeds. Omitted means preserve the current bindings. */ - readonly profiles?: SessionProfiles + readonly profiles?: SessionProfiles } /** @@ -201,9 +202,10 @@ export type InjectedSkillEntries = { /** * A running fold session: one durable log, one root agent, already started (or adopted). Every method * is safe to call without further wiring; root runs and `switchModel` are serialized against each - * other so a switch cannot interleave with an in-flight root run. + * other so a switch cannot interleave with an in-flight root run. `R` is the host services the session + * was started with; tools installed by a later switch may use them. */ -export type FoldSession = { +export type FoldSession = { readonly sessionId: SessionId readonly rootAgentId: AgentId /** @@ -251,7 +253,7 @@ export type FoldSession = { * `thinking-change` when the reasoning level changed - and provisions the new configuration for every * subsequent send. The same log continues across the switch. */ - readonly switchModel: (model: FoldModel, options?: SwitchModelOptions) => Effect.Effect + readonly switchModel: (model: FoldModel, options?: SwitchModelOptions) => Effect.Effect /** Force a root-agent compaction now. Returns null when there is nothing safe to summarize. */ readonly compact: (options?: CompactOptions) => Effect.Effect /** @@ -263,7 +265,7 @@ export type FoldSession = { * `model-change` transition. The ROOT agent's model is never profile-bound; switch it with * {@link switchModel}. */ - readonly setProfile: (role: ProfileRole, model: FoldModel) => Effect.Effect + readonly setProfile: (role: ProfileRole, model: FoldModel) => Effect.Effect /** Merged stream of durable log rows and ephemeral streaming deltas. */ readonly events: (fromSeq?: LogSeq) => Stream.Stream /** Snapshot of all durable log entries appended so far. */ @@ -276,104 +278,92 @@ export type FoldSession = { } /** The switchable slice of a session's configuration, tracked so omitted switch options carry forward. */ -type SessionAgentConfig = { - readonly model: FoldModel +type SessionAgentConfig = { + readonly model: FoldModel readonly promptCacheKey: string | null - readonly systemPrompt: string | ReadonlyArray | null - readonly tools: ReadonlyArray + /** The agent's own leading blocks, normalized from its descriptor. */ + readonly systemPrompt: ReadonlyArray + readonly tools: ReadonlyArray> } +/** Lower the event log descriptor to its EventLog layer; the backend's own needs stay in the layer's `R`. */ +const eventLogLayerFor = (log: FoldEventLog): Layer.Layer => + Match.valueTags(log, { + memory: () => layerInMemoryEventLogWithIds, + source: ({ make }) => Layer.effect(EventLog, make), + }) + /** - * Pass along whichever platform services the caller provided, the way Effect AI's Toolkit passes its - * build context to handlers. A session never requires them; descriptors that declare them (disk tools, - * a JSONL log, a disk skill source) read them from here, and die with a missing-service defect when the - * caller left one out. - */ -const providedPlatformServices: Effect.Effect> = Effect.map( - Effect.context(), - (caller) => { - const platform = Context.pick(FileSystem.FileSystem, Path.Path, ChildProcessSpawner.ChildProcessSpawner)(caller) - // SAFETY: `pick` keeps only the keys present, so this may hold some of the platform services. That - // is the point: a descriptor that declares one the caller did not provide fails when it asks for it. - // oxlint-disable-next-line typescript/consistent-type-assertions - return platform as Context.Context - }, -) - -/** Lower the event log descriptor to its EventLog layer. */ -/** - * Run the tool's handler with the session's platform services over whatever is in scope where the turn - * runs. Effect AI merges the turn's services over the toolkit's, so a host whose request handling carries - * its own FileSystem (Alchemy's Worker runtime provides Node's) would otherwise shadow the session's. + * Run a tool's handler with the session's host services over whatever is in scope where the turn runs. + * Effect AI merges the turn's services over the toolkit's, so a host whose request handling carries its + * own FileSystem (Alchemy's Worker runtime provides Node's) would otherwise shadow the session's. */ -const withPlatformServices = ( +const withHostServices = ( contribution: SessionToolContribution, - platformServices: Context.Context, + hostServices: Context.Context, ): SessionToolContribution => ({ ...contribution, handler: (params) => - contribution.handler(params).pipe(Effect.updateContext((context) => Context.merge(context, platformServices))), + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- the stored handler is erased by design + contribution.handler(params).pipe(Effect.updateContext((context) => Context.merge(context, hostServices))), }) -const eventLogLayerFor = ( - log: FoldEventLog, - platformServices: Context.Context, -): Layer.Layer => - Match.valueTags(log, { - memory: () => layerInMemoryEventLogWithIds, - source: ({ make }) => Layer.effect(EventLog, make.pipe(Effect.provideContext(platformServices))), - }) - -/** 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 +const activeModelsEquivalent = Schema.toEquivalence(Schema.NullOr(ActiveModel)) +const promptBlocksEquivalent = Schema.toEquivalence(Schema.Array(Schema.String)) /** Everything one assembled session shares between `startSession` and `resumeSession`. */ -type SessionGraph = { - readonly agent: AgentDefinition +type SessionGraph = { + readonly agent: AgentDefinition readonly session: SessionService readonly eventLog: EventLogService readonly ids: IdsService readonly controls: SessionControlsService readonly systemPromptService: SystemPromptService - readonly subagentsEngine: SubagentsService + readonly continueSubagent: ( + input: ContinueSubagentInput, + ) => Effect.Effect readonly profiles: ProfilesService - readonly configRef: Ref.Ref + readonly configRef: Ref.Ref> readonly validateSubagentRegistry: ( - definitions: CollectedAgentDefinitions, - profiles: SessionProfiles, + definitions: CollectedAgentDefinitions, + profiles: SessionProfiles, ) => Effect.Effect - readonly extendSubagentRegistry: (definitions: CollectedAgentDefinitions) => void - readonly ensureToolContributions: (tools: ReadonlyArray) => Effect.Effect - readonly collectNewSubagentDefinitions: (tools: ReadonlyArray) => Effect.Effect + readonly extendSubagentRegistry: (definitions: CollectedAgentDefinitions) => void + readonly ensureToolContributions: (tools: ReadonlyArray>) => Effect.Effect + readonly collectNewSubagentDefinitions: ( + tools: ReadonlyArray>, + ) => Effect.Effect> readonly provisionRootRuntime: ( - model: FoldModel, - tools: ReadonlyArray, + model: FoldModel, + tools: ReadonlyArray>, ) => Effect.Effect readonly setProvisionedRuntime: (runtime: AgentRuntimeService) => Effect.Effect readonly currentProvisionedRuntime: Effect.Effect readonly leadingPromptFor: ( - systemPrompt: string | ReadonlyArray | null, - tools: ReadonlyArray, + systemPrompt: ReadonlyArray, + tools: ReadonlyArray>, ) => ReadonlyArray | null } /** * Assemble one session's whole service graph - registry, tool contributions, shared services, - * provisioner, Subagents engine, controls, and the delegating root runtime - without writing anything + * controls, and the delegating root runtime - without writing anything * durable. `startSession` follows with `session.start`; `resumeSession` follows with adoption. */ -const assembleSessionGraph = (options: { - readonly agent: AgentDefinition - readonly log?: FoldEventLog +const assembleSessionGraph = (options: { + readonly agent: AgentDefinition + readonly log?: FoldEventLog readonly steering?: SteeringMode - readonly profiles?: SessionProfiles + readonly profiles?: SessionProfiles readonly catalog?: ReadonlyArray readonly compactionArchiveAccess?: CompactionArchiveAccessService -}): Effect.Effect => +}): Effect.Effect, never, Scope.Scope | R> => Effect.gen(function* () { const agent = options.agent - const platformServices = yield* providedPlatformServices + // The host services every tool and the log backend need, taken once from the caller - the way Effect + // AI's Toolkit hands its build context to handlers. Tools run later on the session's own fibers + // (including tools installed by a later switch), so the session carries these for them. + const hostServices = yield* Effect.context() const rootTools = agent.tools ?? [] const rootHooks = agent.hooks ?? {} @@ -389,7 +379,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}` @@ -415,15 +405,19 @@ const assembleSessionGraph = (options: { // constant; for the skill tool it is the roster scan): the contribution - realized tool, // leading-prompt block, skill source - is reused by every agent listing that value, across // epochs, and by every subagent dispatch (D20's one-snapshot law). - const toolContributions = new Map() - const ensureToolContributions = (tools: ReadonlyArray): Effect.Effect => + // Tool inits may acquire services (a tool's own layer); they live as long as the session, including + // tools first introduced by a later agent switch. + const sessionScope = yield* Effect.scope + const toolContributions = new Map, SessionToolContribution>() + const ensureToolContributions = (tools: ReadonlyArray>): Effect.Effect => Effect.forEach( tools.filter((tool) => !toolContributions.has(tool)), (tool) => tool.init.pipe( - Effect.provideContext(platformServices), + Effect.provideContext(hostServices), + Scope.provide(sessionScope), Effect.map((contribution) => - toolContributions.set(tool, withPlatformServices(contribution, platformServices)), + toolContributions.set(tool, withHostServices(contribution, hostServices)), ), ), { discard: true }, @@ -438,7 +432,7 @@ const assembleSessionGraph = (options: { }) /** Realize one agent's configured tools against the session-start contributions (§2.5). */ - const realizeAgentTools = (tools: ReadonlyArray): RealizedAgentTools => { + const realizeAgentTools = (tools: ReadonlyArray>): RealizedAgentTools => { const realized: Array = [] const promptBlocks: Array = [] let skillSource: SkillSourceService | null = null @@ -462,78 +456,70 @@ const assembleSessionGraph = (options: { /** One agent's leading blocks: its own, then its tools' contributed blocks (skills block, D20). */ const leadingPromptFor = ( - systemPrompt: string | ReadonlyArray | null, - tools: ReadonlyArray, + systemPrompt: ReadonlyArray, + tools: ReadonlyArray>, ): ReadonlyArray | null => { - const blocks = [...promptBlocksOf(systemPrompt), ...realizeAgentTools(tools).promptBlocks] - return blocks.length === 0 ? null : blocks + const blocks = [...systemPrompt, ...realizeAgentTools(tools).promptBlocks] + return Arr.isArrayEmpty(blocks) ? null : blocks } - const initialConfig: SessionAgentConfig = { + const initialConfig: SessionAgentConfig = { model: agent.model, promptCacheKey: agent.promptCacheKey ?? null, - systemPrompt: agent.systemPrompt ?? null, + systemPrompt: systemPromptBlocks(agent.systemPrompt), tools: rootTools, } - // The Subagents engine is constructed after the provisioner (it provisions per dispatch), but the - // provisioner's runtimes need the Subagents service in their graph (tool handlers yield it as an - // ambient per-call service). A delegating value breaks the construction cycle: it is installed in - // the session services now and bound to the real engine right after construction below. - const subagentsHolder: { current: SubagentsService | null } = { current: null } - const requireSubagentsEngine: Effect.Effect = Effect.suspend(() => - subagentsHolder.current === null - ? Effect.die(new Error('Subagents engine consumed before session construction completed')) - : Effect.succeed(subagentsHolder.current), + const configRef = yield* Ref.make(initialConfig) + const currentRootAgent: Effect.Effect = Ref.get(configRef).pipe( + Effect.map((config) => ({ + model: config.model, + promptCacheKey: config.promptCacheKey, + tools: config.tools, + hooks: rootHooks, + systemPrompt: config.systemPrompt, + })), ) - const delegatingSubagents: SubagentsService = { - dispatch: (input) => requireSubagentsEngine.pipe(Effect.flatMap((engine) => engine.dispatch(input))), - fork: (input) => requireSubagentsEngine.pipe(Effect.flatMap((engine) => engine.fork(input))), - resume: (input) => requireSubagentsEngine.pipe(Effect.flatMap((engine) => engine.resume(input))), - continueSubagent: (input) => - requireSubagentsEngine.pipe(Effect.flatMap((engine) => engine.continueSubagent(input))), - } - // One shared service graph per session; every provisioned runtime closes over these same - // instances (one EventLog, one Ids source, one AgentEvents PubSub, one SessionControls, one - // Subagents engine). HookRunner is deliberately NOT session-fixed: each provisioned runtime + // One shared service graph per session; every provisioned runtime runs inside these same + // instances (one EventLog, one Ids source, one AgentEvents PubSub, one SessionControls). HookRunner is deliberately NOT session-fixed: each provisioned runtime // carries its own agent's hook chains (D16/D21). Tool handlers get their declared platform // services from this graph too: Effect AI hands each handler the context its toolkit was built in. const idsLayer = layerLiveIdFactory const infraLayer = Layer.mergeAll( - eventLogLayerFor(options.log ?? memoryEventLog(), platformServices).pipe(Layer.provide(idsLayer)), + eventLogLayerFor(options.log ?? memoryEventLog()).pipe(Layer.provide(idsLayer)), idsLayer, liveAgentEventsLayer, ) const servicesLayer = Layer.mergeAll( infraLayer, - Layer.succeedContext(platformServices), - makeSystemPrompt(agent.basePrompts === undefined ? {} : { basePrompts: agent.basePrompts }), + Layer.succeedContext(hostServices), + layerSystemPrompt(agent.basePrompts === undefined ? {} : { basePrompts: agent.basePrompts }), liveModelRequestSettingsLayer, toolEventSinkLayerFromAgentEvents.pipe(Layer.provide(infraLayer)), - Layer.succeed(Subagents, delegatingSubagents), - // Session-wide auto-compaction policy (D11): the live service when the agent enabled it, the - // no-op default otherwise. Every provisioned runtime - root and subagent - shares this one - // policy while checking against its own projection and summarizing with its own model. - Layer.succeed(Compaction, compactionServiceFor(agent.autoCompact)), + // How this session builds agents: the subagent operations and every provision read it. + Layer.succeed(SessionAgents, { + registry, + realizeTools: realizeAgentTools, + currentRoot: currentRootAgent, + // Session-wide auto-compaction policy (D11): every provisioned runtime - root and subagent - + // shares it while checking its own projection and summarizing with its own model. + autoCompact: agent.autoCompact, + }), Layer.succeed(CompactionArchiveAccess, options.compactionArchiveAccess ?? noopCompactionArchiveAccess), // Session-wide model catalog (D15): compaction resolves context windows through it. Omitted // entries build the empty catalog, which behaves exactly like the Reference default. Layer.succeed(ModelCatalog, modelCatalogFromEntries(options.catalog ?? [])), Layer.succeed(StopConditions, agent.stopConditions ?? {}), - Layer.effect( - SessionControls, - makeSessionControls(options.steering === undefined ? {} : { steeringMode: options.steering }), - ), + layerSessionControls(options.steering === undefined ? {} : { steeringMode: options.steering }), // Session-wide role->model bindings (profiles slice): one mutable map shared by the facade's - // setProfile and the Subagents engine's per-dispatch/resume resolution. - Layer.effect(Profiles, makeProfiles(initialProfiles)), + // setProfile and the subagent operations' per-dispatch/resume resolution. + layerProfiles(initialProfiles), ) // Builds use session-fresh memo maps, never the ambient CurrentMemoMap: layers are memoized by // reference per memo map, and under `Effect.provide` (any app or test harness) the ambient map // would share module-level layers - the event log, the event spine - across sessions, and hand // every model switch the previous epoch's memoized runtime. - const sessionScope = yield* Effect.scope const sessionMemoMap = yield* Layer.makeMemoMap const sessionServices = yield* Layer.buildWithMemoMap(servicesLayer, sessionMemoMap, sessionScope).pipe( Effect.orDie, @@ -542,34 +528,20 @@ const assembleSessionGraph = (options: { // Provision one runtime slice per epoch: the installed Toolset, its family resolver, the root's // HookRunner, the ToolRuntime executing against it, and the AgentRuntime bound to the model's - // provider (Provisioning.ts owns the fresh-memo-map and ambient-scope invariants). The - // delegating runtime below lets the Session service survive swaps (interim AgentModels seam - - // D15). Root provisions target the session scope explicitly: switchModel runs later, from the - // caller's own scope, and the provisioned provider client must outlive that caller. - const provisioner = makeAgentProvisioner(sessionServicesLayer) + // provider (Provisioning.ts owns the fresh-memo-map and ambient-scope invariants). The delegating + // runtime below lets the Session service survive swaps (interim AgentModels seam - D15). Root + // provisions target the session scope explicitly: switchModel runs later, from the caller's own + // scope, and the provisioned provider client must outlive that caller. The session handle is the + // edge where host code calls in with nothing around it, so it runs these inside the session's + // services. const provisionRootRuntime = ( - model: FoldModel, - tools: ReadonlyArray, + model: FoldModel, + tools: ReadonlyArray>, ): Effect.Effect => - provisioner - .provisionAgentRuntime({ model, tools: realizeAgentTools(tools).tools, hooks: rootHooks }) - .pipe(Scope.provide(sessionScope)) - - const configRef = yield* Ref.make(initialConfig) - const currentRootAgent: Effect.Effect = Ref.get(configRef).pipe( - Effect.map((config) => ({ - model: config.model, - promptCacheKey: config.promptCacheKey, - tools: config.tools, - hooks: rootHooks, - systemPrompt: config.systemPrompt, - })), - ) - - const subagentsEngine = yield* makeSubagents({ registry, realizeAgentTools, currentRootAgent }).pipe( - Effect.provide(Layer.mergeAll(sessionServicesLayer, Layer.succeed(AgentProvisioner, provisioner))), - ) - subagentsHolder.current = subagentsEngine + provisionAgentRuntime({ model, tools: realizeAgentTools(tools).tools, hooks: rootHooks }).pipe( + Scope.provide(sessionScope), + Effect.provideContext(sessionServices), + ) const runtimeRef = yield* Ref.make(yield* provisionRootRuntime(agent.model, rootTools)) const delegatingRuntime: AgentRuntimeService = { @@ -594,7 +566,7 @@ const assembleSessionGraph = (options: { ids: Context.get(sessionServices, Ids), controls: Context.get(sessionServices, SessionControls), systemPromptService: Context.get(sessionServices, SystemPrompt), - subagentsEngine, + continueSubagent: (input) => continueSubagent(input).pipe(Effect.provideContext(sessionServices)), profiles: Context.get(sessionServices, Profiles), configRef, validateSubagentRegistry: (definitions, candidateProfiles) => @@ -606,7 +578,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`, @@ -626,8 +598,8 @@ const assembleSessionGraph = (options: { }) /** Build the public handle over one assembled, started-or-adopted session. */ -const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): FoldSession => { - const { session, eventLog, ids, controls, subagentsEngine, configRef, profiles } = graph +const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): FoldSession => { + const { session, eventLog, ids, controls, continueSubagent, configRef, profiles } = graph const rootAgentId = identity.rootAgentId const collectEntries: Effect.Effect> = Stream.runCollect(eventLog.entries()).pipe( @@ -776,9 +748,9 @@ const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): FoldS } // A finished subagent continues directly (D8): null toolCallId - no tool dispatch caused it. - return yield* subagentsEngine - .continueSubagent({ agentId: target, prompt: text }) - .pipe(Effect.catchTag('SubagentBusyError', () => send(text, options))) + return yield* continueSubagent({ agentId: target, prompt: text }).pipe( + Effect.catchTag('SubagentBusyError', () => send(text, options)), + ) }) const steer = (text: string, options?: AgentTargetOptions): Effect.Effect => @@ -880,16 +852,19 @@ const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): FoldS Effect.asVoid, ) - const switchModel = (model: FoldModel, switchOptions?: SwitchModelOptions): Effect.Effect => + const switchModel = (model: FoldModel, switchOptions?: SwitchModelOptions): Effect.Effect => gate.withPermit( Effect.gen(function* () { const current = yield* Ref.get(configRef) const currentProfiles = yield* profiles.snapshot const candidateProfiles = switchOptions?.profiles ?? currentProfiles - const next: SessionAgentConfig = { + const next: SessionAgentConfig = { model, promptCacheKey: current.promptCacheKey, - systemPrompt: switchOptions?.systemPrompt ?? current.systemPrompt, + systemPrompt: + switchOptions?.systemPrompt === undefined + ? current.systemPrompt + : systemPromptBlocks(switchOptions.systemPrompt), tools: switchOptions?.tools ?? current.tools, } yield* validateToolNames(next.tools) @@ -946,7 +921,7 @@ const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): FoldS // Deliberately un-gated (unlike switchModel): role bindings are read at dispatch/resume time, so a // racing dispatch coherently gets the old or the new binding and nothing mid-run ever rebinds. - const setProfile = (role: ProfileRole, model: FoldModel): Effect.Effect => profiles.set(role, model) + const setProfile = (role: ProfileRole, model: FoldModel): Effect.Effect => profiles.set(role, model) const setTitle: FoldSession['setTitle'] = (title, provenance) => graph.eventLog .append( @@ -982,28 +957,24 @@ const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): FoldS * the surrounding scope: closing the scope releases the log backend, event spine, and provisioned model * runtimes. */ -export const startSession = (options: StartSessionOptions): Effect.Effect => +export const startSession = ( + options: StartSessionOptions, +): Effect.Effect, never, Scope.Scope | RA | RL | RP> => Effect.gen(function* () { - const graph = yield* assembleSessionGraph(options) + const graph = yield* assembleSessionGraph(options) const config = yield* Ref.get(graph.configRef) - const meta: Mutable> = { ...options.meta } - if (options.agent.name !== undefined) { - meta.agentName = options.agent.name - } - const startInput: Mutable = { - cwd: options.cwd ?? null, - model: options.agent.model.activeModel, - systemPrompt: graph.leadingPromptFor(config.systemPrompt, config.tools), - meta, - } - if (options.agent.promptCacheKey !== undefined) { - startInput.promptCacheKey = options.agent.promptCacheKey - } - if (options.sessionId !== undefined) { - startInput.sessionId = options.sessionId - } - - const started = yield* graph.session.start(startInput).pipe(Effect.orDie) + const meta = + options.agent.name === undefined ? { ...options.meta } : { ...options.meta, agentName: options.agent.name } + const started = yield* graph.session + .start({ + cwd: options.cwd ?? null, + model: options.agent.model.activeModel, + systemPrompt: graph.leadingPromptFor(config.systemPrompt, config.tools), + meta, + ...Struct.pick(options.agent, ['promptCacheKey']), + ...Struct.pick(options, ['sessionId']), + }) + .pipe(Effect.orDie) return makeSessionHandle(graph, started) }) @@ -1015,9 +986,11 @@ export const startSession = (options: StartSessionOptions): Effect.Effect => +export const resumeSession = ( + options: ResumeSessionOptions, +): Effect.Effect, never, Scope.Scope | RA | RL | RP> => Effect.gen(function* () { - const graph = yield* assembleSessionGraph(options) + const graph = yield* assembleSessionGraph(options) const entries = yield* Stream.runCollect(graph.eventLog.entries()).pipe( Effect.orDie, Effect.map((collected): ReadonlyArray => collected), @@ -1057,8 +1030,8 @@ export const resumeSession = (options: ResumeSessionOptions): Effect.Effect message.content) : [] - const modelDiffers = JSON.stringify(projected.activeModel) !== JSON.stringify(config.model.activeModel) - const blocksDiffer = JSON.stringify(composedBlocks) !== JSON.stringify(loggedBlocks) + const modelDiffers = !activeModelsEquivalent(projected.activeModel, config.model.activeModel) + const blocksDiffer = !promptBlocksEquivalent(composedBlocks, loggedBlocks) if (modelDiffers || blocksDiffer) { yield* graph.session diff --git a/packages/fold-core/src/Api/ToolDefinition.ts b/packages/fold-core/src/Api/ToolDefinition.ts index 18f1e2c..84fade8 100644 --- a/packages/fold-core/src/Api/ToolDefinition.ts +++ b/packages/fold-core/src/Api/ToolDefinition.ts @@ -11,28 +11,39 @@ * scan into its description and contributes the skills prompt block - do real work in theirs. Sharing * the same value across several agents' `tools` arrays shares one init (one scan, one snapshot). */ -import { Effect, FileSystem, Path, Schema } from 'effect' +import { Effect, Schema, type Scope } from 'effect' import { Tool } from 'effect/unstable/ai' -import { ChildProcessSpawner } from 'effect/unstable/process' +import type { AgentEvents } from '../AgentEvents/AgentEventsService' +import type { EventLog } from '../EventLog/EventLogService' +import type { Ids } from '../Ids' +import type { ModelRequestSettings } from '../Model/ModelRequestSettings' +import type { Profiles } from '../Session/Profiles' +import type { SessionControls } from '../Session/SessionControls' import type { SkillSourceService } from '../Skills/SkillSource' -import { Subagents } from '../Subagents/SubagentsService' +import type { SessionAgents } from '../Subagents/SessionAgents' +import type { SubagentToolCapabilities } from '../Subagents/SubagentTool' +import type { SystemPrompt } from '../SystemPrompt/SystemPromptService' import { CurrentAgent, CurrentToolCall, InterruptNote, StopController, ToolEvents, + type ToolEventSink, } from '../ToolRuntime/ToolContextServices' import { ToolState } from '../ToolRuntime/ToolStateService' /** * Ambient services every tool handler may use: durable per-call `ToolState` (through declared * `defineToolState` namespaces), ephemeral `ToolEvents` progress, the cooperative `StopController`, - * the executing call's identity (`CurrentAgent`/`CurrentToolCall` - D12), the `InterruptNote` enriching - * this call's synthetic result if it is interrupted, and the `Subagents` engine (the subagent tool's - * handler delegates to it). The runtime provides all of them around each call; handlers needing none of - * them simply have a smaller `R`. Platform services are not among them: see {@link PlatformServices}. + * the executing call's identity (`CurrentAgent`/`CurrentToolCall` - D12), and the `InterruptNote` + * enriching this call's synthetic result if it is interrupted. The runtime provides all of them around + * each call. Every handler also runs inside the session's own services - its event log, ids, controls, + * profiles, and what building an agent takes - which is what lets a delegation tool's handler call + * `dispatchSubagent`, `forkSubagent`, and `resumeSubagent`. Handlers needing none of these simply have a + * smaller `R`. Anything else a handler needs (a filesystem, an HTTP client, a host + * service) becomes part of the tool's own type, {@link FoldTool}, and the host provides it at the top. */ export type ToolHandlerServices = | ToolState @@ -41,14 +52,15 @@ export type ToolHandlerServices = | CurrentAgent | CurrentToolCall | InterruptNote - | Subagents - -/** - * Host services that disk- and process-backed descriptors (coding tools, a JSONL log, a disk skill - * source) may declare. A session never requires them: it passes along whichever ones its caller - * provides, so a host without a filesystem can run any session whose descriptors don't declare one. - */ -export type PlatformServices = FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + | EventLog + | Ids + | AgentEvents + | ToolEventSink + | SystemPrompt + | ModelRequestSettings + | SessionControls + | Profiles + | SessionAgents type ToolDependency = | typeof ToolState @@ -57,10 +69,6 @@ type ToolDependency = | typeof CurrentAgent | typeof CurrentToolCall | typeof InterruptNote - | typeof Subagents - | typeof FileSystem.FileSystem - | typeof Path.Path - | typeof ChildProcessSpawner.ChildProcessSpawner type ToolOptionsBuilder = { description: string @@ -71,15 +79,18 @@ type ToolOptionsBuilder } -/** Neutral platform services used by filesystem and process-backed tools. */ -export const platformToolDependencies = [ - FileSystem.FileSystem, - Path.Path, - ChildProcessSpawner.ChildProcessSpawner, -] as const - -/** Handler stored on a tool descriptor, erased to the runtime dispatch shape. */ -export type ErasedToolHandler = (params: unknown) => Effect.Effect +/** + * Handler stored on a tool descriptor, erased to the runtime dispatch shape (Effect AI's erased tool + * params). Its `R` is the per-call services the runtime provides around each call; the session's and the + * host's services come from the context its toolkit is built in. + */ +export type ErasedToolHandler = ( + params: Tool.Parameters, +) => Effect.Effect< + unknown, + unknown, + ToolState | ToolEvents | StopController | CurrentAgent | CurrentToolCall | InterruptNote +> /** * What one tool contributes to a session once its `init` has run: the realized tool definition (final @@ -92,8 +103,8 @@ export type SessionToolContribution = { /** Appended to the leading prompt blocks of each agent whose `tools` carry this value. */ readonly promptBlock: string | null /** - * The resolved skill source, when this contribution is a skill tool's - the seam the Subagents - * service preloads dispatch-time skills through (the dispatcher picks from skills *it* can see). + * The resolved skill source, when this contribution is a skill tool's - the seam the subagent + * operations preload dispatch-time skills through (the dispatcher picks from skills *it* can see). */ readonly skillSource?: SkillSourceService } @@ -101,14 +112,39 @@ export type SessionToolContribution = { /** * One tool as configured on an agent, ready for the composition root to initialize. Built with * {@link defineTool} or a system-tool factory (`skillTool`, `subagentTool`); consumed by `startSession` - * and by subagent definitions. + * and by subagent definitions. `R` is every service the tool needs from the host - its handler's and its + * init's - so an agent's type, and `startSession`'s, carry what the host must provide. */ -export type FoldTool = { +export type FoldTool = { readonly name: string - /** Run ONCE per distinct value per session by the composition root; contributions are reused. */ - readonly init: Effect.Effect + /** + * Run ONCE per distinct value per session by the composition root; contributions are reused. Resources + * it acquires live for the session's scope. Its handler runs with the same host services. + */ + readonly init: Effect.Effect + /** The subagent roster, on delegation tools built by `subagentTool` or `withSubagentCapabilities`. */ + readonly subagents?: SubagentToolCapabilities } +/** The host services a tool, or a union of tools, needs (the session always supplies `Scope` itself). */ +export type FoldToolServices = T extends { readonly init: Effect.Effect } + ? Exclude + : never + +/** + * View a list of tools as tools needing the union of their services. TypeScript infers a mixed array's + * element type as a union of tools, but cannot infer one service union across the elements, so the + * public constructors take the tools' own type and convert once here. + */ +export const toolsNeedingAll = >( + tools: ReadonlyArray, +): ReadonlyArray>> => + // SAFETY: each element is a FoldTool whose R is one member of FoldToolServices, and FoldTool is + // covariant in R, so each is a FoldTool>. TypeScript cannot see through the + // conditional type for a generic T. + // oxlint-disable-next-line typescript/consistent-type-assertions, automation/no-type-assertion + tools as ReadonlyArray>> + /** One realized tool ready to install into a Toolset: the composition-internal, post-init stage. */ export type RealizedFoldTool = { readonly name: string @@ -117,7 +153,12 @@ export type RealizedFoldTool = { } /** Options for {@link defineTool}. Schemas default to no parameters, void success, and no failure. */ -export type DefineToolOptions = { +export type DefineToolOptions< + Params extends Schema.Top, + Success extends Schema.Top, + Failure extends Schema.Top, + Services = never, +> = { readonly name: string readonly description: string /** Parameter schema advertised to the model. Defaults to an empty struct (no parameters). */ @@ -126,10 +167,11 @@ export type DefineToolOptions Effect.Effect + /** + * The handler may use the per-call {@link ToolHandlerServices} plus any host service; the host ones + * become the tool's `R` and the host provides them where it starts the session. + */ + readonly handler: (params: Params['Type']) => Effect.Effect } /** @@ -149,25 +191,17 @@ export const defineTool = < Params extends Schema.Top = Tool.EmptyParams, Success extends Schema.Top = typeof Schema.Void, Failure extends Schema.Top = typeof Schema.Never, + Services = never, >( - options: DefineToolOptions, -): FoldTool => { + options: DefineToolOptions, +): FoldTool> => { const toolOptions: ToolOptionsBuilder = { description: options.description, success: options.success ?? Schema.Undefined, failureMode: 'return', // Every tool may use the ambient per-call services; declaring them here keeps handler `R` // honest while the runtime provides all of them around each execution. - dependencies: [ - ToolState, - ToolEvents, - StopController, - CurrentAgent, - CurrentToolCall, - InterruptNote, - Subagents, - ...(options.dependencies ?? []), - ], + dependencies: [ToolState, ToolEvents, StopController, CurrentAgent, CurrentToolCall, InterruptNote], } if (options.parameters !== undefined) { toolOptions.parameters = options.parameters @@ -178,25 +212,22 @@ export const defineTool = < const tool = Tool.make(options.name, toolOptions).annotate(Tool.Strict, false) // asVoid yields the undefined value at runtime, which is exactly what Schema.Undefined encodes. - const handlerWithDependencies = + const handler = options.success === undefined ? (params: Params['Type']) => options.handler(params).pipe(Effect.asVoid) : options.handler - // SAFETY: Effect AI supplies the services declared by `dependencies` before invoking this handler. - // The erased Fold dispatch table retains only Fold's per-call services because heterogeneous tools - // cannot preserve their individual dependency rows after collection. - // oxlint-disable-next-line typescript/consistent-type-assertions - const handler = handlerWithDependencies as ErasedToolHandler return { name: options.name, init: Effect.succeed({ tool, - // SAFETY: the handler is stored erased so heterogeneous tools can share one dispatch table. - // Effect AI decodes model-supplied params against `parameters` before invoking the handler, - // so it is only ever called with values of `Params['Type']`. - // oxlint-disable-next-line typescript/consistent-type-assertions - handler: handler, + // SAFETY: the handler is stored erased so heterogeneous tools can share one dispatch table. Effect + // AI decodes model-supplied params against `parameters` before invoking it, so it only ever receives + // `Params['Type']`. The runtime provides the per-call services around each call; the session's own + // services and the host services in `Services` come from the context the toolkit is built in, and + // `startSession` requires the host ones from its caller because this tool's type carries them. + // oxlint-disable-next-line typescript/consistent-type-assertions, automation/no-type-assertion + handler: handler as ErasedToolHandler, promptBlock: null, }), } diff --git a/packages/fold-core/src/Compaction/CompactionEngine.ts b/packages/fold-core/src/Compaction/CompactionEngine.ts index 3d9f796..9dd44b5 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 { Array as Arr, Match, Predicate } from 'effect' /** * This file is the pure auto-compaction engine (D11): the threshold arithmetic over API-reported @@ -8,6 +8,15 @@ import { Match, Predicate } from 'effect' * error classifier for the reactive path. Everything here is data-in/data-out; the Compaction * service and the agent loop orchestrate around it. */ +import { + contentPartText, + encodedContentParts, + isPartOfType, + type EncodedContentPart, + type EncodedMessageContent, + toolCallParamsText, + toolResultText, +} from '../EventLog/MessageContent' import type { LogEntry } from '../EventLog/Schemas' import { usageInputTotal, usageOutputTotal, type UsageEncoded } from '../EventLog/Usage' import type { ProjectedMessage } from '../Projection/Projection' @@ -102,47 +111,18 @@ export const latestReportedContextTokens = (visibleEntries: ReadonlyArray { - try { - return JSON.stringify(value) ?? String(value) - } catch { - return String(value) - } -} - -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 estimatePartChars = (part: EncodedContentPart): number => + Match.value(part).pipe( + Match.discriminatorsExhaustive('type')({ + text: ({ text }) => text.length, + reasoning: ({ text }) => text.length, + file: () => filePartEstimateChars, + 'tool-call': (call) => call.name.length + toolCallParamsText(call).length, + 'tool-result': (result) => toolResultText(result).length, + 'tool-approval-request': (approval) => contentPartText(approval).length, + 'tool-approval-response': (approval) => contentPartText(approval).length, + }), ) -} - -const estimatePartChars = (part: EncodedPart): number => { - switch (part.type) { - case 'text': - case 'reasoning': - return part.text?.length ?? 0 - case 'file': - return filePartEstimateChars - case 'tool-call': - return (part.name?.length ?? 0) + safeStringify(part.params).length - case 'tool-result': - return safeStringify(part.result).length - default: - return safeStringify(part).length - } -} /** Estimate one projected message's token footprint (chars/4 heuristic; image parts weigh 4800 chars). */ export const estimateMessageTokens = (message: ProjectedMessage): number => { @@ -150,7 +130,7 @@ export const estimateMessageTokens = (message: ProjectedMessage): number => { const chars = Predicate.isTagged(message, 'system-message') ? message.messages.reduce((total, systemMessage) => total + systemMessage.content.length, 0) - : contentParts(message.message.content).reduce((total, part) => total + estimatePartChars(part), 0) + : encodedContentParts(message.message.content).reduce((total, part) => total + estimatePartChars(part), 0) return Math.max(1, Math.ceil(chars / 4)) } @@ -189,7 +169,7 @@ export const findCompactionCutPlan = ( messages: ReadonlyArray, keepRecentTokens: number, ): CompactionCut => { - if (messages.length === 0) return { firstKeptIndex: 0, turnStartIndex: -1, isSplitTurn: false } + if (Arr.isReadonlyArrayEmpty(messages)) return { firstKeptIndex: 0, turnStartIndex: -1, isSplitTurn: false } let accumulated = 0 let boundary = -1 @@ -240,18 +220,17 @@ export const findCompactionCutPlan = ( export const findCompactionCut = (messages: ReadonlyArray, keepRecentTokens: number): number => findCompactionCutPlan(messages, keepRecentTokens).firstKeptIndex -const serializeUserContent = (content: unknown): string => - contentParts(content) - .map((part) => { - switch (part.type) { - case 'text': - return part.text ?? '' - case 'file': - return '[attached file]' - default: - return safeStringify(part) - } - }) +const serializeUserContent = (content: EncodedMessageContent): string => + encodedContentParts(content) + .map((part) => + Match.value(part).pipe( + Match.discriminators('type')({ + text: ({ text }) => text, + file: () => '[attached file]', + }), + Match.orElse(contentPartText), + ), + ) .join('\n') const truncateToolResult = (serialized: string): string => @@ -274,29 +253,28 @@ export const serializeConversation = (messages: ReadonlyArray) lines.push(`[User]: ${serializeUserContent(entry.message.content)}`) }, 'assistant-message': (entry) => { - const parts = contentParts(entry.message.content) - const reasoning = parts.filter((part) => part.type === 'reasoning') - const text = parts.filter((part) => part.type === 'text') - const toolCalls = parts.filter((part) => part.type === 'tool-call') + const parts = encodedContentParts(entry.message.content) + const reasoning = parts.filter(isPartOfType('reasoning')) + const text = parts.filter(isPartOfType('text')) + const toolCalls = parts.filter(isPartOfType('tool-call')) for (const part of reasoning) { - lines.push(`[Assistant thinking]: ${part.text ?? ''}`) + lines.push(`[Assistant thinking]: ${part.text}`) } - if (text.length > 0) { - lines.push(`[Assistant]: ${text.map((part) => part.text ?? '').join('\n')}`) + if (Arr.isArrayNonEmpty(text)) { + lines.push(`[Assistant]: ${text.map((part) => part.text).join('\n')}`) } - if (toolCalls.length > 0) { + if (Arr.isArrayNonEmpty(toolCalls)) { lines.push( `[Assistant tool calls]: ${toolCalls - .map((part) => `${part.name ?? 'tool'}(${safeStringify(part.params)})`) + .map((part) => `${part.name}(${toolCallParamsText(part)})`) .join('; ')}`, ) } }, 'tool-result': (entry) => { - for (const part of contentParts(entry.message.content)) { - if (part.type !== 'tool-result') continue - lines.push(`[Tool result]: ${truncateToolResult(safeStringify(part.result))}`) + for (const part of encodedContentParts(entry.message.content).filter(isPartOfType('tool-result'))) { + lines.push(`[Tool result]: ${truncateToolResult(toolResultText(part))}`) } }, 'system-message': (entry) => { diff --git a/packages/fold-core/src/Compaction/CompactionLayer.ts b/packages/fold-core/src/Compaction/CompactionLayer.ts index 5696737..0aee757 100644 --- a/packages/fold-core/src/Compaction/CompactionLayer.ts +++ b/packages/fold-core/src/Compaction/CompactionLayer.ts @@ -3,13 +3,14 @@ * (D11). The live service is pure policy plus one summarization call: `shouldCompact` compares the * agent's last post-compaction API-reported usage against the model's usable budget, and `plan` * picks the keep-recent cut over the agent's projected conversation, serializes the replaced - * history, runs the summarizer through the AMBIENT LanguageModel (each agent's own provisioned - * model - subagents therefore summarize with their own model, D21), and returns the durable entry - * payload. The session facade provides this service session-wide; the loop owns appends. + * history, runs the summarizer through the LanguageModel captured when the service is built (each + * agent's own provisioned model - subagents therefore summarize with their own model, D21), and returns + * the durable entry payload. Provisioning builds one service per agent runtime from the session's + * policy; the loop owns appends. */ import { AnthropicLanguageModel } from '@humanlayer/effect-ai-anthropic' import { OpenAiLanguageModel } from '@humanlayer/effect-ai-openai' -import { Effect, Predicate, Stream } from 'effect' +import { Array as Arr, Effect, Layer, Predicate, Stream } from 'effect' import { LanguageModel, Prompt } from 'effect/unstable/ai' import { ModelCatalog } from '../Model/ModelCatalog' @@ -37,25 +38,14 @@ import { type CompactionPlan, type CompactionPlanInput, type CompactionService, + Compaction, } from './CompactionService' /** The enabled variant of {@link AutoCompactConfig}. */ export type EnabledAutoCompactConfig = Extract -const describeSummarizerError = (error: unknown): string => { - if (Predicate.isError(error)) return error.message - - try { - return JSON.stringify(error) - } catch { - return String(error) - } -} - /** Split an agent's projection into the summarizable conversation and its current summary, if any. */ -const conversationOf = ( - projected: ReadonlyArray, -): { readonly conversation: ReadonlyArray; readonly previousSummary: string | null } => { +const conversationOf = (projected: ReadonlyArray) => { let previousSummary: string | null = null const conversation: Array = [] @@ -75,8 +65,13 @@ const conversationOf = ( return { conversation, previousSummary } } -/** Build the live Compaction service for one enabled config. */ -export const makeCompactionService = (config: EnabledAutoCompactConfig): CompactionService => { +/** + * Build the live Compaction service for one enabled config. The summarizer LanguageModel and the + * session ModelCatalog are captured here, once, so the service's operations require nothing. + */ +export const makeCompactionService = Effect.fnUntraced(function* (config: EnabledAutoCompactConfig) { + const languageModel = yield* LanguageModel.LanguageModel + const modelCatalog = yield* ModelCatalog const reserveTokens = config.reserveTokens ?? defaultReserveTokens const summaryOutputFraction = 0.8 const turnPrefixOutputFraction = 0.5 @@ -91,7 +86,7 @@ export const makeCompactionService = (config: EnabledAutoCompactConfig): Compact if (config.contextWindow !== undefined) return config.contextWindow if (input.model?.providerKind === 'codex') return defaultContextWindowFor(input.model.modelId) - const entry = input.model === null ? null : yield* (yield* ModelCatalog).lookup(input.model) + const entry = input.model === null ? null : yield* modelCatalog.lookup(input.model) return entry?.contextWindow ?? defaultContextWindowFor(input.model?.modelId ?? null) }) @@ -105,7 +100,7 @@ export const makeCompactionService = (config: EnabledAutoCompactConfig): Compact const modelOutputLimitFor = (input: CompactionCheckInput): Effect.Effect => Effect.gen(function* () { - const entry = input.model === null ? null : yield* (yield* ModelCatalog).lookup(input.model) + const entry = input.model === null ? null : yield* modelCatalog.lookup(input.model) return entry !== null && entry.maxOutputTokens > 0 ? entry.maxOutputTokens : maxOutputTokenBudget }) @@ -113,11 +108,10 @@ export const makeCompactionService = (config: EnabledAutoCompactConfig): Compact input: CompactionPlanInput, requestText: string, outputFraction: number, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { const modelOutputLimit = yield* modelOutputLimitFor(input) const maxOutputTokens = Math.min(Math.floor(outputFraction * reserveTokens), modelOutputLimit) - const languageModel = yield* LanguageModel.LanguageModel const baseRequest = Stream.runCollect( languageModel.streamText({ prompt: Prompt.fromMessages([ @@ -137,7 +131,7 @@ export const makeCompactionService = (config: EnabledAutoCompactConfig): Compact ) } const request = configuredRequest.pipe( - Effect.mapError((error) => new CompactionSummarizeError({ message: describeSummarizerError(error) })), + Effect.mapError((error) => new CompactionSummarizeError({ message: error.message })), ) const parts = yield* request const summary = parts @@ -205,7 +199,7 @@ export const makeCompactionService = (config: EnabledAutoCompactConfig): Compact replacedMessages: discarded.length, keptMessages: conversation.length - discarded.length, splitTurn: cut.isSplitTurn, - summaryCalls: cut.isSplitTurn ? (toSummarize.length > 0 ? 2 : 1) : 1, + summaryCalls: cut.isSplitTurn ? (Arr.isArrayNonEmpty(toSummarize) ? 2 : 1) : 1, }) const requestText = buildCompactionRequestText({ @@ -220,10 +214,9 @@ export const makeCompactionService = (config: EnabledAutoCompactConfig): Compact additionalInstructions: input.additionalInstructions ?? null, }) - const historySummary = - toSummarize.length > 0 - ? yield* summarize(input, requestText, summaryOutputFraction) - : 'No prior history.' + const historySummary = Arr.isArrayNonEmpty(toSummarize) + ? yield* summarize(input, requestText, summaryOutputFraction) + : 'No prior history.' const summary = cut.isSplitTurn ? `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${yield* summarize( input, @@ -247,13 +240,23 @@ export const makeCompactionService = (config: EnabledAutoCompactConfig): Compact }), ) - return { enabled: true, shouldCompact, plan } -} + const service: CompactionService = { enabled: true, shouldCompact, plan } + return service +}) /** Resolve an agent definition's automatic policy while retaining an explicit compaction planner. */ -export const compactionServiceFor = (config: AutoCompactConfig | undefined): CompactionService => { - const live = makeCompactionService(config?.enabled === true ? config : { enabled: true }) +export const compactionServiceFor = Effect.fnUntraced(function* (config: AutoCompactConfig | undefined) { + const live = yield* makeCompactionService(config?.enabled === true ? config : { enabled: true }) if (config?.enabled !== false) return live - return { ...live, enabled: false, shouldCompact: () => Effect.succeed(false) } -} + const disabled: CompactionService = { ...live, enabled: false, shouldCompact: () => Effect.succeed(false) } + return disabled +}) + +/** + * The Compaction layer for one provisioned agent runtime: the session's policy over that runtime's own + * LanguageModel and the session ModelCatalog. + */ +export const compactionLayerFor = ( + config: AutoCompactConfig | undefined, +): Layer.Layer => Layer.effect(Compaction, compactionServiceFor(config)) diff --git a/packages/fold-core/src/Compaction/CompactionService.ts b/packages/fold-core/src/Compaction/CompactionService.ts index 79d15d7..442f7cd 100644 --- a/packages/fold-core/src/Compaction/CompactionService.ts +++ b/packages/fold-core/src/Compaction/CompactionService.ts @@ -6,14 +6,12 @@ * the loop consults it unconditionally with zero configuration burden on low-level composition * roots (core owns loop semantics; hosts own enablement - the D11 package-boundary ruling). * - * The service deliberately does NOT append log entries or hold a summarizer model: the loop owns - * envelopes and appends, and `plan` takes `LanguageModel` in its requirements so each agent - * summarizes through its own provisioned model by default (subagents compact with their own model - - * D21) while a future fold-agent layer can ignore the ambient model and bring a configured fast - * summarizer instead. + * The service deliberately does NOT append log entries: the loop owns envelopes and appends. + * Provisioning builds one live service per agent runtime over that runtime's own LanguageModel, so each + * agent summarizes through its own model (subagents compact with their own model - D21), while a + * future fold-agent layer can bring a configured fast summarizer instead. */ import { Context, Effect, Schema } from 'effect' -import type { LanguageModel } from 'effect/unstable/ai' import type { ActiveModel, LogEntry, LogSeq } from '../EventLog/Schemas' import type { AgentId } from '../Ids' @@ -89,16 +87,14 @@ export class CompactionSummarizeError extends Schema.TaggedError Effect.Effect - readonly plan: ( - input: CompactionPlanInput, - ) => Effect.Effect + readonly plan: (input: CompactionPlanInput) => Effect.Effect } /** Low-level fallback for graphs that do not install an agent policy. */ diff --git a/packages/fold-core/src/EventLog/Errors.ts b/packages/fold-core/src/EventLog/Errors.ts index ce9aad3..3da7583 100644 --- a/packages/fold-core/src/EventLog/Errors.ts +++ b/packages/fold-core/src/EventLog/Errors.ts @@ -33,8 +33,8 @@ export class EventLogCorruptEntryError extends Schema.TaggedError, fromSeq: LogSeq) => @@ -21,7 +21,7 @@ export const layerInMemoryEventLogWithIds: Layer.Layer = L appendLock.withPermit( Effect.gen(function* () { const current = yield* Ref.get(entriesRef) - const stored = yield* makeStoredLogEntry(input, current.length, ids) + const stored = yield* storedLogEntry(input, current.length, yield* ids.makeEventId) yield* Ref.set(entriesRef, [...current, stored]) yield* PubSub.publish(pubsub, stored) diff --git a/packages/fold-core/src/EventLog/EventLogService.ts b/packages/fold-core/src/EventLog/EventLogService.ts index b8223c7..6c80b71 100644 --- a/packages/fold-core/src/EventLog/EventLogService.ts +++ b/packages/fold-core/src/EventLog/EventLogService.ts @@ -1,8 +1,8 @@ import { Context } from 'effect' import type { Effect, Stream } from 'effect' -import type { EventLogError } from './Errors.ts' -import type { LogEntry, LogEntryInput, LogSeq } from './Schemas.ts' +import type { EventLogError } from './Errors' +import type { LogEntry, LogEntryInput, LogSeq } from './Schemas' /** Public EventLog service surface for a single fold session log. */ export type EventLogService = { diff --git a/packages/fold-core/src/EventLog/LogEntryFactory.ts b/packages/fold-core/src/EventLog/LogEntryFactory.ts index 7bcfe16..ae2a5b2 100644 --- a/packages/fold-core/src/EventLog/LogEntryFactory.ts +++ b/packages/fold-core/src/EventLog/LogEntryFactory.ts @@ -1,6 +1,6 @@ import { Clock, Effect, Schema } from 'effect' -import type { IdsService } from '../Ids' +import type { EventId } from '../Ids' import { EventLogInvalidEntryError } from './Errors' import { CURRENT_LOG_ENTRY_VERSION, LogEntry, LogEntryInput, type LogSeq } from './Schemas' @@ -11,20 +11,19 @@ const invalidEntryError = (message: string, cause: unknown) => cause, }) -/** Validate append input and assign the canonical event envelope. */ -export const makeStoredLogEntry = ( +/** Validate append input and assign the canonical event envelope under the given sequence number and event id. */ +export const storedLogEntry = ( input: LogEntryInput, seq: LogSeq, - ids: Pick, + eventId: EventId, ): Effect.Effect => Effect.gen(function* () { - const decodedInput = yield* Schema.decodeUnknownEffect(LogEntryInput)(input).pipe( + const decodedInput = yield* Schema.decodeEffect(LogEntryInput)(input).pipe( Effect.mapError((cause) => invalidEntryError('Invalid EventLog entry input', cause)), ) - const eventId = yield* ids.makeEventId const ts = yield* Clock.currentTimeMillis - return yield* Schema.decodeUnknownEffect(LogEntry)({ + return yield* Schema.decodeEffect(LogEntry)({ ...decodedInput, seq, eventId, diff --git a/packages/fold-core/src/EventLog/MessageContent.ts b/packages/fold-core/src/EventLog/MessageContent.ts new file mode 100644 index 0000000..3956efd --- /dev/null +++ b/packages/fold-core/src/EventLog/MessageContent.ts @@ -0,0 +1,58 @@ +/** + * Read helpers over the content of persisted (encoded) prompt messages. User, assistant, and tool + * messages store their content in Effect AI's encoded Prompt shape: a bare string or a list of encoded + * parts. Compaction, projection, and the CLI renderer all read that content through these helpers so + * the string/parts normalization lives in one place. + */ +import { Match, Option, Schema } from 'effect' +import type { Prompt } from 'effect/unstable/ai' + +/** Content of a persisted user, assistant, or tool message. */ +export type EncodedMessageContent = + | Prompt.UserMessageEncoded['content'] + | Prompt.AssistantMessageEncoded['content'] + | Prompt.ToolMessageEncoded['content'] + +/** One encoded part of a persisted message. */ +export type EncodedContentPart = Prompt.PartEncoded + +/** Normalize persisted message content to its parts; a bare string is one text part. */ +export const encodedContentParts = (content: EncodedMessageContent): ReadonlyArray => + Match.value(content).pipe( + Match.when(Match.string, (text): ReadonlyArray => [{ type: 'text', text }]), + Match.orElse((parts): ReadonlyArray => parts), + ) + +/** Narrow encoded parts to one part type, for `filter`/`find`/`some`. */ +export const isPartOfType = + (type: Type) => + (part: EncodedContentPart): part is Extract => + part.type === type + +/** Concatenate the text parts of persisted message content. */ +export const encodedContentText = (content: EncodedMessageContent): string => + encodedContentParts(content) + .filter(isPartOfType('text')) + .map((part) => part.text) + .join('') + +// Effect AI leaves tool-call params and tool results schema-free, so their JSON rendering can fail +// (cycles, bigints); each renderer below falls back to a plain-text form for that case. +const encodeJsonText = Schema.encodeUnknownOption(Schema.fromJsonString(Schema.Unknown)) + +/** Render a tool call's params as JSON text. */ +export const toolCallParamsText = (part: Prompt.ToolCallPartEncoded): string => + Option.getOrElse(encodeJsonText(part.params), () => String(part.params)) + +/** Render a tool result's payload as JSON text. */ +export const toolResultText = (part: Prompt.ToolResultPartEncoded): string => + Option.getOrElse(encodeJsonText(part.result), () => String(part.result)) + +/** Render a whole encoded part as JSON text. */ +export const contentPartText = (part: EncodedContentPart): string => + Option.getOrElse(encodeJsonText(part), () => `[${part.type} part]`) + +const encodeJson = Schema.encodeOption(Schema.fromJsonString(Schema.Json)) + +/** Render a JSON value (for example a tool progress payload) as JSON text. */ +export const jsonText = (value: Schema.Json): string => Option.getOrElse(encodeJson(value), () => '[unrenderable JSON]') diff --git a/packages/fold-core/src/EventLog/Schemas.ts b/packages/fold-core/src/EventLog/Schemas.ts index bb500af..27c71d4 100644 --- a/packages/fold-core/src/EventLog/Schemas.ts +++ b/packages/fold-core/src/EventLog/Schemas.ts @@ -432,7 +432,8 @@ export const ToolStateLogEntryInput = Schema.TaggedStruct('tool_state', { namespace: Schema.String, stateId: StateId, key: Schema.String, - value: Schema.Unknown, + /** The key schema's encoded value; null clears the key. */ + value: Schema.Json, }).annotate({ identifier: 'ToolStateLogEntryInput' }) export type ToolStateLogEntryInput = typeof ToolStateLogEntryInput.Type @@ -445,7 +446,8 @@ export const ToolStateLogEntry = Schema.TaggedStruct('tool_state', { namespace: Schema.String, stateId: StateId, key: Schema.String, - value: Schema.Unknown, + /** The key schema's encoded value; null clears the key. */ + value: Schema.Json, }).annotate({ identifier: 'ToolStateLogEntry' }) export type ToolStateLogEntry = typeof ToolStateLogEntry.Type @@ -459,7 +461,7 @@ export const CompactionLogEntryInput = Schema.TaggedStruct('compaction', { summary: Schema.String, postCompactionInstructions: Schema.optionalKey(Schema.String), replacesThroughSeq: LogSeq, - tokensBefore: Schema.Number, + tokensBefore: Schema.Finite, }) .check(AgentRunContextFilter) .annotate({ identifier: 'CompactionLogEntryInput' }) @@ -476,7 +478,7 @@ export const CompactionLogEntry = Schema.TaggedStruct('compaction', { summary: Schema.String, postCompactionInstructions: Schema.optionalKey(Schema.String), replacesThroughSeq: LogSeq, - tokensBefore: Schema.Number, + tokensBefore: Schema.Finite, }) .check(AgentRunContextFilter) .annotate({ identifier: 'CompactionLogEntry' }) @@ -597,7 +599,7 @@ export const SessionTitleLogEntryInput = Schema.TaggedStruct('session_title', { toolCallId: Schema.Null, title: Schema.String, generatedThroughSeq: Schema.optional(LogSeq), - rootUserTurns: Schema.optional(Schema.Number), + rootUserTurns: Schema.optional(Schema.Finite), }).annotate({ identifier: 'SessionTitleLogEntryInput' }) export type SessionTitleLogEntryInput = typeof SessionTitleLogEntryInput.Type @@ -609,7 +611,7 @@ export const SessionTitleLogEntry = Schema.TaggedStruct('session_title', { toolCallId: Schema.Null, title: Schema.String, generatedThroughSeq: Schema.optional(LogSeq), - rootUserTurns: Schema.optional(Schema.Number), + rootUserTurns: Schema.optional(Schema.Finite), }).annotate({ identifier: 'SessionTitleLogEntry' }) export type SessionTitleLogEntry = typeof SessionTitleLogEntry.Type diff --git a/packages/fold-core/src/EventLog/StoredLogEntryDecoder.ts b/packages/fold-core/src/EventLog/StoredLogEntryDecoder.ts index 53c9c55..b95a40a 100644 --- a/packages/fold-core/src/EventLog/StoredLogEntryDecoder.ts +++ b/packages/fold-core/src/EventLog/StoredLogEntryDecoder.ts @@ -1,4 +1,4 @@ -import { Effect, Schema } from 'effect' +import { Effect, Match, Schema } from 'effect' import { EventLogCorruptEntryError, EventLogUnsupportedVersionError } from './Errors' import { @@ -11,20 +11,8 @@ import { const PersistedRecord = Schema.Record(Schema.String, Schema.Unknown) -type Mutable = { -readonly [Key in keyof T]: T[Key] } - -const corruptEntry = (message: string, cause: unknown, seq?: number) => { - const input: Mutable[0]> = { - operation: 'entries', - message, - cause, - } - if (seq !== undefined) { - input.seq = seq - } - - return new EventLogCorruptEntryError(input) -} +const corruptEntry = (message: string, cause: unknown, seq?: number) => + new EventLogCorruptEntryError({ operation: 'entries', message, cause, seq }) /** * Decode one persisted Fold event by its wire-format version. @@ -39,7 +27,7 @@ const corruptEntry = (message: string, cause: unknown, seq?: number) => { * so an older runtime never guesses how to replay newer state. */ export const decodeStoredLogEntry = Effect.fn('fold.event_log.decode_stored_entry')( - (input: unknown) => + (input: Schema.Json) => Effect.gen(function* () { const record = yield* Schema.decodeUnknownEffect(PersistedRecord)(input).pipe( Effect.mapError((cause) => corruptEntry('Persisted EventLog entry is not an object', cause)), @@ -49,24 +37,28 @@ export const decodeStoredLogEntry = Effect.fn('fold.event_log.decode_stored_entr ) const version = envelope.version ?? CURRENT_LOG_ENTRY_VERSION - switch (version) { - case 1: - return yield* Schema.decodeUnknownEffect(LogEntryV1)({ + return yield* Match.value(version).pipe( + Match.when(1, () => + Schema.decodeUnknownEffect(LogEntryV1)({ ...record, version: CURRENT_LOG_ENTRY_VERSION, }).pipe( Effect.mapError((cause) => corruptEntry('Persisted EventLog v1 entry has an invalid payload', cause, envelope.seq), ), - ) - default: - return yield* new EventLogUnsupportedVersionError({ - operation: 'entries', - message: `Fold event format v${version} is not supported by this runtime`, - version, - seq: envelope.seq, - supportedVersions: [...SUPPORTED_LOG_ENTRY_VERSIONS], - }) - } + ), + ), + Match.orElse(() => + Effect.fail( + new EventLogUnsupportedVersionError({ + operation: 'entries', + message: `Fold event format v${version} is not supported by this runtime`, + version, + seq: envelope.seq, + supportedVersions: [...SUPPORTED_LOG_ENTRY_VERSIONS], + }), + ), + ), + ) }) satisfies Effect.Effect, ) diff --git a/packages/fold-core/src/HookRunner/Errors.ts b/packages/fold-core/src/HookRunner/Errors.ts index 6b05744..88aee0d 100644 --- a/packages/fold-core/src/HookRunner/Errors.ts +++ b/packages/fold-core/src/HookRunner/Errors.ts @@ -1,4 +1,4 @@ -import { Data, Predicate, type Cause } from 'effect' +import { Data, type Cause } from 'effect' export type HookPhase = 'preRequest' | 'preToolUse' | 'postToolUse' | 'onComplete' @@ -15,6 +15,3 @@ export class HookExecutionError extends Data.TaggedError('HookExecutionError')<{ return `${this.phase} hook "${this.hookName}" failed` } } - -export const isHookExecutionError = (error: unknown): error is HookExecutionError => - Predicate.isTagged(error, 'HookExecutionError') diff --git a/packages/fold-core/src/HookRunner/HookRunnerLayer.ts b/packages/fold-core/src/HookRunner/HookRunnerLayer.ts index 4265fb0..781d249 100644 --- a/packages/fold-core/src/HookRunner/HookRunnerLayer.ts +++ b/packages/fold-core/src/HookRunner/HookRunnerLayer.ts @@ -23,7 +23,7 @@ import type { PreToolUseHookDecision, PreToolUseHookInput, } from './Schema' -import type { HookScope, OnCompleteHook, PostToolUseHook, PreRequestHook, PreToolUseHook, HookConfig } from './Types.ts' +import type { HookScope, OnCompleteHook, PostToolUseHook, PreRequestHook, PreToolUseHook, HookConfig } from './Types' type HookDecision = PreRequestHookDecision | PreToolUseHookDecision | PostToolUseHookDecision | OnCompleteHookDecision const HookDecision = Data.taggedEnum() @@ -37,10 +37,9 @@ const catchHookExecutionError = ( hookName: string, ): Effect.Effect => effect.pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.fail(new HookExecutionError(phase, hookName, cause)), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => Effect.fail(new HookExecutionError(phase, hookName, cause)), ), ) @@ -88,7 +87,7 @@ const provideHookState = }) /** Build a HookRunner layer from hook configuration data. */ -export const makeHookRunner = (hooks: HookConfig): Layer.Layer => +export const layerHookRunner = (hooks: HookConfig): Layer.Layer => Layer.effect( HookRunner, Effect.gen(function* () { diff --git a/packages/fold-core/src/HookRunner/Types.ts b/packages/fold-core/src/HookRunner/Types.ts index 44c0d97..cfc825a 100644 --- a/packages/fold-core/src/HookRunner/Types.ts +++ b/packages/fold-core/src/HookRunner/Types.ts @@ -11,7 +11,7 @@ import type { PreRequestHookInput, PreToolUseHookDecision, PreToolUseHookInput, -} from './Schema.ts' +} from './Schema' /** * Ambient services every hook handler may use. diff --git a/packages/fold-core/src/Model/ModelCatalog.ts b/packages/fold-core/src/Model/ModelCatalog.ts index 02b4a7b..621ecdd 100644 --- a/packages/fold-core/src/Model/ModelCatalog.ts +++ b/packages/fold-core/src/Model/ModelCatalog.ts @@ -15,10 +15,10 @@ import type { ActiveModel } from '../EventLog/Schemas' /** Model pricing in USD per 1,000,000 tokens. Null rates mean the provider publishes no such rate. */ export const ModelPricing = Schema.Struct({ - inputPerMTokens: Schema.Number, - outputPerMTokens: Schema.Number, - cacheReadPerMTokens: Schema.NullOr(Schema.Number), - cacheWritePerMTokens: Schema.NullOr(Schema.Number), + inputPerMTokens: Schema.Finite, + outputPerMTokens: Schema.Finite, + cacheReadPerMTokens: Schema.NullOr(Schema.Finite), + cacheWritePerMTokens: Schema.NullOr(Schema.Finite), }).annotate({ identifier: 'ModelPricing' }) export type ModelPricing = typeof ModelPricing.Type @@ -31,9 +31,9 @@ export const ModelCatalogEntry = Schema.Struct({ providerId: Schema.String, modelId: Schema.String, name: Schema.NullOr(Schema.String), - contextWindow: Schema.Number, - maxInputTokens: Schema.NullOr(Schema.Number), - maxOutputTokens: Schema.Number, + contextWindow: Schema.Finite, + maxInputTokens: Schema.NullOr(Schema.Finite), + maxOutputTokens: Schema.Finite, reasoning: Schema.Boolean, reasoningEfforts: Schema.NullOr(Schema.Array(Schema.String)), vision: Schema.Boolean, @@ -57,16 +57,15 @@ export type ModelCatalogService = { * `anthropic`, the codex backend serves openai models, and openai-compatible endpoints usually proxy * `openai` models but may be a real catalog provider themselves (the configured profile id). */ -const candidateProviderIds = (model: ActiveModel): ReadonlyArray => { - switch (model.providerKind) { - case 'anthropic': - return ['anthropic'] - case 'codex': - return ['openai'] - case 'openai-compatible': - return model.providerId === 'openai' ? ['openai'] : [model.providerId, 'openai'] - } -} +const candidateProviderIds = (model: ActiveModel): ReadonlyArray => + Match.value(model.providerKind).pipe( + Match.when('anthropic', () => ['anthropic']), + Match.when('codex', () => ['openai']), + Match.when('openai-compatible', () => + model.providerId === 'openai' ? ['openai'] : [model.providerId, 'openai'], + ), + Match.exhaustive, + ) /** Deterministic preference for bare-model-id matches: anthropic, then openai, then first-seen. */ const bareMatchPriority = Match.type().pipe( diff --git a/packages/fold-core/src/Model/ModelFamily.ts b/packages/fold-core/src/Model/ModelFamily.ts index c068b7b..602de1e 100644 --- a/packages/fold-core/src/Model/ModelFamily.ts +++ b/packages/fold-core/src/Model/ModelFamily.ts @@ -5,24 +5,24 @@ * model id patterns for openai-compatible endpoints that serve many vendors (openrouter-style ids like * "anthropic/claude-..."). */ +import { Match } from 'effect' + import type { ActiveModel } from '../EventLog/Schemas' /** Coarse model grouping driving per-family prompt, toolset, and reasoning decisions. */ export type ModelFamily = 'claude' | 'codex' | 'gpt' | 'unknown' /** Classify one resolved model snapshot into its family. */ -export const modelFamilyFor = (model: ActiveModel): ModelFamily => { - switch (model.providerKind) { - case 'anthropic': - return 'claude' - case 'codex': - return 'codex' - case 'openai-compatible': { +export const modelFamilyFor = (model: ActiveModel): ModelFamily => + Match.value(model.providerKind).pipe( + Match.when('anthropic', (): ModelFamily => 'claude'), + Match.when('codex', (): ModelFamily => 'codex'), + Match.when('openai-compatible', (): ModelFamily => { const modelId = model.modelId.toLowerCase() if (modelId.includes('claude')) return 'claude' if (modelId.includes('codex')) return 'codex' if (modelId.startsWith('gpt') || /^o\d/.test(modelId)) return 'gpt' return 'unknown' - } - } -} + }), + Match.exhaustive, + ) diff --git a/packages/fold-core/src/Model/ModelRequestSettings.ts b/packages/fold-core/src/Model/ModelRequestSettings.ts index 6cc4246..8b77b57 100644 --- a/packages/fold-core/src/Model/ModelRequestSettings.ts +++ b/packages/fold-core/src/Model/ModelRequestSettings.ts @@ -76,14 +76,14 @@ export const supportsAdaptiveThinking = (modelId: string): boolean => { * ModelCatalog-driven per-model budgets and max_tokens fitting land (D23). The provider defaults * `max_tokens` to the model's max output, which comfortably exceeds these budgets. */ -export const defaultAnthropicThinkingBudgets: Record, number> = { +export const defaultAnthropicThinkingBudgets = { minimal: 1024, low: 2048, medium: 8192, high: 16384, xhigh: 16384, max: 16384, -} +} as const satisfies Record, number> /** * Map one reasoning level onto an anthropic thinking setting for the given model: `off` disables @@ -102,20 +102,14 @@ export const resolveAnthropicThinking = (level: ReasoningLevel, modelId: string) * thinking. The vendored provider `Config` currently exposes only `low | medium | high`, so * `minimal` maps to `low` and `xhigh`/`max` clamp to `high` until the SDK exposes the full scale. */ -export const anthropicEffortForLevel = (level: ReasoningLevel): 'low' | 'medium' | 'high' => { - switch (level) { - case 'off': - case 'minimal': - case 'low': - return 'low' - case 'medium': - return 'medium' - case 'high': - case 'xhigh': - case 'max': - return 'high' - } -} +export const anthropicEffortForLevel = (level: ReasoningLevel): 'low' | 'medium' | 'high' => + Match.value(level).pipe( + Match.withReturnType<'low' | 'medium' | 'high'>(), + Match.when(Match.is('off', 'minimal', 'low'), () => 'low'), + Match.when('medium', () => 'medium'), + Match.when(Match.is('high', 'xhigh', 'max'), () => 'high'), + Match.exhaustive, + ) /** Input for wrapping one model request: the projected active model and reasoning level. */ export type WrapModelRequestInput = { @@ -140,6 +134,8 @@ export class ModelRequestSettings extends Context.Service(self: Effect.Effect) => Effect.Effect + const identity = (self: Effect.Effect): Effect.Effect => self /** @@ -155,77 +151,81 @@ export const liveModelRequestSettingsLayer: Layer.Layer = const level = reasoningLevel ?? model.requestedReasoningLevel - switch (model.providerKind) { - case 'openai-compatible': { - let setting = model.reasoning - if (level !== model.requestedReasoningLevel) { - setting = resolveOpenAiReasoning(level) - } - const reasoning = Match.valueTags(setting, { - disabled: () => ({}), - effort: ({ effort }) => { - if (model.reasoningSummary === undefined) return { reasoning: { effort } } - return { reasoning: { effort, summary: model.reasoningSummary } } - }, - }) - - const config: OpenAiConfigBuilder = { - model: model.modelId, - ...reasoning, - } - if (promptCacheKey !== null) { - config.prompt_cache_key = promptCacheKey - } - - return (self: Effect.Effect) => OpenAiLanguageModel.withConfigOverride(self, config) - } - - case 'codex': { - const setting = level === model.requestedReasoningLevel ? model.reasoning : resolveCodexReasoning(level) - const reasoning = Match.valueTags(setting, { - disabled: () => ({}), - effort: ({ effort, summary }) => ({ reasoning: { effort, summary } }), - }) - - const config: OpenAiConfigBuilder = { - model: model.modelId, - ...reasoning, - } - if (promptCacheKey !== null) { - config.prompt_cache_key = promptCacheKey - } - - return (self: Effect.Effect) => OpenAiLanguageModel.withConfigOverride(self, config) - } - - case 'anthropic': { - const setting = - level === model.requestedReasoningLevel - ? model.thinking - : resolveAnthropicThinking(level, model.modelId) - - // Unlike the OpenAI config, the anthropic generated schema types `model` as a strict literal - // union, so the projected model id cannot bind per-request; anthropic model selection stays at - // layer construction until the AgentModels layer seam lands (D15). Tools opt out of strict - // structured-output mode at definition time; do not pass the provider's `strictJsonSchema` - // config helper here, because this beta provider accidentally forwards it into the API payload. - return Match.valueTags(setting, { - disabled: () => identity, - adaptive: - () => - (self: Effect.Effect) => - AnthropicLanguageModel.withConfigOverride(self, { - thinking: { type: 'adaptive' }, - output_config: { effort: anthropicEffortForLevel(level) }, - }), - budget: - (budget) => - (self: Effect.Effect) => - AnthropicLanguageModel.withConfigOverride(self, { - thinking: { type: 'enabled', budget_tokens: budget.budgetTokens }, - }), - }) - } - } + return Match.value(model).pipe( + Match.withReturnType(), + Match.discriminatorsExhaustive('providerKind')({ + 'openai-compatible': (model) => { + let setting = model.reasoning + if (level !== model.requestedReasoningLevel) { + setting = resolveOpenAiReasoning(level) + } + const reasoning = Match.valueTags(setting, { + disabled: () => ({}), + effort: ({ effort }) => { + if (model.reasoningSummary === undefined) return { reasoning: { effort } } + return { reasoning: { effort, summary: model.reasoningSummary } } + }, + }) + + const config: OpenAiConfigBuilder = { + model: model.modelId, + ...reasoning, + } + if (promptCacheKey !== null) { + config.prompt_cache_key = promptCacheKey + } + + return (self: Effect.Effect) => + OpenAiLanguageModel.withConfigOverride(self, config) + }, + codex: (model) => { + const setting = + level === model.requestedReasoningLevel ? model.reasoning : resolveCodexReasoning(level) + const reasoning = Match.valueTags(setting, { + disabled: () => ({}), + effort: ({ effort, summary }) => ({ reasoning: { effort, summary } }), + }) + + const config: OpenAiConfigBuilder = { + model: model.modelId, + ...reasoning, + } + if (promptCacheKey !== null) { + config.prompt_cache_key = promptCacheKey + } + + return (self: Effect.Effect) => + OpenAiLanguageModel.withConfigOverride(self, config) + }, + anthropic: (model) => { + const setting = + level === model.requestedReasoningLevel + ? model.thinking + : resolveAnthropicThinking(level, model.modelId) + + // Unlike the OpenAI config, the anthropic generated schema types `model` as a strict literal + // union, so the projected model id cannot bind per-request; anthropic model selection stays at + // layer construction until the AgentModels layer seam lands (D15). Tools opt out of strict + // structured-output mode at definition time; do not pass the provider's `strictJsonSchema` + // config helper here, because this beta provider accidentally forwards it into the API payload. + return Match.valueTags(setting, { + disabled: () => identity, + adaptive: + () => + (self: Effect.Effect) => + AnthropicLanguageModel.withConfigOverride(self, { + thinking: { type: 'adaptive' }, + output_config: { effort: anthropicEffortForLevel(level) }, + }), + budget: + (budget) => + (self: Effect.Effect) => + AnthropicLanguageModel.withConfigOverride(self, { + thinking: { type: 'enabled', budget_tokens: budget.budgetTokens }, + }), + }) + }, + }), + ) }, }) diff --git a/packages/fold-core/src/Model/RequestBuilder.ts b/packages/fold-core/src/Model/RequestBuilder.ts index e217b3f..5631724 100644 --- a/packages/fold-core/src/Model/RequestBuilder.ts +++ b/packages/fold-core/src/Model/RequestBuilder.ts @@ -8,7 +8,7 @@ * metadata into history. The assistant tool-call params stay exactly as decoded from the persisted * assistant message, keeping already-sent prompt bytes stable across turns. */ -import { Effect, Encoding, Match, Option, Predicate, Schema } from 'effect' +import { Array as Arr, Effect, Encoding, Match, Option, Predicate, Schema } from 'effect' import { Prompt } from 'effect/unstable/ai' import type { ProjectedMessage, ProjectedToolResult } from '../Projection/Projection' @@ -24,7 +24,7 @@ export const providerToolCallIdKey = 'providerToolCallId' /** A projected message could not be decoded into a live Prompt message. */ export class PromptDecodeError extends Schema.TaggedError()('PromptDecodeError', { - sourceSeq: Schema.Number, + sourceSeq: Schema.Finite, entryTag: Schema.String, message: Schema.String, cause: Schema.optional(Schema.Defect()), @@ -67,7 +67,13 @@ const restoreAssistantToolCallIds = ( if (providerId === null) return part providerIdsByFoldId.set(part.id, providerId) - return Prompt.toolCallPart({ ...part, id: providerId }) + return Prompt.toolCallPart({ + id: providerId, + name: part.name, + params: part.params, + providerExecuted: part.providerExecuted, + options: part.options, + }) }), options: message.options, }) @@ -84,7 +90,14 @@ const restoreToolResultIds = ( const providerId = providerIdsByFoldId.get(part.id) if (providerId === undefined) return part - return Prompt.toolResultPart({ ...part, id: providerId }) + return Prompt.toolResultPart({ + id: providerId, + name: part.name, + isFailure: part.isFailure, + result: part.result, + providerExecuted: part.providerExecuted, + options: part.options, + }) }), options: message.options, }) @@ -218,7 +231,9 @@ const markLatestUserSideCacheBreakpoint = (messages: ReadonlyArray => { +const prepareToolResult = ( + result: Prompt.ToolResultPart['result'], +): Effect.Effect => { const decoded = decodeToolResultOutput(result) if (Option.isNone(decoded)) return Effect.succeed(result) @@ -246,7 +261,18 @@ const prepareToolResult = (result: unknown): Effect.Effect => Effect.forEach(message.content, (part): Effect.Effect => part.type === 'tool-result' - ? prepareToolResult(part.result).pipe(Effect.map((result) => Prompt.toolResultPart({ ...part, result }))) + ? prepareToolResult(part.result).pipe( + Effect.map((result) => + Prompt.toolResultPart({ + id: part.id, + name: part.name, + isFailure: part.isFailure, + result, + providerExecuted: part.providerExecuted, + options: part.options, + }), + ), + ) : Effect.succeed(part), ).pipe(Effect.map((content) => Prompt.toolMessage({ content, options: message.options }))) @@ -276,7 +302,7 @@ export const buildPrompt = ( const toolCalls = unresolvedToolCallsFromAssistantMessage(assistant) promptMessages.push(restoreAssistantToolCallIds(assistant, providerIdsByFoldId)) - if (toolCalls.length === 0) continue + if (Arr.isReadonlyArrayEmpty(toolCalls)) continue const results: Array = [] while (true) { diff --git a/packages/fold-core/src/Projection/Projection.ts b/packages/fold-core/src/Projection/Projection.ts index 8085470..96045af 100644 --- a/packages/fold-core/src/Projection/Projection.ts +++ b/packages/fold-core/src/Projection/Projection.ts @@ -1,5 +1,6 @@ -import { Data, Match, Predicate } from 'effect' +import { Array as Arr, Data, Match, Predicate, type Schema, Struct } from 'effect' +import { encodedContentParts, encodedContentText, isPartOfType } from '../EventLog/MessageContent' import type { ActiveModel, AgentFinishedLogEntry, @@ -13,8 +14,8 @@ import type { ToolMessageEncoded, ToolResultLogEntry, UserMessageLogEntry, -} from '../EventLog/Schemas.ts' -import type { AgentId } from '../Ids.ts' +} from '../EventLog/Schemas' +import type { AgentId } from '../Ids' /** Read model for an agent's lifecycle: how it started, whether it has finished, and whether it is runnable. */ export type AgentLifecycleProjection = { @@ -56,12 +57,12 @@ export type ProjectedAssistantMessage = ProjectedLogEntry /** Projection-only stand-in for history replaced by a compaction entry. */ -export type ProjectedCompactionSummary = ProjectedLogEntryFields< - CompactionLogEntry, - 'compactionId' | 'replacesThroughSeq' | 'summary' | 'tokensBefore' | 'postCompactionInstructions' -> & { - readonly _tag: `${CompactionLogEntry['_tag']}-summary` -} +export type ProjectedCompactionSummary = Data.TaggedEnum<{ + 'compaction-summary': ProjectedLogEntryFields< + CompactionLogEntry, + 'compactionId' | 'replacesThroughSeq' | 'summary' | 'tokensBefore' | 'postCompactionInstructions' + > +}> /** Ordered read model of the messages an agent should send to the language model. */ export type ProjectedMessage = @@ -73,10 +74,8 @@ export type ProjectedMessage = const ProjectedMessage = Data.taggedEnum() -type Mutable = { -readonly [Key in keyof T]: T[Key] } - /** Tool-owned key/value state for one agent namespace, built by folding tool_state entries in log order. */ -export type ToolStateProjection = Readonly> +export type ToolStateProjection = Readonly> const ownEntriesForAgent = (entries: ReadonlyArray, agentId: AgentId): ReadonlyArray => entries.filter((entry) => entry.agentId === agentId) @@ -95,21 +94,18 @@ 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' - ? entry.message.content - : entry.message.content.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('') - const isInjectedSkillMessage = (entry: LogEntry): boolean => { if (!Predicate.isTagged(entry, 'user-message')) return false - const content = userMessageText(entry).trim() + const content = encodedContentText(entry.message.content).trim() return /^)/.test(content) && content.endsWith('') } 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 - return entry.message.content.length > 0 && entry.message.content.every((part) => part.type === 'text') + return Match.value(entry.message.content).pipe( + Match.when(Match.string, (text) => text.trim().length > 0), + Match.orElse((parts) => Arr.isReadonlyArrayNonEmpty(parts) && parts.every(isPartOfType('text'))), + ) } const eligibleForkHistory = ( @@ -247,19 +243,19 @@ export const toolStateForAgent = ( agentId: AgentId, namespace: string, ): ToolStateProjection => { - const state: Record = {} + const state = new Map() for (const entry of ownEntriesForAgent(entries, agentId)) { if (!Predicate.isTagged(entry, 'tool_state') || entry.namespace !== namespace) continue if (entry.value === null) { - delete state[entry.key] + state.delete(entry.key) } else { - state[entry.key] = entry.value + state.set(entry.key, entry.value) } } - return state + return Object.fromEntries(state) } const latestLeadingSystemMessage = (entries: ReadonlyArray): SystemMessageLogEntry | null => @@ -271,14 +267,13 @@ const latestLeadingSystemMessage = (entries: ReadonlyArray): SystemMes const latestCompaction = (entries: ReadonlyArray): CompactionLogEntry | null => entries.findLast((entry): entry is CompactionLogEntry => Predicate.isTagged(entry, 'compaction')) ?? null -const toolCallIdsForAssistantMessage = (message: AssistantMessageEncoded): ReadonlyArray => { - if (typeof message.content === 'string') return [] - - return message.content.flatMap((part) => (part.type === 'tool-call' ? [part.id] : [])) -} +const toolCallIdsForAssistantMessage = (message: AssistantMessageEncoded): ReadonlyArray => + encodedContentParts(message.content) + .filter(isPartOfType('tool-call')) + .map((part) => part.id) const toolResultIds = (message: ToolMessageEncoded): ReadonlyArray => - message.content.flatMap((part) => (part.type === 'tool-result' ? [part.id] : [])) + message.content.filter(isPartOfType('tool-result')).map((part) => part.id) /** Put completed tool results back into the assistant's tool-call order before building the next prompt. */ const orderProjectedToolResults = (messages: ReadonlyArray): ReadonlyArray => { @@ -295,7 +290,7 @@ const orderProjectedToolResults = (messages: ReadonlyArray): R if (!Predicate.isTagged(message, 'assistant-message')) continue const toolCallIds = toolCallIdsForAssistantMessage(message.message) - if (toolCallIds.length === 0) continue + if (Arr.isReadonlyArrayEmpty(toolCallIds)) continue const toolResults: Array = [] while (true) { @@ -389,17 +384,18 @@ export const messagesForAgent = ( } if (compaction !== null) { - const summaryInput: Mutable[0]> = { - sourceSeq: compaction.seq, - compactionId: compaction.compactionId, - replacesThroughSeq: compaction.replacesThroughSeq, - summary: compaction.summary, - tokensBefore: compaction.tokensBefore, - } - if (compaction.postCompactionInstructions !== undefined) { - summaryInput.postCompactionInstructions = compaction.postCompactionInstructions - } - projected.push(ProjectedMessage['compaction-summary'](summaryInput)) + projected.push( + ProjectedMessage['compaction-summary']({ + sourceSeq: compaction.seq, + ...Struct.pick(compaction, [ + 'compactionId', + 'replacesThroughSeq', + 'summary', + 'tokensBefore', + 'postCompactionInstructions', + ]), + }), + ) } for (const entry of visibleEntries) { diff --git a/packages/fold-core/src/Session/Profiles.ts b/packages/fold-core/src/Session/Profiles.ts index 2faf189..6af5b66 100644 --- a/packages/fold-core/src/Session/Profiles.ts +++ b/packages/fold-core/src/Session/Profiles.ts @@ -8,7 +8,7 @@ * at resolve time is an engine invariant violation - session-start validation rejects any roster whose * role bindings the initial profiles do not cover - and dies like other configuration invariants. */ -import { Context, Effect, Match, Ref, Schema } from 'effect' +import { Context, Effect, Layer, Match, Ref, Schema } from 'effect' import type { FoldModel } from '../Api/ModelDescriptor' @@ -25,19 +25,22 @@ export const isProfileRole = Schema.is(ProfileRole) * A session's role->model bindings, as passed to `startSession`/`resumeSession`. A plain type, not a * schema: the values are full model descriptors carrying Redacted keys and provider Effects (the same * status as `FoldModel` itself); nothing durable is written from this map - role bindings resolve to - * concrete models before any log row exists. + * concrete models before any log row exists. `R` is the host services the bound models need. */ -export type SessionProfiles = { - readonly smart?: FoldModel - readonly fast?: FoldModel - readonly orchestrator?: FoldModel +export type SessionProfiles = { + readonly smart?: FoldModel + readonly fast?: FoldModel + readonly orchestrator?: FoldModel } /** The model covering one role in a profiles map; `orchestrator` falls back to `smart` (D25). */ -export const profileModelFor = (profiles: SessionProfiles, role: ProfileRole): FoldModel | undefined => +export const profileModelFor = (profiles: SessionProfiles, role: ProfileRole): FoldModel | undefined => role === 'orchestrator' ? (profiles.orchestrator ?? profiles.smart) : profiles[role] -/** Session-wide mutable role->model bindings; role-bound agents resolve per dispatch/resume. */ +/** + * Session-wide mutable role->model bindings; role-bound agents resolve per dispatch/resume. Models are + * held as `FoldModel`: the session's public API only accepts models whose services it has. + */ export type ProfilesService = { /** * The model currently bound to a role (`orchestrator` falls back to `smart`, D25). Dies when the @@ -45,46 +48,49 @@ export type ProfilesService = { * against the initial bindings, and `set` can only add or replace bindings - so an unbound role * here is an engine invariant violation, never a caller error. */ - readonly resolve: (role: ProfileRole) => Effect.Effect + readonly resolve: (role: ProfileRole) => Effect.Effect> /** Rebind one role; every subsequent dispatch/resume of a role-bound type sees the new model. */ - readonly set: (role: ProfileRole, model: FoldModel) => Effect.Effect + readonly set: (role: ProfileRole, model: FoldModel) => Effect.Effect /** Atomically replace the complete role map at a session configuration commit boundary. */ - readonly replace: (profiles: SessionProfiles) => Effect.Effect + readonly replace: (profiles: SessionProfiles) => Effect.Effect /** The current bindings, as plain data. */ - readonly snapshot: Effect.Effect + readonly snapshot: Effect.Effect> } -/** Profiles service tag; one instance per session, shared by the facade and the Subagents engine. */ +/** Profiles service tag; one instance per session, shared by the facade and the subagent operations. */ export class Profiles extends Context.Service()('fold/Profiles') {} -/** Build one session's profiles over the initial bindings from `startSession`/`resumeSession`. */ -export const makeProfiles = (initial: SessionProfiles): Effect.Effect => - Effect.gen(function* () { - const state = yield* Ref.make(initial) +/** One session's profiles over the initial bindings from `startSession`/`resumeSession`. */ +export const layerProfiles = (initial: SessionProfiles): Layer.Layer => + Layer.effect( + Profiles, + Effect.gen(function* () { + const state = yield* Ref.make>(initial) - const resolve = (role: ProfileRole): Effect.Effect => - Ref.get(state).pipe( - Effect.flatMap((profiles) => { - const model = profileModelFor(profiles, role) - return model === undefined - ? Effect.die( - new Error( - `profile role "${role}" has no bound model - session-start validation should have rejected this roster`, - ), - ) - : Effect.succeed(model) - }), - ) + const resolve = (role: ProfileRole): Effect.Effect> => + Ref.get(state).pipe( + Effect.flatMap((profiles) => { + const model = profileModelFor(profiles, role) + return model === undefined + ? Effect.die( + new Error( + `profile role "${role}" has no bound model - session-start validation should have rejected this roster`, + ), + ) + : Effect.succeed(model) + }), + ) - const set = (role: ProfileRole, model: FoldModel): Effect.Effect => - Ref.update(state, (profiles) => - Match.value(role).pipe( - Match.when('smart', () => ({ ...profiles, smart: model })), - Match.when('fast', () => ({ ...profiles, fast: model })), - Match.when('orchestrator', () => ({ ...profiles, orchestrator: model })), - Match.exhaustive, - ), - ) + const set = (role: ProfileRole, model: FoldModel): Effect.Effect => + Ref.update(state, (profiles) => + Match.value(role).pipe( + Match.when('smart', () => ({ ...profiles, smart: model })), + Match.when('fast', () => ({ ...profiles, fast: model })), + Match.when('orchestrator', () => ({ ...profiles, orchestrator: model })), + Match.exhaustive, + ), + ) - return { resolve, set, replace: (profiles) => Ref.set(state, profiles), snapshot: Ref.get(state) } - }) + return { resolve, set, replace: (profiles) => Ref.set(state, profiles), snapshot: Ref.get(state) } + }), + ) diff --git a/packages/fold-core/src/Session/SessionControls.ts b/packages/fold-core/src/Session/SessionControls.ts index 4b41c00..926da44 100644 --- a/packages/fold-core/src/Session/SessionControls.ts +++ b/packages/fold-core/src/Session/SessionControls.ts @@ -3,7 +3,7 @@ * One instance per session owns three things every agent's loop and the public facade share: * * - The running-agent registry: who is running right now, with the run fiber once it exists. The - * facade claims the root's runs, the Subagents engine claims dispatched children, and SDK + * facade claims the root's runs, the subagent operations claim dispatched children, and SDK * continuations claim their target - so "is agent X running", targeted interrupt (D10), and the * subagent Busy guard (D21) all read one source of truth. * - Per-agent steering and follow-up queues (D8). Both are ephemeral in-memory queues: a steered @@ -15,7 +15,7 @@ * its batch boundaries (root and subagents alike - the tree stops together), cleared by the facade * when the next send begins. */ -import { Context, Deferred, Effect, type Exit, Fiber, Ref, SynchronizedRef } from 'effect' +import { Array as Arr, Context, Deferred, Effect, type Exit, Fiber, Layer, Ref, SynchronizedRef } from 'effect' import type { AgentFinishedLogEntry } from '../EventLog/Schemas' import type { AgentId } from '../Ids' @@ -86,144 +86,155 @@ const notRunning = (agentId: AgentId): AgentNotRunningError => `use send(message, { agentId: "${agentId}" }) to continue a finished agent.`, }) -/** Build one session's controls. `steeringMode` fixes how steering queues drain (D8). */ -export const makeSessionControls = (options?: { +/** One session's controls. `steeringMode` fixes how steering queues drain (D8). */ +export const layerSessionControls = (options?: { readonly steeringMode?: SteeringMode -}): Effect.Effect => - Effect.gen(function* () { - const steeringMode = options?.steeringMode ?? 'one-at-a-time' - - const running = yield* SynchronizedRef.make>(new Map()) - const steering = yield* Ref.make>>(new Map()) - const followUps = yield* Ref.make>>(new Map()) - const stopReason = yield* Ref.make(null) - - const claimRunning = (agentId: AgentId): Effect.Effect => - SynchronizedRef.modifyEffect(running, (current) => - current.has(agentId) - ? Effect.succeed([false, current] as const) - : Effect.succeed([true, new Map(current).set(agentId, { fiber: null })] as const), - ) +}): Layer.Layer => + Layer.effect( + SessionControls, + Effect.gen(function* () { + const steeringMode = options?.steeringMode ?? 'one-at-a-time' + + const running = yield* SynchronizedRef.make>(new Map()) + const steering = yield* Ref.make>>(new Map()) + const followUps = yield* Ref.make>>(new Map()) + const stopReason = yield* Ref.make(null) + + const claimRunning = (agentId: AgentId): Effect.Effect => + SynchronizedRef.modifyEffect(running, (current) => + current.has(agentId) + ? Effect.succeed([false, current] as const) + : Effect.succeed([true, new Map(current).set(agentId, { fiber: null })] as const), + ) - const setRunningFiber = (agentId: AgentId, fiber: Fiber.Fiber): Effect.Effect => - SynchronizedRef.modifyEffect(running, (current) => { - const record = current.get(agentId) - if (record === undefined) return Effect.succeed([undefined, current] as const) - return Effect.succeed([undefined, new Map(current).set(agentId, { ...record, fiber })] as const) - }) - - /** Abandon one agent's queued follow-ups: resolve every ticket false and clear the queue. */ - const abandonFollowUps = (agentId: AgentId): Effect.Effect => - Effect.gen(function* () { - const queued = yield* Ref.modify(followUps, (current) => { - const entries = current.get(agentId) ?? [] - const next = new Map(current) - next.delete(agentId) - return [entries, next] as const + const setRunningFiber = ( + agentId: AgentId, + fiber: Fiber.Fiber, + ): Effect.Effect => + SynchronizedRef.modifyEffect(running, (current) => { + const record = current.get(agentId) + if (record === undefined) return Effect.succeed([undefined, current] as const) + return Effect.succeed([undefined, new Map(current).set(agentId, { ...record, fiber })] as const) }) - yield* Effect.forEach(queued, (entry) => Deferred.succeed(entry.consumed, false), { discard: true }) - }) - const releaseRunning = (agentId: AgentId): Effect.Effect => - Effect.gen(function* () { - yield* SynchronizedRef.modifyEffect(running, (current) => { - const next = new Map(current) - next.delete(agentId) - return Effect.succeed([undefined, next] as const) + /** Abandon one agent's queued follow-ups: resolve every ticket false and clear the queue. */ + const abandonFollowUps = (agentId: AgentId): Effect.Effect => + Effect.gen(function* () { + const queued = yield* Ref.modify(followUps, (current) => { + const entries = current.get(agentId) ?? [] + const next = new Map(current) + next.delete(agentId) + return [entries, next] as const + }) + yield* Effect.forEach(queued, (entry) => Deferred.succeed(entry.consumed, false), { discard: true }) }) - yield* Ref.update(steering, (current) => { - const next = new Map(current) - next.delete(agentId) - return next + + const releaseRunning = (agentId: AgentId): Effect.Effect => + Effect.gen(function* () { + yield* SynchronizedRef.modifyEffect(running, (current) => { + const next = new Map(current) + next.delete(agentId) + return Effect.succeed([undefined, next] as const) + }) + yield* Ref.update(steering, (current) => { + const next = new Map(current) + next.delete(agentId) + return next + }) + yield* abandonFollowUps(agentId) }) - yield* abandonFollowUps(agentId) - }) - - const isRunning = (agentId: AgentId): Effect.Effect => - SynchronizedRef.get(running).pipe(Effect.map((current) => current.has(agentId))) - - const awaitRunning = (agentId: AgentId): Effect.Effect | null> => - SynchronizedRef.get(running).pipe( - Effect.flatMap((current) => { - const fiber = current.get(agentId)?.fiber ?? null - return fiber === null ? Effect.succeed(null) : Fiber.await(fiber) - }), - ) - const interruptRunning = (agentId: AgentId): Effect.Effect => - SynchronizedRef.get(running).pipe( - Effect.flatMap((current) => { - const fiber = current.get(agentId)?.fiber ?? null - return fiber === null ? Effect.succeed(false) : Fiber.interrupt(fiber).pipe(Effect.as(true)) - }), - ) + const isRunning = (agentId: AgentId): Effect.Effect => + SynchronizedRef.get(running).pipe(Effect.map((current) => current.has(agentId))) - const interruptAllRunning: Effect.Effect = SynchronizedRef.get(running).pipe( - Effect.flatMap((current) => - Effect.forEach([...current.keys()], (agentId) => interruptRunning(agentId), { discard: true }), - ), - ) - - const steer = (agentId: AgentId, text: string): Effect.Effect => - Effect.gen(function* () { - if (!(yield* isRunning(agentId))) return yield* notRunning(agentId) - yield* Ref.update(steering, (current) => - new Map(current).set(agentId, [...(current.get(agentId) ?? []), text]), + const awaitRunning = (agentId: AgentId): Effect.Effect | null> => + SynchronizedRef.get(running).pipe( + Effect.flatMap((current) => { + const fiber = current.get(agentId)?.fiber ?? null + return fiber === null ? Effect.succeed(null) : Fiber.await(fiber) + }), ) - }) - - const drainSteering = (agentId: AgentId): Effect.Effect> => - Ref.modify(steering, (current) => { - const queued = current.get(agentId) ?? [] - if (queued.length === 0) return [[], current] as const - - const drained = steeringMode === 'all' ? queued : queued.slice(0, 1) - const remaining = steeringMode === 'all' ? [] : queued.slice(1) - const next = new Map(current) - if (remaining.length === 0) next.delete(agentId) - else next.set(agentId, remaining) - return [drained, next] as const - }) - - const pushFollowUp = (agentId: AgentId, text: string): Effect.Effect => - Effect.gen(function* () { - if (!(yield* isRunning(agentId))) return yield* notRunning(agentId) - const consumed = yield* Deferred.make() - yield* Ref.update(followUps, (current) => - new Map(current).set(agentId, [...(current.get(agentId) ?? []), { text, consumed }]), + + const interruptRunning = (agentId: AgentId): Effect.Effect => + SynchronizedRef.get(running).pipe( + Effect.flatMap((current) => { + const fiber = current.get(agentId)?.fiber ?? null + return fiber === null ? Effect.succeed(false) : Fiber.interrupt(fiber).pipe(Effect.as(true)) + }), ) - return { consumed: Deferred.await(consumed) } - }) - const drainFollowUps = (agentId: AgentId): Effect.Effect> => - Effect.gen(function* () { - const queued = yield* Ref.modify(followUps, (current) => { - const entries = current.get(agentId) ?? [] + const interruptAllRunning: Effect.Effect = SynchronizedRef.get(running).pipe( + Effect.flatMap((current) => + Effect.forEach([...current.keys()], (agentId) => interruptRunning(agentId), { discard: true }), + ), + ) + + const steer = (agentId: AgentId, text: string): Effect.Effect => + Effect.gen(function* () { + if (!(yield* isRunning(agentId))) return yield* notRunning(agentId) + yield* Ref.update(steering, (current) => { + const queued = current.get(agentId) ?? [] + return new Map(current).set(agentId, [...queued, text]) + }) + }) + + const drainSteering = (agentId: AgentId): Effect.Effect> => + Ref.modify(steering, (current) => { + const queued = current.get(agentId) ?? [] + if (Arr.isReadonlyArrayEmpty(queued)) return [[], current] as const + + const drained = steeringMode === 'all' ? queued : queued.slice(0, 1) + const remaining = steeringMode === 'all' ? [] : queued.slice(1) const next = new Map(current) - next.delete(agentId) - return [entries, next] as const + if (Arr.isReadonlyArrayEmpty(remaining)) next.delete(agentId) + else next.set(agentId, remaining) + return [drained, next] as const }) - yield* Effect.forEach(queued, (entry) => Deferred.succeed(entry.consumed, true), { discard: true }) - return queued.map((entry) => entry.text) - }) - - const requestSessionStop = (reason: string): Effect.Effect => - Ref.update(stopReason, (current) => current ?? reason) - - return { - claimRunning, - setRunningFiber, - releaseRunning, - isRunning, - awaitRunning, - interruptRunning, - interruptAllRunning, - steer, - drainSteering, - pushFollowUp, - drainFollowUps, - requestSessionStop, - sessionStopReason: Ref.get(stopReason), - clearSessionStop: Ref.set(stopReason, null), - } - }) + + const pushFollowUp = ( + agentId: AgentId, + text: string, + ): Effect.Effect => + Effect.gen(function* () { + if (!(yield* isRunning(agentId))) return yield* notRunning(agentId) + const consumed = yield* Deferred.make() + yield* Ref.update(followUps, (current) => { + const queued = current.get(agentId) ?? [] + return new Map(current).set(agentId, [...queued, { text, consumed }]) + }) + return { consumed: Deferred.await(consumed) } + }) + + const drainFollowUps = (agentId: AgentId): Effect.Effect> => + Effect.gen(function* () { + const queued = yield* Ref.modify(followUps, (current) => { + const entries = current.get(agentId) ?? [] + const next = new Map(current) + next.delete(agentId) + return [entries, next] as const + }) + yield* Effect.forEach(queued, (entry) => Deferred.succeed(entry.consumed, true), { discard: true }) + return queued.map((entry) => entry.text) + }) + + const requestSessionStop = (reason: string): Effect.Effect => + Ref.update(stopReason, (current) => current ?? reason) + + return { + claimRunning, + setRunningFiber, + releaseRunning, + isRunning, + awaitRunning, + interruptRunning, + interruptAllRunning, + steer, + drainSteering, + pushFollowUp, + drainFollowUps, + requestSessionStop, + sessionStopReason: Ref.get(stopReason), + clearSessionStop: Ref.set(stopReason, null), + } + }), + ) diff --git a/packages/fold-core/src/Session/SessionService.ts b/packages/fold-core/src/Session/SessionService.ts index 71e1762..d479a2d 100644 --- a/packages/fold-core/src/Session/SessionService.ts +++ b/packages/fold-core/src/Session/SessionService.ts @@ -18,8 +18,8 @@ export type StartSessionInput = { readonly model: ActiveModel readonly promptCacheKey?: string /** One leading system block, an ordered set of blocks (one system message each), or null for none. */ - readonly systemPrompt: string | ReadonlyArray | null - readonly meta?: Readonly> + readonly systemPrompt: ReadonlyArray | null + readonly meta?: Readonly> /** Pre-minted session id (hosts that name the log location by session id - D5); defaults to fresh. */ readonly sessionId?: SessionId } @@ -34,7 +34,7 @@ export type StartedSession = { export type SwitchSessionModelInput = { readonly model: ActiveModel /** The agent's own prompt blocks, recomposed with the new model family's base prompt. */ - readonly systemPrompt: string | ReadonlyArray | null + readonly systemPrompt: ReadonlyArray | null readonly reason?: string | null } diff --git a/packages/fold-core/src/Skills/SkillSource.ts b/packages/fold-core/src/Skills/SkillSource.ts index 3316800..c6f445b 100644 --- a/packages/fold-core/src/Skills/SkillSource.ts +++ b/packages/fold-core/src/Skills/SkillSource.ts @@ -5,7 +5,7 @@ * disk loader. Public configuration goes through descriptors ({@link skillsFromData} / * {@link skillSource}) so no service or layer appears in caller signatures. */ -import { Data, Predicate, Context, Effect, Schema, type FileSystem } from 'effect' +import { Context, Effect, Schema } from 'effect' import { skillDescriptionProblem, skillNameProblem, type Skill, type SkillMeta } from './Schemas' @@ -78,23 +78,22 @@ export const skillSourceFromData = (skills: ReadonlyArray): Effect.Ef } }) -/** Skills configuration descriptor for {@link defineAgent}: data-backed or a custom source seam. */ -export type FoldSkills = - | { readonly _tag: 'fromData'; readonly skills: ReadonlyArray } - | { readonly _tag: 'source'; readonly make: Effect.Effect } - -const FoldSkills = Data.taggedEnum() +/** + * Skills configuration descriptor for `skillTool`: how to build the skill source once per session. `R` + * is the host services that takes (a filesystem for disk skills); the session requires them. + */ +export type FoldSkills = { + readonly make: Effect.Effect +} /** Configure an agent's skills from in-memory data (isomorphic; browser/worker hosts). */ -export const skillsFromData = (skills: ReadonlyArray): FoldSkills => FoldSkills.fromData({ skills }) +export const skillsFromData = (skills: ReadonlyArray): FoldSkills => ({ make: skillSourceFromData(skills) }) /** * Configure an agent's skills from a custom source implementation (the extension seam, mirroring - * `eventLogSource`): fold-agent exposes its disk loader through this. + * `eventLogSource`): fold-agent exposes its disk loader through this. A construction failure is an + * infrastructure defect. */ -export const skillSource = (make: Effect.Effect): FoldSkills => - FoldSkills.source({ make }) - -/** Lower a skills descriptor to its source implementation (composition-root internal). */ -export const skillSourceFor = (skills: FoldSkills): Effect.Effect => - Predicate.isTagged(skills, 'fromData') ? skillSourceFromData(skills.skills) : skills.make.pipe(Effect.orDie) +export const skillSource = (make: Effect.Effect): FoldSkills => ({ + make: Effect.orDie(make), +}) diff --git a/packages/fold-core/src/Skills/SkillTool.ts b/packages/fold-core/src/Skills/SkillTool.ts index b1cc247..3d24950 100644 --- a/packages/fold-core/src/Skills/SkillTool.ts +++ b/packages/fold-core/src/Skills/SkillTool.ts @@ -5,13 +5,13 @@ * New skills become discoverable through the tool's `refresh` flag, which diffs the live list against * the session-start snapshot and reports changes inside the tool result (cache-neutral). */ -import { Effect } from 'effect' +import { Array as Arr, Effect, Option } from 'effect' import { defineTool, type SessionToolContribution, type FoldTool } from '../Api/ToolDefinition' import { skillToolContract } from '../Tools/Contracts' import { ToolResultFailure, ToolResultText } from '../Tools/ToolResultContent' import type { SkillMeta } from './Schemas' -import { skillSourceFor, type SkillSourceService, type FoldSkills } from './SkillSource' +import type { SkillSourceService, FoldSkills } from './SkillSource' /** Escape text destined for the XML-ish skills listing (pi parity). */ const escapeXml = (text: string): string => @@ -36,7 +36,7 @@ const renderSkillList = (skills: ReadonlyArray): string => * roster is empty so no block is written. */ export const renderSkillsBlock = (skills: ReadonlyArray): string | null => { - if (skills.length === 0) return null + if (Arr.isReadonlyArrayEmpty(skills)) return null return ( 'The following skills provide specialized instructions and workflows for specific tasks.\n' + @@ -76,10 +76,10 @@ export type MakeSkillToolInput = { * loses its baseline - D20). Listing the same value on several agents shares that one scan; a fresh * `skillTool(...)` call gives an agent its own independent setup. */ -export const skillTool = (source: FoldSkills): FoldTool => ({ +export const skillTool = (source: FoldSkills): FoldTool => ({ name: skillToolContract.name, init: Effect.gen(function* () { - const resolved = yield* skillSourceFor(source) + const resolved = yield* source.make const snapshot = yield* resolved.list.pipe(Effect.orDie) const realized = yield* makeSkillTool({ source: resolved, snapshot }).init @@ -103,10 +103,9 @@ type SkillToolFailure = ToolResultFailure */ export const makeSkillTool = (input: MakeSkillToolInput): FoldTool => { const snapshotNames = input.snapshot.map((meta) => meta.name) - const rosterSuffix = - snapshotNames.length === 0 - ? ' No skills were available when this session started; call with refresh: true to re-scan.' - : ` Available skills: ${snapshotNames.join(', ')}.` + const rosterSuffix = Arr.isArrayEmpty(snapshotNames) + ? ' No skills were available when this session started; call with refresh: true to re-scan.' + : ` Available skills: ${snapshotNames.join(', ')}.` return defineTool({ ...skillToolContract, @@ -121,7 +120,9 @@ export const makeSkillTool = (input: MakeSkillToolInput): FoldTool => { Effect.fail( ToolResultFailure.make({ text: `Skill "${params.name}" not found. Available skills: ${ - error.availableSkills.length === 0 ? '(none)' : error.availableSkills.join(', ') + Arr.isReadonlyArrayEmpty(error.availableSkills) + ? '(none)' + : error.availableSkills.join(', ') }`, details: { availableSkills: error.availableSkills }, }), @@ -159,16 +160,20 @@ const refreshedRoster = (input: MakeSkillToolInput) => const added = current.filter((meta) => !snapshotNames.has(meta.name)) const removed = input.snapshot.filter((meta) => !currentNames.has(meta.name)) - if (added.length === 0 && removed.length === 0) { + if (Arr.isArrayEmpty(added) && Arr.isArrayEmpty(removed)) { return 'The skill list has not changed since this session started.' } const addedLines = added.map((meta) => `- ${meta.name}: ${meta.description}`) const removedLines = removed.map((meta) => `- ${meta.name}`) - const sections = [ - ...(addedLines.length === 0 ? [] : [`Skills added since session start:\n${addedLines.join('\n')}`]), - ...(removedLines.length === 0 ? [] : [`Skills removed since session start:\n${removedLines.join('\n')}`]), - ] + const section = (heading: string, lines: ReadonlyArray) => + Option.liftPredicate(lines, Arr.isReadonlyArrayNonEmpty).pipe( + Option.map((present) => `${heading}:\n${present.join('\n')}`), + ) + const sections = Arr.getSomes([ + section('Skills added since session start', addedLines), + section('Skills removed since session start', removedLines), + ]) return `\n${sections.join('\n\n')}\n` }) diff --git a/packages/fold-core/src/StopConditions/StopConditions.ts b/packages/fold-core/src/StopConditions/StopConditions.ts index fdf52af..5723b67 100644 --- a/packages/fold-core/src/StopConditions/StopConditions.ts +++ b/packages/fold-core/src/StopConditions/StopConditions.ts @@ -4,7 +4,8 @@ * 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 { Array as Arr, Context, Equal } from 'effect' +import type { Prompt } from 'effect/unstable/ai' /** Doom-loop detector configuration. Omitted means disabled. */ export type DoomLoopStopCondition = @@ -20,16 +21,16 @@ export type StopConditionConfig = { readonly doomLoop?: DoomLoopStopCondition } -/** Per-run doom-loop detector state. */ -export type DoomLoopState = { - readonly fingerprint: string | null - readonly count: number -} - /** A model-visible tool call, projected to only the fields relevant for doom-loop detection. */ export type ToolCallFingerprintInput = { readonly name: string - readonly params: unknown + readonly params: Prompt.ToolCallPart['params'] +} + +/** Per-run doom-loop detector state: the last observed batch and how many times it repeated in a row. */ +export type DoomLoopState = { + readonly batch: ReadonlyArray | null + readonly count: number } /** Result of observing a tool-call batch against the stop-condition policy. */ @@ -39,29 +40,7 @@ export type DoomLoopObservation = { } /** Empty per-run detector state. */ -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 - - return Object.fromEntries( - Object.entries(value) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, child]) => [key, normalizeForFingerprint(child)]), - ) -} - -const safeStableStringify = (value: unknown): string => { - try { - return JSON.stringify(normalizeForFingerprint(value)) ?? String(value) - } catch { - return String(value) - } -} - -const batchFingerprint = (toolCalls: ReadonlyArray): string => - toolCalls.map((call) => `${call.name}:${safeStableStringify(call.params)}`).join('\n') +export const initialDoomLoopState: DoomLoopState = { batch: null, count: 0 } /** Observe one tool-call batch and decide whether the configured doom-loop policy should stop the run. */ export const observeDoomLoop = ( @@ -69,13 +48,13 @@ export const observeDoomLoop = ( state: DoomLoopState, toolCalls: ReadonlyArray, ): DoomLoopObservation => { - if (config.doomLoop === undefined || !config.doomLoop.enabled || toolCalls.length === 0) { + if (config.doomLoop === undefined || !config.doomLoop.enabled || Arr.isReadonlyArrayEmpty(toolCalls)) { return { state: initialDoomLoopState, reason: null } } - const fingerprint = batchFingerprint(toolCalls) - const count = state.fingerprint === fingerprint ? state.count + 1 : 1 - const nextState = { fingerprint, count } + // Effect's structural equality ignores object key order, so reordered params still repeat. + const count = Equal.equals(state.batch, toolCalls) ? state.count + 1 : 1 + const nextState = { batch: toolCalls, count } const threshold = config.doomLoop.repeatedToolCalls return count >= threshold diff --git a/packages/fold-core/src/Subagents/AgentIdRef.ts b/packages/fold-core/src/Subagents/AgentIdRef.ts index 2586487..e8789bc 100644 --- a/packages/fold-core/src/Subagents/AgentIdRef.ts +++ b/packages/fold-core/src/Subagents/AgentIdRef.ts @@ -9,7 +9,7 @@ * cannot themselves be full ids (4-20 characters - a full cuid segment is 21-32). The 4-char floor * matches the CLI renderer's tag suffix, so an id read off a tag is always a valid reference. */ -import { Data, Predicate, Schema } from 'effect' +import { Array as Arr, Data, Predicate, Schema } from 'effect' import type { LogEntry } from '../EventLog/Schemas' import type { AgentId } from '../Ids' @@ -50,11 +50,12 @@ export type AgentIdRef = typeof AgentIdRef.Type export const shortAgentId = (agentId: AgentId): string => `agent_${cuidSegmentOf(agentId).slice(0, 8)}` /** Outcome of resolving one inbound agent-id reference against the session's known agent ids. */ -export type AgentIdRefResolution = - | { readonly _tag: 'resolved'; readonly agentId: AgentId } - | { readonly _tag: 'not-found' } +export type AgentIdRefResolution = Data.TaggedEnum<{ + resolved: { readonly agentId: AgentId } + 'not-found': {} /** Two or more known ids share the referenced prefix; `candidates` carries their SHORT ids. */ - | { readonly _tag: 'ambiguous'; readonly candidates: ReadonlyArray } + ambiguous: { readonly candidates: ReadonlyArray } +}> const AgentIdRefResolution = Data.taggedEnum() @@ -76,7 +77,7 @@ export const resolveAgentIdRef = (knownIds: Iterable, ref: string): Age const matches = ids.filter((id) => cuidSegmentOf(id).startsWith(wanted)) const [single] = matches if (matches.length === 1 && single !== undefined) return AgentIdRefResolution.resolved({ agentId: single }) - if (matches.length === 0) return AgentIdRefResolution['not-found']() + if (Arr.isArrayEmpty(matches)) return AgentIdRefResolution['not-found']() return AgentIdRefResolution.ambiguous({ candidates: matches.map(shortAgentId) }) } diff --git a/packages/fold-core/src/Subagents/AgentRegistry.ts b/packages/fold-core/src/Subagents/AgentRegistry.ts index acf12eb..e382dd8 100644 --- a/packages/fold-core/src/Subagents/AgentRegistry.ts +++ b/packages/fold-core/src/Subagents/AgentRegistry.ts @@ -9,6 +9,7 @@ */ import { Effect } from 'effect' +import { systemPromptBlocks } from '../Api/AgentDefinition' import type { FoldTool } from '../Api/ToolDefinition' import type { HookConfig } from '../HookRunner/Types' import type { ForkAgentDefinition, ForkAgentDefinitionId } from './ForkAgentDefinition' @@ -16,24 +17,26 @@ import type { SubagentDefinition, SubagentModelBinding } from './SubagentDefinit import { subagentCapabilitiesOf } from './SubagentTool' /** Agent definitions reachable from a configured root toolset. */ -export type CollectedAgentDefinitions = { - readonly subagents: ReadonlyArray - readonly forkAgents: ReadonlyArray +export type CollectedAgentDefinitions = { + readonly subagents: ReadonlyArray> + readonly forkAgents: ReadonlyArray> } /** One registered subagent type, resolved for runtime use. */ export type RegisteredAgentType = { readonly name: string readonly description: string - readonly systemPrompt: string | ReadonlyArray | null + /** Own leading prompt blocks, normalized from the definition (empty when it has none). */ + readonly systemPrompt: ReadonlyArray /** * The type's tools exactly as configured - its skillTool/subagentTool values included. The - * Subagents service realizes session-initialized values from their session-start contributions - * when it provisions this type's runtime; the roster and skill source are derivable from here. + * subagent operations realize session-initialized values from their session-start contributions + * when they provision this type's runtime; the roster and skill source are derivable from here. + * Every one has already been initialized, so their host services no longer matter here. */ - readonly tools: ReadonlyArray - /** Concrete model or profile role name; the Subagents engine resolves roles per dispatch/resume. */ - readonly model: SubagentModelBinding + readonly tools: ReadonlyArray> + /** Concrete model or profile role name; the subagent operations resolve roles per dispatch/resume. */ + readonly model: SubagentModelBinding readonly hooks: HookConfig } @@ -44,13 +47,13 @@ export type AgentRegistry = { /** Every registered type, in first-reached order from the root's tools. */ readonly entries: ReadonlyArray /** Resolve one host-configured fork toolset by its durable identifier. */ - readonly resolveForkAgentDefinition: (id: ForkAgentDefinitionId) => ForkAgentDefinition | null + readonly resolveForkAgentDefinition: (id: ForkAgentDefinitionId) => ForkAgentDefinition | null /** * Add definitions discovered at an explicit session switch boundary. Existing names remain bound * to their original session definition; duplicate names within the incoming graph have already * been rejected by {@link collectAgentDefinitions}. Returns the specialist definitions actually added. */ - readonly extend: (definitions: CollectedAgentDefinitions) => ReadonlyArray + readonly extend: (definitions: CollectedAgentDefinitions) => ReadonlyArray } /** @@ -60,16 +63,18 @@ export type AgentRegistry = { * two distinct definitions is a configuration bug and dies. The seen-set makes traversal total even * if a definition graph is ever made circular through post-construction mutation. */ -export const collectAgentDefinitions = (rootTools: ReadonlyArray): Effect.Effect => +export const collectAgentDefinitions = ( + rootTools: ReadonlyArray>, +): Effect.Effect> => Effect.suspend(() => { - const seenSubagents = new Set() - const subagentsByName = new Map() - const subagents: Array = [] - const seenForkAgents = new Set() - const forkAgentsById = new Map() - const forkAgents: Array = [] - - const visitDefinition = (definition: SubagentDefinition): Effect.Effect => { + const seenSubagents = new Set>() + const subagentsByName = new Map>() + const subagents: Array> = [] + const seenForkAgents = new Set>() + const forkAgentsById = new Map>() + const forkAgents: Array> = [] + + const visitDefinition = (definition: SubagentDefinition): Effect.Effect => { if (seenSubagents.has(definition)) return Effect.void seenSubagents.add(definition) @@ -90,7 +95,7 @@ export const collectAgentDefinitions = (rootTools: ReadonlyArray): Eff return visitTools(definition.tools ?? []) } - const visitForkAgent = (definition: ForkAgentDefinition): Effect.Effect => { + const visitForkAgent = (definition: ForkAgentDefinition): Effect.Effect => { if (seenForkAgents.has(definition)) return Effect.void seenForkAgents.add(definition) @@ -108,7 +113,7 @@ export const collectAgentDefinitions = (rootTools: ReadonlyArray): Eff return visitTools(definition.tools) } - const visitTools = (tools: ReadonlyArray): Effect.Effect => + const visitTools = (tools: ReadonlyArray>): Effect.Effect => Effect.forEach( tools, (tool) => { @@ -126,17 +131,17 @@ export const collectAgentDefinitions = (rootTools: ReadonlyArray): Eff }) /** Compatibility helper returning only registered specialist definitions. */ -export const collectSubagentDefinitions = ( - rootTools: ReadonlyArray, -): Effect.Effect> => +export const collectSubagentDefinitions = ( + rootTools: ReadonlyArray>, +): Effect.Effect>> => collectAgentDefinitions(rootTools).pipe(Effect.map((definitions) => definitions.subagents)) /** Build the flat registry over pre-collected (validated, deduped) definitions. */ -export const agentRegistryFromDefinitions = (definitions: CollectedAgentDefinitions): AgentRegistry => { - const registeredFrom = (definition: SubagentDefinition): RegisteredAgentType => ({ +export const agentRegistryFromDefinitions = (definitions: CollectedAgentDefinitions): AgentRegistry => { + const registeredFrom = (definition: SubagentDefinition): RegisteredAgentType => ({ name: definition.name, description: definition.description, - systemPrompt: definition.systemPrompt ?? null, + systemPrompt: systemPromptBlocks(definition.systemPrompt), tools: definition.tools ?? [], model: definition.model, hooks: definition.hooks ?? {}, diff --git a/packages/fold-core/src/Subagents/Errors.ts b/packages/fold-core/src/Subagents/Errors.ts index 5234a65..261810f 100644 --- a/packages/fold-core/src/Subagents/Errors.ts +++ b/packages/fold-core/src/Subagents/Errors.ts @@ -1,5 +1,5 @@ /** - * This file defines the expected failures of the Subagents service (D21). Each error models the + * This file defines the expected failures of the subagent operations (D21). Each error models the * dispatching model's recovery action - pick from the listed roster, check the id, or wait - and the * subagent tool encodes them into instructive `failureMode: "return"` payloads so the model * self-corrects instead of the run failing. diff --git a/packages/fold-core/src/Subagents/ForkAgentDefinition.ts b/packages/fold-core/src/Subagents/ForkAgentDefinition.ts index 09ff51e..564623b 100644 --- a/packages/fold-core/src/Subagents/ForkAgentDefinition.ts +++ b/packages/fold-core/src/Subagents/ForkAgentDefinition.ts @@ -1,16 +1,19 @@ import { Schema } from 'effect' -import type { FoldTool } from '../Api/ToolDefinition' +import { toolsNeedingAll, type FoldTool, type FoldToolServices } from '../Api/ToolDefinition' /** Stable identifier persisted on forks so their host-configured tools survive replay. */ export const ForkAgentDefinitionId = Schema.NonEmptyString.annotate({ identifier: 'ForkAgentDefinitionId' }) export type ForkAgentDefinitionId = typeof ForkAgentDefinitionId.Type /** Host-owned tool configuration for one fork generation. Other agent configuration remains inherited. */ -export type ForkAgentDefinition = { +export type ForkAgentDefinition = { readonly id: ForkAgentDefinitionId - readonly tools: ReadonlyArray + readonly tools: ReadonlyArray> } -/** Define the tools inherited by a configured fork. */ -export const defineForkAgent = (definition: ForkAgentDefinition): ForkAgentDefinition => definition +/** Define the tools inherited by a configured fork; it needs the union of their services. */ +export const defineForkAgent = >(definition: { + readonly id: ForkAgentDefinitionId + readonly tools: ReadonlyArray +}): ForkAgentDefinition> => ({ id: definition.id, tools: toolsNeedingAll(definition.tools) }) diff --git a/packages/fold-core/src/Subagents/Schemas.ts b/packages/fold-core/src/Subagents/Schemas.ts index 21e6265..1cee448 100644 --- a/packages/fold-core/src/Subagents/Schemas.ts +++ b/packages/fold-core/src/Subagents/Schemas.ts @@ -1,5 +1,5 @@ /** - * This file defines the schema-first domain types for the Subagents service (D21). Schemas are the + * This file defines the schema-first domain types for the subagent operations (D21). Schemas are the * source of truth and the TypeScript types are derived from them; SubagentOutcome deliberately reuses * the agent-finished vocabulary rather than inventing a parallel one, so a subagent result and its * durable terminal marker can never disagree about what outcomes exist. The subagent tool's flat wire @@ -23,7 +23,7 @@ export const SubagentOutcome = AgentFinishedOutcome export type SubagentOutcome = typeof SubagentOutcome.Type /** - * Result of one subagent dispatch, fork, or resume, as returned by the Subagents service. A subagent + * Result of one subagent dispatch, fork, or resume, as returned by the subagent operations. A subagent * that errored, died, or was interrupted is still a result (its agent-finished marker is in the log); * the dispatching model sees it rendered and may resume the subagent by id. */ diff --git a/packages/fold-core/src/Subagents/SessionAgents.ts b/packages/fold-core/src/Subagents/SessionAgents.ts new file mode 100644 index 0000000..df9fafb --- /dev/null +++ b/packages/fold-core/src/Subagents/SessionAgents.ts @@ -0,0 +1,46 @@ +/** + * This file defines SessionAgents - how one session builds its agents: the flat registry of + * dispatchable types, the tool contributions every agent's tools are realized against, the root + * agent's current configuration, and the session-wide auto-compaction policy. `startSession` provides + * it with the other session services; agent provisioning and the subagent operations read it. + */ +import { Context } from 'effect' +import type { Effect } from 'effect' + +import type { FoldModel } from '../Api/ModelDescriptor' +import type { FoldTool, RealizedFoldTool } from '../Api/ToolDefinition' +import type { AutoCompactConfig } from '../Compaction/CompactionService' +import type { HookConfig } from '../HookRunner/Types' +import type { SkillSourceService } from '../Skills/SkillSource' +import type { AgentRegistry } from './AgentRegistry' + +/** One agent's tools realized against the session-start contributions (once-per-value inits). */ +export type RealizedAgentTools = { + readonly tools: ReadonlyArray + /** Prompt blocks contributed by session-initialized tools, in tools-array order. */ + readonly promptBlocks: ReadonlyArray + /** The skill source contributed by this agent's skillTool, for dispatch-time skill preload. */ + readonly skillSource: SkillSourceService | null +} + +/** The root agent's current configuration; model switches move it (read fresh per dispatch). */ +export type RootAgentSnapshot = { + readonly model: FoldModel + readonly promptCacheKey: string | null + /** The root's tools as configured (system-tool values included). */ + readonly tools: ReadonlyArray> + readonly hooks: HookConfig + /** The root's own leading blocks, WITHOUT tool-contributed blocks (those come from realization). */ + readonly systemPrompt: ReadonlyArray +} + +export type SessionAgentsService = { + readonly registry: AgentRegistry + /** Realize one agent's configured tools via the session-start contribution map (§2.5). */ + readonly realizeTools: (tools: ReadonlyArray>) => RealizedAgentTools + readonly currentRoot: Effect.Effect + /** Shared by every provisioned runtime - root and subagent - checking its own projection (D11). */ + readonly autoCompact: AutoCompactConfig | undefined +} + +export class SessionAgents extends Context.Service()('fold/SessionAgents') {} diff --git a/packages/fold-core/src/Subagents/SubagentDefinition.ts b/packages/fold-core/src/Subagents/SubagentDefinition.ts index 27b0103..2d0b83f 100644 --- a/packages/fold-core/src/Subagents/SubagentDefinition.ts +++ b/packages/fold-core/src/Subagents/SubagentDefinition.ts @@ -1,3 +1,5 @@ +import type { Effect, Scope } from 'effect' + /** * This file defines the subagent type descriptor for the public API (D21, round-five shape). A subagent * definition is the full agent configuration minus the log: its own model/provider, hooks, prompt, and @@ -6,8 +8,9 @@ * but the session flattens everything reachable into one flat registry - nesting scopes * *dispatchability*, never state; every dispatched subagent lives in one id space on one log. */ +import type { SystemPromptInput } from '../Api/AgentDefinition' import type { FoldModel } from '../Api/ModelDescriptor' -import type { FoldTool } from '../Api/ToolDefinition' +import { toolsNeedingAll, type FoldTool, type FoldToolServices } from '../Api/ToolDefinition' import type { HookConfig } from '../HookRunner/Types' import type { ProfileRole } from '../Session/Profiles' @@ -18,10 +21,13 @@ import type { ProfileRole } from '../Session/Profiles' * must receive a `profiles` map covering every role the roster names (`orchestrator` falls back to * `smart`, D25). */ -export type SubagentModelBinding = FoldModel | ProfileRole +export type SubagentModelBinding = FoldModel | ProfileRole -/** Configuration for one subagent type, as plain data. Built with {@link defineSubagent}. */ -export type SubagentDefinition = { +/** + * Configuration for one subagent type, as plain data. Built with {@link defineSubagent}. `R` is every + * host service its tools need. + */ +export type SubagentDefinition = { /** Registry name; what the dispatching model passes as `agent`. Unique per session. */ readonly name: string /** Feeds the dispatching agent's roster listing - what this type is for, model-facing. */ @@ -30,13 +36,13 @@ export type SubagentDefinition = { * This type's own leading prompt blocks, appended after the family base prompt - the same * semantics as `defineAgent.systemPrompt` (append/compose; pi precedent, ruled 2026-07-07). */ - readonly systemPrompt?: string | ReadonlyArray + readonly systemPrompt?: SystemPromptInput /** * Tools installed for this type: platform tools from `defineTool`, plus `skillTool(...)` for its * skill setup and `subagentTool([...])` for the types IT may dispatch (no subagentTool means it * cannot delegate at all). Sharing a system-tool value with another agent shares one setup. */ - readonly tools?: ReadonlyArray + readonly tools?: ReadonlyArray> /** This type's own hook chains (D16), independent of the root's and every other type's. */ readonly hooks?: HookConfig /** @@ -45,8 +51,45 @@ export type SubagentDefinition = { * (`'smart' | 'fast' | 'orchestrator'`) resolved through the session's profiles map at each * dispatch/resume, so one `setProfile` swap moves every role-bound type together. */ - readonly model: SubagentModelBinding + readonly model: SubagentModelBinding +} + +/** {@link SubagentDefinition} as written by a caller: its tools keep their own types. */ +export type SubagentDefinitionInput, RM> = Omit & { + readonly model: SubagentModelBinding + readonly tools?: ReadonlyArray +} + +/** The host services a model binding needs; a profile role needs none of its own. */ +type ModelBindingServices = B extends { readonly make: Effect.Effect } + ? Exclude + : never + +/** The host services a subagent definition, or a union of them, needs: its tools' and its model's. */ +export type SubagentDefinitionServices = D extends { + readonly tools?: ReadonlyArray + readonly model: infer B +} + ? FoldToolServices | ModelBindingServices + : never + +/** + * Define one subagent type; it needs the union of its tools' services. The single place type-config + * validation lands later. + */ +export const defineSubagent = = FoldTool, RM = never>( + definition: SubagentDefinitionInput, +): SubagentDefinition | RM> => { + const { tools, ...rest } = definition + return tools === undefined ? rest : { ...rest, tools: toolsNeedingAll(tools) } } -/** Define one subagent type. Identity today; the single place type-config validation lands later. */ -export const defineSubagent = (definition: SubagentDefinition): SubagentDefinition => definition +/** View a list of subagent definitions as definitions needing the union of their services. */ +export const subagentsNeedingAll = >( + definitions: ReadonlyArray, +): ReadonlyArray>> => + // SAFETY: each element is a SubagentDefinition whose R is one member of SubagentDefinitionServices, + // and SubagentDefinition is covariant in R. TypeScript cannot see through the conditional type for a + // generic D. + // oxlint-disable-next-line typescript/consistent-type-assertions, automation/no-type-assertion + definitions as ReadonlyArray>> diff --git a/packages/fold-core/src/Subagents/SubagentEngine.ts b/packages/fold-core/src/Subagents/SubagentEngine.ts new file mode 100644 index 0000000..4225ec5 --- /dev/null +++ b/packages/fold-core/src/Subagents/SubagentEngine.ts @@ -0,0 +1,852 @@ +/** + * This file implements the subagent operations (D21) - dispatch, fork, resume, and continue - the deep + * module behind the subagent tool. It owns the whole choreography: roster guards, id minting, + * per-dispatch runtime provisioning (own model, tools, hooks over the shared session services), the + * durable agent_started/user-message writes via AgentRuntime, the subagent run fiber (forked into the + * dispatch call's scope so interrupting the dispatcher structurally tears the subagent down), exit + * folding (a subagent that errors, dies, or is interrupted is a RESULT, never a failure - + * capture-then-narrow like the tool-settlement seam), the uninterruptible exit finalizers that keep the + * log honest (interrupt/error markers + the InterruptNote naming the subagent id and turn count), skill + * preload through the dispatcher's own skillTool source, and resume - including the D17 model transition + * when the configured binding changed since the subagent last ran. Registry entries may bind their model + * by profile role name (profiles slice): role bindings resolve through the session's Profiles map at + * every consumption point - dispatch, and the origin snapshot feeding fork/resume/continue - so a + * `setProfile` swap binds on the very next run and the existing transition diff sees only concrete + * models. + * + * The operations are plain Effects that take the session's services from their surroundings. Dispatch, + * fork, and resume run inside a tool call (the executing call's identity and interrupt note arrive as the + * per-call CurrentAgent / CurrentToolCall / InterruptNote services), and the subagents they launch run + * tools that may call them again. Continue runs from the session handle, with no tool call. + */ +import { Array as Arr, Data, Match, Predicate, Cause, Effect, Exit, Fiber, Ref, Schema, Stream, Struct } from 'effect' +import { Prompt } from 'effect/unstable/ai' + +import type { FoldModel } from '../Api/ModelDescriptor' +import { provisionAgentRuntime } from '../Api/Provisioning' +import type { RealizedFoldTool, FoldTool } from '../Api/ToolDefinition' +import { EventLog } from '../EventLog/EventLogService' +import { encodedContentText } from '../EventLog/MessageContent' +import { + ActiveModel, + LogEntryInputs, + type AgentFinishedLogEntry, + type AgentFork, + type AgentLaunchMode, + type AgentStartedLogEntry, + type AssistantMessageLogEntry, + type LogEntry, + type LogEntryInput, + type LogSeq, +} from '../EventLog/Schemas' +import type { HookConfig } from '../HookRunner/Types' +import { Ids, type AgentId, type ToolCallId } from '../Ids' +import { runtimeForAgent } from '../Projection/Projection' +import { isProfileRole, Profiles } from '../Session/Profiles' +import { SessionControls } from '../Session/SessionControls' +import { SkillNotFoundError } from '../Skills/SkillSource' +import { renderSkillContent } from '../Skills/SkillTool' +import { modelVisibleErrorDetailsFromCause } from '../ToolRuntime/ModelVisibleErrors' +import { + CurrentAgent, + CurrentToolCall, + InterruptNote, + type InterruptNoteService, +} from '../ToolRuntime/ToolContextServices' +import { agentIdsFromEntries, resolveAgentIdRef, shortAgentId } from './AgentIdRef' +import type { AgentRegistry, RegisteredAgentType } from './AgentRegistry' +import { SubagentBusyError, SubagentNotFoundError, SubagentTypeNotInRosterError } from './Errors' +import type { ForkSubagentInput, SubagentResult } from './Schemas' +import { SessionAgents, type RealizedAgentTools } from './SessionAgents' +import type { SubagentModelBinding } from './SubagentDefinition' + +const encodeUserMessage = Schema.encodeUnknownSync(Prompt.UserMessage) + +/** Input for dispatching one fresh subagent of a registered type. */ +export type DispatchSubagentInput = { + /** The requested agent type name. */ + readonly agent: string + readonly prompt: string + /** Skill to preload after the prompt, resolved through the dispatcher's own skillTool source. */ + readonly skill: string | null + /** The executing subagentTool value's roster (from its closure) - the dispatch authority (§1a). */ + readonly allowedAgents: ReadonlyArray +} + +/** + * Input for resuming a previously dispatched subagent by reference: the full branded id, or a unique + * short prefix like `agent_ab12cd34` (the form rendered in subagent results). The reference resolves + * against the log's `agent_started` rows - exact match first, then unique prefix. + */ +export type ResumeSubagentInput = { + readonly agentId: AgentId | string + readonly prompt: string + readonly skill: string | null +} + +/** Input for continuing a finished subagent directly from the SDK (D8 `send(message, { agentId })`). */ +export type ContinueSubagentInput = { + readonly agentId: AgentId + readonly prompt: string +} + +/** Where an agent's configuration comes from: a registered type, or the root agent. */ +type OriginatingConfig = Data.TaggedEnum<{ entry: { readonly entry: RegisteredAgentType }; root: {} }> +const OriginatingConfig = Data.taggedEnum() + +type AgentConfigurationSnapshot = { + readonly model: FoldModel + readonly promptCacheKey: string | null + readonly tools: ReadonlyArray> + readonly hooks: HookConfig + readonly systemPrompt: ReadonlyArray +} + +/** Everything one subagent launch/resume needs, resolved before the run fiber forks. */ +type LaunchSubagentParams = { + readonly subagentId: AgentId + /** Registry type name recorded on agent_started; null for forks. */ + readonly agentTypeName: string | null + /** Human-readable label for interrupt notes ("researcher", "fork of agent_x..."). */ + readonly agentLabel: string + readonly parentAgentId: AgentId + readonly toolCallId: ToolCallId + readonly mode: AgentLaunchMode + readonly fork: AgentFork | null + readonly model: FoldModel + readonly promptCacheKey: string | null + readonly tools: ReadonlyArray + readonly hooks: HookConfig + /** Leading blocks for fresh starts (entry blocks + tool-contributed blocks); null for forks/resumes. */ + readonly systemPrompt: ReadonlyArray | null + /** The preloaded skill name recorded on agent_started (fresh starts only). */ + readonly skillParam: string | null + readonly messages: Arr.NonEmptyReadonlyArray + readonly launch: SubagentLaunch + readonly interruptNote: InterruptNoteService +} + +/** Fresh dispatch/fork writes agent_started; resume re-enters, optionally after a model transition. */ +type SubagentLaunch = Data.TaggedEnum<{ + start: {} + resume: { readonly modelTransition: { readonly systemPrompt: ReadonlyArray | null } | null } +}> +const SubagentLaunch = Data.taggedEnum() + +/** Leading blocks for one agent: its own blocks, then its tools' contributed blocks. */ +const leadingBlocksFor = ( + systemPrompt: ReadonlyArray, + realized: RealizedAgentTools, +): ReadonlyArray | null => { + const blocks = [...systemPrompt, ...realized.promptBlocks] + return Arr.isArrayEmpty(blocks) ? null : blocks +} + +/** + * The note embedded in the dispatcher's synthetic result if this call is interrupted. Set with zero + * turns the moment the subagent id exists, then kept current by the turn watcher as the subagent's + * assistant messages land - so whenever the interruption strikes, the note already reflects the last + * completed turn (no dependence on teardown ordering). + */ +const interruptedSubagentNote = (agentLabel: string, subagentId: AgentId, turnsThisRun: number): string => + `Subagent ${agentLabel} (agent_id: ${shortAgentId(subagentId)}) was interrupted after ${turnsThisRun} ` + + `turn${turnsThisRun === 1 ? '' : 's'}, before completing. Its progress up to the interruption is saved. ` + + `Pass agent_id: ${shortAgentId(subagentId)} to the subagent tool to resume it.` + +const findAgentStarted = (entries: ReadonlyArray, agentId: AgentId): AgentStartedLogEntry | null => + entries.find( + (entry): entry is AgentStartedLogEntry => + Predicate.isTagged(entry, 'agent_started') && entry.agentId === agentId, + ) ?? null + +/** Count assistant turns for one subagent: this dispatch/resume (by toolCallId) and lifetime total. */ +const countAssistantTurns = (entries: ReadonlyArray, agentId: AgentId, toolCallId: ToolCallId) => { + const own = entries.filter( + (entry): entry is AssistantMessageLogEntry => + Predicate.isTagged(entry, 'assistant-message') && entry.agentId === agentId, + ) + + return { + thisRun: own.filter((entry) => entry.toolCallId === toolCallId).length, + total: own.length, + } +} + +/** The subagent's final assistant text for this run, or null when it produced none. */ +const lastAssistantTextForRun = ( + entries: ReadonlyArray, + agentId: AgentId, + toolCallId: ToolCallId, +): string | null => { + const lastAssistant = entries.findLast( + (entry): entry is AssistantMessageLogEntry => + Predicate.isTagged(entry, 'assistant-message') && + entry.agentId === agentId && + entry.toolCallId === toolCallId, + ) + if (lastAssistant === undefined) return null + + const text = encodedContentText(lastAssistant.message.content) + return text.length > 0 ? text : null +} + +/** Structural model-binding comparison deciding whether a resume needs a D17 transition. */ +const activeModelsEquivalent = Schema.toEquivalence(Schema.NullOr(ActiveModel)) +const activeModelsDiffer = (left: ActiveModel | null, right: ActiveModel | null): boolean => + !activeModelsEquivalent(left, right) + +const CHILD_CACHE_SUFFIX_LENGTH = 28 +const MAX_PROMPT_CACHE_KEY_LENGTH = 64 + +/** Derive a bounded, deterministic child cache-affinity key from its parent and durable id. */ +export const deriveChildPromptCacheKey = (parentKey: string, agentId: AgentId): Effect.Effect => { + const suffix = `:${agentId.slice(-CHILD_CACHE_SUFFIX_LENGTH)}` + const parentLength = MAX_PROMPT_CACHE_KEY_LENGTH - suffix.length + return Effect.succeed(`${parentKey.slice(0, parentLength)}${suffix}`) +} + +const appendToEventLog = (input: LogEntryInput): Effect.Effect => + EventLog.use((eventLog) => eventLog.append(input)).pipe(Effect.orDie) + +const collectEntries: Effect.Effect, never, EventLog> = EventLog.use((eventLog) => + Stream.runCollect(eventLog.entries()), +).pipe( + Effect.orDie, + Effect.map((entries): ReadonlyArray => entries), +) + +/** Resolve one registry entry's model binding: a role name reads the current profiles map. */ +const resolveModelBinding = ( + binding: SubagentModelBinding, +): Effect.Effect, never, Profiles> => + isProfileRole(binding) ? Profiles.use((profiles) => profiles.resolve(binding)) : Effect.succeed(binding) + +/** Atomically claim a subagent id in the session registry; already-running means Busy. */ +const claimRunningSubagent = (subagentId: AgentId): Effect.Effect => + SessionControls.use((controls) => controls.claimRunning(subagentId)).pipe( + Effect.flatMap((claimed) => + claimed ? Effect.void : Effect.fail(new SubagentBusyError({ agentId: subagentId })), + ), + ) + +/** Claim an agent id for the enclosing scope: the session's running registry releases it on close. */ +const holdRunningSubagent = (subagentId: AgentId) => + Effect.acquireRelease(claimRunningSubagent(subagentId), () => + SessionControls.use((controls) => controls.releaseRunning(subagentId)), + ) + +/** + * Resolve which configuration an agent runs under: its registry entry (by agent_started.agentType), + * the fork-source's configuration for forks, or the root's current configuration. + */ +const originatingConfigForAgent = ( + registry: AgentRegistry, + entries: ReadonlyArray, + agentId: AgentId, + seen: ReadonlySet = new Set(), +): OriginatingConfig | null => { + if (seen.has(agentId)) return null + const started = findAgentStarted(entries, agentId) + if (started === null) return null + + if (started.agentType !== null) { + const entry = registry.resolveAgentType(started.agentType) + return entry === null ? null : OriginatingConfig.entry({ entry }) + } + if (started.mode === 'fork' && started.fork !== null) { + return originatingConfigForAgent(registry, entries, started.fork.fromAgentId, new Set([...seen, agentId])) + } + if (started.parentAgentId === null) return OriginatingConfig.root() + + return null +} + +/** + * The (model, tools, hooks, prompt) snapshot behind one originating configuration. Entry origins + * resolve their model binding against the CURRENT profiles map, so fork/resume/continue of a + * role-bound type all see the live binding (a fork clones the caller's binding by definition). + */ +const agentSnapshotForOrigin = ( + origin: OriginatingConfig, +): Effect.Effect => + Match.valueTags(origin, { + root: () => SessionAgents.use((agents) => agents.currentRoot), + entry: ({ entry }) => + resolveModelBinding(entry.model).pipe( + Effect.map((model) => ({ + model, + promptCacheKey: null, + tools: entry.tools, + hooks: entry.hooks, + systemPrompt: entry.systemPrompt, + })), + ), + }) + +/** Reconstruct one agent's effective configuration, including a persisted fork tool override. */ +const agentSnapshotForAgent = Effect.fnUntraced(function* (entries: ReadonlyArray, agentId: AgentId) { + const { registry } = yield* SessionAgents + const origin = originatingConfigForAgent(registry, entries, agentId) + if (origin === null) return null + + const snapshot = yield* agentSnapshotForOrigin(origin) + const started = findAgentStarted(entries, agentId) + if (started === null) return snapshot + const withCacheKey = { ...snapshot, promptCacheKey: started.promptCacheKey ?? snapshot.promptCacheKey } + const definitionId = started.fork?.definitionId + if (definitionId === undefined) return withCacheKey + + const definition = registry.resolveForkAgentDefinition(definitionId) + if (definition === null) { + return yield* Effect.die( + new Error(`fork agent definition "${definitionId}" required by ${agentId} is not registered`), + ) + } + + return { ...withCacheKey, tools: definition.tools } +}) + +/** Every ancestor of an agent (parent chain from agent_started rows), for the resume self-guard. */ +const ancestorAgentIds = (entries: ReadonlyArray, agentId: AgentId): ReadonlySet => { + const ancestors = new Set() + let current: AgentId | null = agentId + + while (current !== null && !ancestors.has(current)) { + const started = findAgentStarted(entries, current) + if (started === null) break + current = started.parentAgentId + if (current !== null) ancestors.add(current) + } + + return ancestors +} + +/** Load and render a preloaded skill through the dispatcher's own skillTool source (§2.3). */ +const preloadedSkillMessage = Effect.fnUntraced(function* ( + dispatcherSnapshot: AgentConfigurationSnapshot, + skillName: string, +) { + const agents = yield* SessionAgents + const realized = agents.realizeTools(dispatcherSnapshot.tools) + + if (realized.skillSource === null) { + return yield* new SkillNotFoundError({ name: skillName, availableSkills: [] }) + } + + const skill = yield* realized.skillSource + .load(skillName) + .pipe(Effect.catchTag('SkillSourceError', (error) => Effect.die(error))) + + return renderSkillContent(skill) +}) + +/** The durable rows marking one interrupted run: the interruption notice, then the terminal marker. */ +const writeInterruptedMarkers = Effect.fnUntraced(function* (envelope: { + readonly agentId: AgentId + readonly parentAgentId: AgentId | null + readonly toolCallId: ToolCallId | null +}) { + const ids = yield* Ids + yield* appendToEventLog( + LogEntryInputs['user-message']({ + ...envelope, + messageId: yield* ids.makeMessageId, + message: encodeUserMessage( + Prompt.userMessage({ + content: [ + Prompt.textPart({ + text: 'You were interrupted by the user before completing this work.', + }), + ], + }), + ), + }), + ) + yield* appendToEventLog( + LogEntryInputs['agent-finished']({ + ...envelope, + outcome: 'interrupted', + resultText: null, + reason: 'interrupted by the user', + }), + ) +}) + +/** Uninterruptible exit finalizer: durable interrupt/error markers + the turn-counting note. */ +const writeSubagentExitMarkers = + (params: LaunchSubagentParams) => + (exit: Exit.Exit): Effect.Effect => + Effect.gen(function* () { + if (Exit.isSuccess(exit)) return + + const entries = yield* collectEntries + const finishedThisRun = entries.some( + (entry) => + Predicate.isTagged(entry, 'agent-finished') && + entry.agentId === params.subagentId && + entry.toolCallId === params.toolCallId, + ) + if (finishedThisRun) return + + const envelope = { + agentId: params.subagentId, + parentAgentId: params.parentAgentId, + toolCallId: params.toolCallId, + } + if (Cause.hasInterrupts(exit.cause)) { + yield* writeInterruptedMarkers(envelope) + } else { + yield* appendToEventLog( + LogEntryInputs['agent-finished']({ + ...envelope, + outcome: 'error', + resultText: null, + reason: modelVisibleErrorDetailsFromCause(exit.cause), + }), + ) + } + }) + +/** Fold a finished (or torn-down) subagent run into its result. Errors and interrupts are results. */ +const subagentResultFromExit = ( + params: LaunchSubagentParams, + exit: Exit.Exit, + entries: ReadonlyArray, +): SubagentResult => { + const turns = countAssistantTurns(entries, params.subagentId, params.toolCallId) + + if (Exit.isSuccess(exit)) { + const finished = exit.value + return { + agentId: params.subagentId, + outcome: finished.outcome, + resultText: finished.resultText, + errorMessage: finished.outcome === 'error' ? finished.reason : null, + turnsThisRun: turns.thisRun, + turnsTotal: turns.total, + } + } + + const interrupted = Cause.hasInterrupts(exit.cause) + return { + agentId: params.subagentId, + outcome: interrupted ? 'interrupted' : 'error', + resultText: lastAssistantTextForRun(entries, params.subagentId, params.toolCallId), + errorMessage: interrupted ? null : modelVisibleErrorDetailsFromCause(exit.cause), + turnsThisRun: turns.thisRun, + turnsTotal: turns.total, + } +} + +/** + * Keep the interrupt note current as the subagent works: each completed turn (one assistant-message row + * under this dispatch's tool call) bumps the count, so whenever an interruption strikes, the + * dispatcher's synthetic result already carries an accurate "interrupted after N turns" note - no + * dependence on teardown ordering. + */ +const watchSubagentTurns = Effect.fnUntraced(function* (params: LaunchSubagentParams) { + const eventLog = yield* EventLog + const turnsSeen = yield* Ref.make(0) + + yield* eventLog.subscribe().pipe( + Stream.filter( + (entry) => + Predicate.isTagged(entry, 'assistant-message') && + entry.agentId === params.subagentId && + entry.toolCallId === params.toolCallId, + ), + Stream.tap(() => + Ref.updateAndGet(turnsSeen, (count) => count + 1).pipe( + Effect.flatMap((turns) => + params.interruptNote.set(interruptedSubagentNote(params.agentLabel, params.subagentId, turns)), + ), + ), + ), + Stream.runDrain, + Effect.orDie, + ) +}) + +/** + * Run one subagent launch/resume to its result: claim the id, provision this agent's runtime into the + * dispatch scope, write the start (or resume transition), fork the run fiber, await its Exit, and fold. + * The dispatch scope closing (dispatcher interrupted) interrupts the run fiber; the onExit finalizer + * then writes the durable markers before the interruption propagates. + */ +const runSubagentToResult = (params: LaunchSubagentParams) => + Effect.scoped( + Effect.gen(function* () { + const controls = yield* SessionControls + yield* holdRunningSubagent(params.subagentId) + + const agentRuntimeForSubagent = yield* provisionAgentRuntime({ + model: params.model, + tools: params.tools, + hooks: params.hooks, + }) + + if (SubagentLaunch.$is('start')(params.launch)) { + yield* agentRuntimeForSubagent.start({ + agentId: params.subagentId, + parentAgentId: params.parentAgentId, + toolCallId: params.toolCallId, + mode: params.mode, + fork: params.fork, + skill: params.skillParam, + agentType: params.agentTypeName, + model: params.model.activeModel, + promptCacheKey: params.promptCacheKey, + systemPrompt: params.systemPrompt, + }) + } else if (params.launch.modelTransition !== null) { + yield* agentRuntimeForSubagent.switchModel({ + agentId: params.subagentId, + parentAgentId: params.parentAgentId, + toolCallId: params.toolCallId, + model: params.model.activeModel, + systemPrompt: params.launch.modelTransition.systemPrompt, + reason: 'resume: the configured model for this agent changed since it last ran', + }) + } + + const subagentRunFiber = yield* Effect.forkScoped( + agentRuntimeForSubagent + .run({ + agentId: params.subagentId, + parentAgentId: params.parentAgentId, + toolCallId: params.toolCallId, + messages: params.messages, + }) + .pipe(Effect.onExit(writeSubagentExitMarkers(params))), + ) + // Closing the dispatch scope interrupts the subagent and awaits its exit finalizers, so the + // durable interrupt/error markers land before the dispatch call returns. + yield* Effect.addFinalizer(() => Fiber.interrupt(subagentRunFiber)) + yield* Effect.forkScoped(watchSubagentTurns(params)) + yield* controls.setRunningFiber(params.subagentId, subagentRunFiber) + + const exit = yield* Fiber.await(subagentRunFiber) + const entries = yield* collectEntries + + return subagentResultFromExit(params, exit, entries) + }), + ) + +/** A fresh id can never be running; a Busy claim failure on one is an engine bug. */ +const dieOnBusy = (error: SubagentBusyError): Effect.Effect => + Effect.die(new Error(`freshly minted subagent id ${error.agentId} was already running`)) + +/** + * Uninterruptible exit markers for a direct SDK continuation (D8): same honesty as dispatch markers, + * but with the null envelope (no dispatching tool call) and a seq baseline guard - a continuation reuses + * the null toolCallId across runs, so "did this run already write its terminal marker" is answered by + * seq position, not by call identity. + */ +const writeDirectExitMarkers = + (agentId: AgentId, baselineSeq: LogSeq) => + (exit: Exit.Exit): Effect.Effect => + Effect.gen(function* () { + if (Exit.isSuccess(exit)) return + + const entries = yield* collectEntries + const finishedThisRun = entries.some( + (entry) => + Predicate.isTagged(entry, 'agent-finished') && entry.agentId === agentId && entry.seq > baselineSeq, + ) + if (finishedThisRun) return + + const envelope = { agentId, parentAgentId: null, toolCallId: null } + if (Cause.hasInterrupts(exit.cause)) { + yield* writeInterruptedMarkers(envelope) + } else { + yield* appendToEventLog( + LogEntryInputs['agent-finished']({ + ...envelope, + outcome: 'error', + resultText: null, + reason: modelVisibleErrorDetailsFromCause(exit.cause), + }), + ) + } + }) + +/** + * Continue a finished subagent from the SDK, with no dispatching tool call (D8): the prompt appends as a + * user message with a null toolCallId/parentAgentId envelope, the agent's loop restarts under its own + * configuration (running the D17 model transition first when its binding changed), and the caller gets + * the durable terminal entry - interrupts and defects included, via their durable markers. This is the + * session handle's `send(text, { agentId })`. + */ +export const continueSubagent = Effect.fn('fold.subagents.continue')(function* (input: ContinueSubagentInput) { + const agents = yield* SessionAgents + const controls = yield* SessionControls + const entries = yield* collectEntries + const started = findAgentStarted(entries, input.agentId) + if (started === null) { + return yield* new SubagentNotFoundError({ requested: input.agentId }) + } + + const snapshot = yield* agentSnapshotForAgent(entries, input.agentId) + if (snapshot === null) { + return yield* Effect.die(new Error(`continuation target ${input.agentId} has no resolvable configuration`)) + } + const realized = agents.realizeTools(snapshot.tools) + + const projected = runtimeForAgent(entries, input.agentId) + const modelTransition = activeModelsDiffer(snapshot.model.activeModel, projected.activeModel) + ? { systemPrompt: leadingBlocksFor(snapshot.systemPrompt, realized) } + : null + + const lastEntry = entries.at(-1) + if (lastEntry === undefined) { + return yield* Effect.die(new Error('continuation requested on an empty session log')) + } + const baselineSeq = lastEntry.seq + + return yield* Effect.scoped( + Effect.gen(function* () { + yield* holdRunningSubagent(input.agentId) + + const agentRuntimeForSubagent = yield* provisionAgentRuntime({ + model: snapshot.model, + tools: realized.tools, + hooks: snapshot.hooks, + }) + + if (modelTransition !== null) { + yield* agentRuntimeForSubagent.switchModel({ + agentId: input.agentId, + parentAgentId: null, + toolCallId: null, + model: snapshot.model.activeModel, + systemPrompt: modelTransition.systemPrompt, + reason: 'continue: the configured model for this agent changed since it last ran', + }) + } + + const runFiber = yield* Effect.forkScoped( + agentRuntimeForSubagent + .run({ + agentId: input.agentId, + parentAgentId: null, + toolCallId: null, + messages: [input.prompt], + }) + .pipe(Effect.onExit(writeDirectExitMarkers(input.agentId, baselineSeq))), + ) + yield* Effect.addFinalizer(() => Fiber.interrupt(runFiber)) + yield* controls.setRunningFiber(input.agentId, runFiber) + + const exit = yield* Fiber.await(runFiber) + if (Exit.isSuccess(exit)) return exit.value + + // Interrupted or dead: the exit markers above wrote the terminal entry - return it. + const after = yield* collectEntries + const finished = after.findLast( + (entry): entry is AgentFinishedLogEntry => + Predicate.isTagged(entry, 'agent-finished') && entry.agentId === input.agentId, + ) + if (finished === undefined) { + return yield* Effect.die(new Error(`continuation of ${input.agentId} ended without a terminal marker`)) + } + return finished + }), + ) +}) + +/** Dispatch one fresh subagent of a registered type from inside the executing subagent tool call. */ +export const dispatchSubagent = Effect.fn('fold.subagents.dispatch')(function* (input: DispatchSubagentInput) { + if (!input.allowedAgents.includes(input.agent)) { + return yield* new SubagentTypeNotInRosterError({ + requested: input.agent, + availableAgents: input.allowedAgents, + }) + } + + const agents = yield* SessionAgents + const entry = agents.registry.resolveAgentType(input.agent) + if (entry === null) { + return yield* Effect.die( + new Error(`agent type "${input.agent}" is in a roster but missing from the session registry`), + ) + } + + const ids = yield* Ids + const dispatcher = yield* CurrentAgent + const currentCall = yield* CurrentToolCall + const interruptNote = yield* InterruptNote + + // Preload resolves through the DISPATCHER's skill source, and fails before any durable subagent row + // exists (§2.3 step 6). + const entries = yield* collectEntries + const dispatcherSnapshot = yield* agentSnapshotForAgent(entries, dispatcher.agentId) + if (dispatcherSnapshot === null) { + return yield* Effect.die(new Error(`dispatching agent ${dispatcher.agentId} has no resolvable configuration`)) + } + const preloaded = input.skill === null ? null : yield* preloadedSkillMessage(dispatcherSnapshot, input.skill) + + const subagentId = yield* ids.makeAgentId + const promptCacheKey = + dispatcherSnapshot.promptCacheKey === null + ? null + : yield* deriveChildPromptCacheKey(dispatcherSnapshot.promptCacheKey, subagentId) + yield* interruptNote.set(interruptedSubagentNote(entry.name, subagentId, 0)) + + const realized = agents.realizeTools(entry.tools) + + return yield* runSubagentToResult({ + subagentId, + agentTypeName: entry.name, + agentLabel: entry.name, + parentAgentId: dispatcher.agentId, + toolCallId: currentCall.toolCallId, + mode: 'fresh', + fork: null, + // Role bindings resolve at dispatch time: a setProfile swap binds the NEXT dispatch. + model: yield* resolveModelBinding(entry.model), + promptCacheKey, + tools: realized.tools, + hooks: entry.hooks, + systemPrompt: leadingBlocksFor(entry.systemPrompt, realized), + skillParam: input.skill, + messages: preloaded === null ? [input.prompt] : [input.prompt, preloaded], + launch: SubagentLaunch.start(), + interruptNote, + }).pipe(Effect.catchTag('SubagentBusyError', dieOnBusy)) +}) + +/** Fork the executing agent: a new subagent over a copy of the caller's context. */ +export const forkSubagent = Effect.fn('fold.subagents.fork')(function* (input: ForkSubagentInput) { + const agents = yield* SessionAgents + const ids = yield* Ids + const dispatcher = yield* CurrentAgent + const currentCall = yield* CurrentToolCall + const interruptNote = yield* InterruptNote + + const entries = yield* collectEntries + const dispatcherSnapshot = yield* agentSnapshotForAgent(entries, dispatcher.agentId) + if (dispatcherSnapshot === null) { + return yield* Effect.die(new Error(`fork dispatcher ${dispatcher.agentId} has no resolvable configuration`)) + } + + const preloaded = input.skill === null ? null : yield* preloadedSkillMessage(dispatcherSnapshot, input.skill) + + const forkDefinition = + input.forkAgentDefinitionId === null + ? null + : agents.registry.resolveForkAgentDefinition(input.forkAgentDefinitionId) + if (input.forkAgentDefinitionId !== null && forkDefinition === null) { + return yield* Effect.die(new Error(`fork agent definition "${input.forkAgentDefinitionId}" is not registered`)) + } + const forkTools = forkDefinition?.tools ?? dispatcherSnapshot.tools + const realized = agents.realizeTools(forkTools) + + // The fork sees the caller's history up to the head observed here; rows appended by parallel work + // after this observation are deliberately outside the fork's view. + const lastEntry = entries.at(-1) + if (lastEntry === undefined) { + return yield* Effect.die(new Error('fork requested on an empty session log')) + } + + const subagentId = yield* ids.makeAgentId + const promptCacheKey = + dispatcherSnapshot.promptCacheKey === null + ? null + : yield* deriveChildPromptCacheKey(dispatcherSnapshot.promptCacheKey, subagentId) + const agentLabel = `fork of ${shortAgentId(dispatcher.agentId)}` + yield* interruptNote.set(interruptedSubagentNote(agentLabel, subagentId, 0)) + const forkPoint = { fromAgentId: dispatcher.agentId, atSeq: lastEntry.seq, ...Struct.pick(input, ['history']) } + const fork = + input.forkAgentDefinitionId === null ? forkPoint : { ...forkPoint, definitionId: input.forkAgentDefinitionId } + + return yield* runSubagentToResult({ + subagentId, + agentTypeName: null, + agentLabel, + parentAgentId: dispatcher.agentId, + toolCallId: currentCall.toolCallId, + mode: 'fork', + fork, + model: dispatcherSnapshot.model, + promptCacheKey, + tools: realized.tools, + hooks: dispatcherSnapshot.hooks, + // Forks append no leading system message: the fold carries the caller's blocks (D21). + systemPrompt: null, + skillParam: input.skill, + messages: preloaded === null ? [input.prompt] : [input.prompt, preloaded], + launch: SubagentLaunch.start(), + interruptNote, + }).pipe(Effect.catchTag('SubagentBusyError', dieOnBusy)) +}) + +/** Resume a previously launched subagent by reference, from inside the executing subagent tool call. */ +export const resumeSubagent = Effect.fn('fold.subagents.resume')(function* (input: ResumeSubagentInput) { + const agents = yield* SessionAgents + const dispatcher = yield* CurrentAgent + const currentCall = yield* CurrentToolCall + const interruptNote = yield* InterruptNote + + const entries = yield* collectEntries + // The wire carries a reference (full id or unique short prefix); resolve it against every started + // agent before anything else. Ambiguity is a not-found carrying the candidates. + const resolution = resolveAgentIdRef(agentIdsFromEntries(entries), input.agentId) + const agentId = yield* Match.valueTags(resolution, { + resolved: ({ agentId }) => Effect.succeed(agentId), + 'not-found': () => Effect.fail(new SubagentNotFoundError({ requested: input.agentId })), + ambiguous: ({ candidates }) => Effect.fail(new SubagentNotFoundError({ requested: input.agentId, candidates })), + }) + + const started = findAgentStarted(entries, agentId) + if (started === null) { + return yield* new SubagentNotFoundError({ requested: input.agentId }) + } + + // Resuming yourself or a running ancestor would run one agent's loop inside itself. + if (agentId === dispatcher.agentId || ancestorAgentIds(entries, dispatcher.agentId).has(agentId)) { + return yield* new SubagentBusyError({ agentId }) + } + + const dispatcherSnapshot = yield* agentSnapshotForAgent(entries, dispatcher.agentId) + const preloaded = + input.skill === null + ? null + : dispatcherSnapshot === null + ? yield* new SkillNotFoundError({ name: input.skill, availableSkills: [] }) + : yield* preloadedSkillMessage(dispatcherSnapshot, input.skill) + + // The resumed agent's binding: its registry entry when its type is (still) registered, otherwise its + // fork-source chain, otherwise the dispatcher's own configuration. + const snapshot = (yield* agentSnapshotForAgent(entries, agentId)) ?? dispatcherSnapshot + if (snapshot === null) { + return yield* Effect.die(new Error(`resume target ${agentId} has no resolvable configuration`)) + } + const realized = agents.realizeTools(snapshot.tools) + + const projected = runtimeForAgent(entries, agentId) + const modelTransition = activeModelsDiffer(snapshot.model.activeModel, projected.activeModel) + ? { systemPrompt: leadingBlocksFor(snapshot.systemPrompt, realized) } + : null + + const agentLabel = started.agentType ?? `subagent ${shortAgentId(agentId)}` + yield* interruptNote.set(interruptedSubagentNote(agentLabel, agentId, 0)) + + return yield* runSubagentToResult({ + subagentId: agentId, + agentTypeName: started.agentType, + agentLabel, + parentAgentId: dispatcher.agentId, + toolCallId: currentCall.toolCallId, + mode: started.mode, + fork: started.fork, + model: snapshot.model, + promptCacheKey: snapshot.promptCacheKey, + tools: realized.tools, + hooks: snapshot.hooks, + systemPrompt: null, + skillParam: input.skill, + messages: preloaded === null ? [input.prompt] : [input.prompt, preloaded], + launch: SubagentLaunch.resume({ modelTransition }), + interruptNote, + }) +}) diff --git a/packages/fold-core/src/Subagents/SubagentTool.ts b/packages/fold-core/src/Subagents/SubagentTool.ts index d2c608f..417f7cb 100644 --- a/packages/fold-core/src/Subagents/SubagentTool.ts +++ b/packages/fold-core/src/Subagents/SubagentTool.ts @@ -3,12 +3,13 @@ * roster IS the factory argument: the returned value's description advertises exactly those types, its * closure carries their names as the dispatch authority (`allowedAgents`), and the composition root * discovers dispatchable definitions by walking tools arrays for these values (`subagentRosterOf`). + * The roster rides on the tool value itself, so the tool's type carries its roster's host services. * The handler is a thin adapter: it parses the flat wire parameters into one `SubagentCommand` at the - * boundary, delegates to the ambient `Subagents` service, and narrows each typed engine failure into + * boundary, delegates to the subagent operations, and narrows each typed engine failure into * the tool's instructive failure payload with `catchTag`/`catchTags` - all choreography lives in the * deep module, and no error is ever inspected as a value. */ -import { Effect, Match } from 'effect' +import { Array as Arr, Effect, Match } from 'effect' import { defineTool, type FoldTool } from '../Api/ToolDefinition' import type { SkillNotFoundError } from '../Skills/SkillSource' @@ -18,31 +19,27 @@ import { shortAgentId } from './AgentIdRef' import type { SubagentBusyError, SubagentNotFoundError, SubagentTypeNotInRosterError } from './Errors' import type { ForkAgentDefinition } from './ForkAgentDefinition' import { parseSubagentCommand, type SubagentResult } from './Schemas' -import type { SubagentDefinition } from './SubagentDefinition' -import { Subagents } from './SubagentsService' +import { subagentsNeedingAll, type SubagentDefinition, type SubagentDefinitionServices } from './SubagentDefinition' +import { dispatchSubagent, forkSubagent, resumeSubagent } from './SubagentEngine' /** Runtime capabilities attached to a model-visible delegation tool. */ -export type SubagentToolCapabilities = { - readonly agents: ReadonlyArray - readonly forkAgent?: ForkAgentDefinition +export type SubagentToolCapabilities = { + readonly agents: ReadonlyArray> + readonly forkAgent?: ForkAgentDefinition } -type Mutable = { -readonly [Key in keyof T]: T[Key] } - -const capabilitiesBySubagentTool = new WeakMap() - /** Attach Fold's roster and fork behavior to any host-defined model-visible tool. */ -export const withSubagentCapabilities = (tool: FoldTool, capabilities: SubagentToolCapabilities): FoldTool => { - capabilitiesBySubagentTool.set(tool, capabilities) - return tool -} +export const withSubagentCapabilities = ( + tool: FoldTool, + capabilities: SubagentToolCapabilities, +): FoldTool => ({ ...tool, subagents: capabilities }) /** Read the capabilities attached to a delegation tool; null for every other tool. */ -export const subagentCapabilitiesOf = (tool: FoldTool): SubagentToolCapabilities | null => - capabilitiesBySubagentTool.get(tool) ?? null +export const subagentCapabilitiesOf = (tool: FoldTool): SubagentToolCapabilities | null => + tool.subagents ?? null /** Read the roster off a subagentTool value; null for every other tool. */ -export const subagentRosterOf = (tool: FoldTool): ReadonlyArray | null => +export const subagentRosterOf = (tool: FoldTool): ReadonlyArray> | null => subagentCapabilitiesOf(tool)?.agents ?? null /** Model-facing failure payload of the subagent tool (schema: message + availableAgents). */ @@ -60,28 +57,30 @@ export const renderSubagentResult = (result: SubagentResult): string => { } /** The system-information note appended for non-completed outcomes; null for clean completions. */ -const outcomeNoteFor = (result: SubagentResult): string | null => { - switch (result.outcome) { - case 'completed': - return null - case 'error': - return ( +const outcomeNoteFor = (result: SubagentResult): string | null => + Match.value(result.outcome).pipe( + Match.when('completed', () => null), + Match.when( + 'error', + () => `This subagent finished with an error: ${result.errorMessage ?? 'unknown error'}. ` + `Its context is preserved; you may resume it with the agent_id above (it will see your new message), ` + - `or dispatch a fresh agent.` - ) - case 'stopped': - return ( + `or dispatch a fresh agent.`, + ), + Match.when( + 'stopped', + () => `This subagent stopped early (a tool or hook requested a stop). ` + - `Its context is preserved; you may resume it with the agent_id above.` - ) - case 'interrupted': - return ( + `Its context is preserved; you may resume it with the agent_id above.`, + ), + Match.when( + 'interrupted', + () => `This subagent was interrupted before completing. ` + - `Its context is preserved; you may resume it with the agent_id above.` - ) - } -} + `Its context is preserved; you may resume it with the agent_id above.`, + ), + Match.exhaustive, + ) // --- pure failure-payload formatters, invoked from catchTag/catchTags branches ----------------------- @@ -90,7 +89,7 @@ const rosterFailure = (error: SubagentTypeNotInRosterError): SubagentToolFailure ToolResultFailure.make({ text: `Agent type "${error.requested}" is not available to you. Available agent types: ` + - `${error.availableAgents.length === 0 ? '(none)' : error.availableAgents.join(', ')}.`, + `${Arr.isReadonlyArrayEmpty(error.availableAgents) ? '(none)' : error.availableAgents.join(', ')}.`, details: { availableAgents: error.availableAgents }, }) @@ -99,7 +98,7 @@ const skillFailure = (error: SkillNotFoundError, allowedAgents: ReadonlyArray): SubagentToolFailure => ToolResultFailure.make({ text: - error.candidates === undefined || error.candidates.length === 0 + error.candidates === undefined || Arr.isReadonlyArrayEmpty(error.candidates) ? `No subagent with agent_id "${error.requested}" exists in this session. Use the agent_id from a ` + `previous subagent result, or dispatch a fresh agent with the agent parameter.` : `agent_id "${error.requested}" is ambiguous: it matches ${error.candidates.length} agents ` + @@ -124,7 +123,7 @@ const busyFailure = (error: SubagentBusyError, allowedAgents: ReadonlyArray): string => { +const rosterDescriptionSuffix = (agents: ReadonlyArray>): string => { const listing = agents.map((agent) => `- ${agent.name}: ${agent.description}`).join('\n') return ( @@ -136,15 +135,15 @@ const rosterDescriptionSuffix = (agents: ReadonlyArray): str /** * Build one subagent tool value over a roster of dispatchable types (round-five public factory). The - * value is plain data: its description is fixed at construction (cache-stable), its handler reaches the - * `Subagents` engine as an ambient per-call service, and the composition root reads the roster back off + * value is plain data: its description is fixed at construction (cache-stable), its handler runs the + * subagent operations over the session's services, and the composition root reads the roster back off * the value (`subagentRosterOf`) to build the session registry. Each call creates an independent value; * agents sharing one roster should share one value. */ -export const subagentTool = ( - agents: ReadonlyArray, - options?: { readonly forkAgent?: ForkAgentDefinition }, -): FoldTool => { +export const subagentTool = , RF = never>( + agents: ReadonlyArray, + options?: { readonly forkAgent?: ForkAgentDefinition }, +): FoldTool | RF> => { const allowedAgents = agents.map((agent) => agent.name) const tool = defineTool({ @@ -152,7 +151,6 @@ export const subagentTool = ( description: `${subagentToolContract.description}${rosterDescriptionSuffix(agents)}`, handler: (params) => Effect.gen(function* () { - const subagents = yield* Subagents const command = yield* parseSubagentCommand(params).pipe( Effect.catchTag('InvalidSubagentCommandError', (error) => Effect.fail( @@ -167,59 +165,51 @@ export const subagentTool = ( return yield* Match.value(command).pipe( Match.tagsExhaustive({ dispatch: (dispatchCommand) => - subagents - .dispatch({ - agent: dispatchCommand.agent, - prompt: dispatchCommand.prompt, - skill: dispatchCommand.skill, - allowedAgents, - }) - .pipe( - Effect.catchTags({ - SubagentTypeNotInRosterError: (error) => Effect.fail(rosterFailure(error)), - SkillNotFoundError: (error) => Effect.fail(skillFailure(error, allowedAgents)), - }), - Effect.map((result) => ToolResultText.make({ text: renderSubagentResult(result) })), - ), + dispatchSubagent({ + agent: dispatchCommand.agent, + prompt: dispatchCommand.prompt, + skill: dispatchCommand.skill, + allowedAgents, + }).pipe( + Effect.catchTags({ + SubagentTypeNotInRosterError: (error) => Effect.fail(rosterFailure(error)), + SkillNotFoundError: (error) => Effect.fail(skillFailure(error, allowedAgents)), + }), + Effect.map((result) => ToolResultText.make({ text: renderSubagentResult(result) })), + ), fork: (forkCommand) => - subagents - .fork({ - prompt: forkCommand.prompt, - skill: forkCommand.skill, - forkAgentDefinitionId: options?.forkAgent?.id ?? null, - history: 'all', - }) - .pipe( - Effect.catchTag('SkillNotFoundError', (error) => - Effect.fail(skillFailure(error, allowedAgents)), - ), - Effect.map((result) => ToolResultText.make({ text: renderSubagentResult(result) })), + forkSubagent({ + prompt: forkCommand.prompt, + skill: forkCommand.skill, + forkAgentDefinitionId: options?.forkAgent?.id ?? null, + history: 'all', + }).pipe( + Effect.catchTag('SkillNotFoundError', (error) => + Effect.fail(skillFailure(error, allowedAgents)), ), + Effect.map((result) => ToolResultText.make({ text: renderSubagentResult(result) })), + ), resume: (resumeCommand) => - subagents - .resume({ - agentId: resumeCommand.agentId, - prompt: resumeCommand.prompt, - skill: resumeCommand.skill, - }) - .pipe( - Effect.catchTags({ - SubagentNotFoundError: (error) => - Effect.fail(notFoundFailure(error, allowedAgents)), - SubagentBusyError: (error) => Effect.fail(busyFailure(error, allowedAgents)), - SkillNotFoundError: (error) => Effect.fail(skillFailure(error, allowedAgents)), - }), - Effect.map((result) => ToolResultText.make({ text: renderSubagentResult(result) })), - ), + resumeSubagent({ + agentId: resumeCommand.agentId, + prompt: resumeCommand.prompt, + skill: resumeCommand.skill, + }).pipe( + Effect.catchTags({ + SubagentNotFoundError: (error) => + Effect.fail(notFoundFailure(error, allowedAgents)), + SubagentBusyError: (error) => Effect.fail(busyFailure(error, allowedAgents)), + SkillNotFoundError: (error) => Effect.fail(skillFailure(error, allowedAgents)), + }), + Effect.map((result) => ToolResultText.make({ text: renderSubagentResult(result) })), + ), }), ) }), }) - const capabilities: Mutable = { agents } - if (options?.forkAgent !== undefined) { - capabilities.forkAgent = options.forkAgent - } - + const roster = subagentsNeedingAll(agents) + const capabilities: SubagentToolCapabilities | RF> = + options?.forkAgent === undefined ? { agents: roster } : { agents: roster, forkAgent: options.forkAgent } return withSubagentCapabilities(tool, capabilities) } diff --git a/packages/fold-core/src/Subagents/SubagentsLayer.ts b/packages/fold-core/src/Subagents/SubagentsLayer.ts deleted file mode 100644 index c253eba..0000000 --- a/packages/fold-core/src/Subagents/SubagentsLayer.ts +++ /dev/null @@ -1,916 +0,0 @@ -/** - * This file implements the Subagents engine (D21) - the deep module behind the subagent tool. It owns - * the whole choreography: roster guards, id minting, per-dispatch runtime provisioning (own model, - * tools, hooks over the shared session services), the durable agent_started/user-message writes via - * AgentRuntime, the subagent run fiber (forked into the dispatch call's scope so interrupting the - * dispatcher structurally tears the subagent down), exit folding (a subagent that errors, dies, or is - * interrupted is a RESULT, never a failure - capture-then-narrow like the tool-settlement seam), the - * uninterruptible exit finalizers that keep the log honest (interrupt/error markers + the - * InterruptNote naming the subagent id and turn count), skill preload through the dispatcher's own - * skillTool source, and resume - including the D17 model transition when the configured binding - * changed since the subagent last ran. Registry entries may bind their model by profile role name - * (profiles slice): the engine resolves role bindings through the session's Profiles map at every - * consumption point - dispatch, and the origin snapshot feeding fork/resume/continue - so a - * `setProfile` swap binds on the very next run and the existing transition diff sees only concrete - * models. - */ -import { Data, Match, Predicate, Cause, Effect, Exit, Fiber, Ref, Schema, Stream } from 'effect' -import type { Array as Arr } from 'effect' -import { Prompt } from 'effect/unstable/ai' - -import type { FoldModel } from '../Api/ModelDescriptor' -import { AgentProvisioner } from '../Api/Provisioning' -import type { RealizedFoldTool, FoldTool } from '../Api/ToolDefinition' -import { EventLog } from '../EventLog/EventLogService' -import { - LogEntryInputs, - type AgentFinishedLogEntry, - type AgentFork, - type AgentLaunchMode, - type AgentStartedLogEntry, - type AssistantMessageLogEntry, - type LogEntry, - type LogEntryInput, - type LogSeq, -} from '../EventLog/Schemas' -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 { SessionControls } from '../Session/SessionControls' -import { SkillNotFoundError, type SkillSourceService } from '../Skills/SkillSource' -import { renderSkillContent } from '../Skills/SkillTool' -import { modelVisibleErrorDetailsFromCause } from '../ToolRuntime/ModelVisibleErrors' -import { - CurrentAgent, - CurrentToolCall, - InterruptNote, - type InterruptNoteService, -} from '../ToolRuntime/ToolContextServices' -import { agentIdsFromEntries, resolveAgentIdRef, shortAgentId } from './AgentIdRef' -import type { AgentRegistry, RegisteredAgentType } from './AgentRegistry' -import { SubagentBusyError, SubagentNotFoundError, SubagentTypeNotInRosterError } from './Errors' -import type { ForkSubagentInput, SubagentResult, TurnCount } from './Schemas' -import type { SubagentModelBinding } from './SubagentDefinition' -import type { - ContinueSubagentInput, - DispatchSubagentInput, - ResumeSubagentInput, - SubagentsService, -} from './SubagentsService' - -const encodeUserMessage = Schema.encodeUnknownSync(Prompt.UserMessage) - -type Mutable = { -readonly [Key in keyof T]: T[Key] } - -/** One agent's tools realized against the session-start contributions (once-per-value inits). */ -export type RealizedAgentTools = { - readonly tools: ReadonlyArray - /** Prompt blocks contributed by session-initialized tools, in tools-array order. */ - readonly promptBlocks: ReadonlyArray - /** The skill source contributed by this agent's skillTool, for dispatch-time skill preload. */ - readonly skillSource: SkillSourceService | null -} - -/** The root agent's current configuration; model switches move it (read fresh per dispatch). */ -export type RootAgentSnapshot = { - readonly model: FoldModel - readonly promptCacheKey: string | null - /** The root's tools as configured (system-tool values included). */ - readonly tools: ReadonlyArray - readonly hooks: HookConfig - /** The root's own leading blocks, WITHOUT tool-contributed blocks (those come from realization). */ - readonly systemPrompt: string | ReadonlyArray | null -} - -/** Composition-root wiring for the Subagents engine, supplied by `startSession`. */ -export type SubagentsConfig = { - readonly registry: AgentRegistry - /** Realize one agent's configured tools via the session-start contribution map (§2.5). */ - readonly realizeAgentTools: (tools: ReadonlyArray) => RealizedAgentTools - readonly currentRootAgent: Effect.Effect -} - -/** Where an agent's configuration comes from: a registered type, or the root agent. */ -type OriginatingConfig = { readonly _tag: 'entry'; readonly entry: RegisteredAgentType } | { readonly _tag: 'root' } -const OriginatingConfig = Data.taggedEnum() - -type AgentConfigurationSnapshot = { - readonly model: FoldModel - readonly promptCacheKey: string | null - readonly tools: ReadonlyArray - readonly hooks: HookConfig - readonly systemPrompt: string | ReadonlyArray | null -} - -/** Everything one subagent launch/resume needs, resolved before the run fiber forks. */ -type LaunchSubagentParams = { - readonly subagentId: AgentId - /** Registry type name recorded on agent_started; null for forks. */ - readonly agentTypeName: string | null - /** Human-readable label for interrupt notes ("researcher", "fork of agent_x..."). */ - readonly agentLabel: string - readonly parentAgentId: AgentId - readonly toolCallId: ToolCallId - readonly mode: AgentLaunchMode - readonly fork: AgentFork | null - readonly model: FoldModel - readonly promptCacheKey: string | null - readonly tools: ReadonlyArray - readonly hooks: HookConfig - /** Leading blocks for fresh starts (entry blocks + tool-contributed blocks); null for forks/resumes. */ - readonly systemPrompt: ReadonlyArray | null - /** The preloaded skill name recorded on agent_started (fresh starts only). */ - readonly skillParam: string | null - readonly messages: Arr.NonEmptyReadonlyArray - /** Fresh dispatch/fork writes agent_started; resume re-enters, optionally after a model transition. */ - readonly launch: - | { readonly _tag: 'start' } - | { - readonly _tag: 'resume' - readonly modelTransition: { readonly systemPrompt: ReadonlyArray | null } | null - } - readonly interruptNote: InterruptNoteService -} - -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 - -/** Leading blocks for one agent: its own blocks, then its tools' contributed blocks. */ -const leadingBlocksFor = ( - systemPrompt: string | ReadonlyArray | null, - realized: RealizedAgentTools, -): ReadonlyArray | null => { - const blocks = [...promptBlocksOf(systemPrompt), ...realized.promptBlocks] - return blocks.length === 0 ? null : blocks -} - -/** - * The note embedded in the dispatcher's synthetic result if this call is interrupted. Set with zero - * turns the moment the subagent id exists, then kept current by the turn watcher as the subagent's - * assistant messages land - so whenever the interruption strikes, the note already reflects the last - * completed turn (no dependence on teardown ordering). - */ -const interruptedSubagentNote = (agentLabel: string, subagentId: AgentId, turnsThisRun: number): string => - `Subagent ${agentLabel} (agent_id: ${shortAgentId(subagentId)}) was interrupted after ${turnsThisRun} ` + - `turn${turnsThisRun === 1 ? '' : 's'}, before completing. Its progress up to the interruption is saved. ` + - `Pass agent_id: ${shortAgentId(subagentId)} to the subagent tool to resume it.` - -const findAgentStarted = (entries: ReadonlyArray, agentId: AgentId): AgentStartedLogEntry | null => - entries.find( - (entry): entry is AgentStartedLogEntry => - Predicate.isTagged(entry, 'agent_started') && entry.agentId === agentId, - ) ?? null - -/** Count assistant turns for one subagent: this dispatch/resume (by toolCallId) and lifetime total. */ -const countAssistantTurns = ( - entries: ReadonlyArray, - agentId: AgentId, - toolCallId: ToolCallId, -): { readonly thisRun: TurnCount; readonly total: TurnCount } => { - const own = entries.filter( - (entry): entry is AssistantMessageLogEntry => - Predicate.isTagged(entry, 'assistant-message') && entry.agentId === agentId, - ) - - return { - thisRun: own.filter((entry) => entry.toolCallId === toolCallId).length, - total: own.length, - } -} - -/** The subagent's final assistant text for this run, or null when it produced none. */ -const lastAssistantTextForRun = ( - entries: ReadonlyArray, - agentId: AgentId, - toolCallId: ToolCallId, -): string | null => { - const lastAssistant = entries.findLast( - (entry): entry is AssistantMessageLogEntry => - Predicate.isTagged(entry, 'assistant-message') && - entry.agentId === agentId && - entry.toolCallId === toolCallId, - ) - if (lastAssistant === undefined) return null - - const content = lastAssistant.message.content - if (typeof content === 'string') return content.length > 0 ? content : null - - const text = content.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('') - return text.length > 0 ? text : null -} - -/** Structural model-binding comparison deciding whether a resume needs a D17 transition. */ -const activeModelsDiffer = (left: unknown, right: unknown): boolean => JSON.stringify(left) !== JSON.stringify(right) - -const CHILD_CACHE_SUFFIX_LENGTH = 28 -const MAX_PROMPT_CACHE_KEY_LENGTH = 64 - -/** Derive a bounded, deterministic child cache-affinity key from its parent and durable id. */ -export const deriveChildPromptCacheKey = (parentKey: string, agentId: AgentId): Effect.Effect => { - const suffix = `:${agentId.slice(-CHILD_CACHE_SUFFIX_LENGTH)}` - const parentLength = MAX_PROMPT_CACHE_KEY_LENGTH - suffix.length - return Effect.succeed(`${parentKey.slice(0, parentLength)}${suffix}`) -} - -/** - * Build the Subagents engine over the session's shared services. `startSession` constructs this once - * per session (after the registry and tool contributions exist) and publishes it as the ambient - * per-call `Subagents` service every tool handler can reach. - */ -export const makeSubagents = ( - config: SubagentsConfig, -): Effect.Effect => - Effect.gen(function* () { - const eventLog = yield* EventLog - const ids = yield* Ids - const provisioner = yield* AgentProvisioner - // The session-wide running registry (slice 2): claiming here is what makes a dispatched subagent - // visible to external steer/targeted-interrupt, and what the Busy guard reads. - const controls = yield* SessionControls - // The session-wide role->model bindings (profiles slice): role-bound registry entries resolve - // here at each dispatch/resume, so a setProfile swap binds on the very next run. - const profiles = yield* Profiles - - /** 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) - - const appendToEventLog = (input: LogEntryInput): Effect.Effect => - eventLog.append(input).pipe(Effect.orDie) - - const collectEntries: Effect.Effect> = Stream.runCollect(eventLog.entries()).pipe( - Effect.orDie, - Effect.map((entries): ReadonlyArray => entries), - ) - - /** Atomically claim a subagent id in the session registry; already-running means Busy. */ - const claimRunningSubagent = (subagentId: AgentId): Effect.Effect => - controls - .claimRunning(subagentId) - .pipe( - Effect.flatMap((claimed) => - claimed ? Effect.void : Effect.fail(new SubagentBusyError({ agentId: subagentId })), - ), - ) - - /** - * Resolve which configuration an agent runs under: its registry entry (by agent_started.agentType), - * the fork-source's configuration for forks, or the root's current configuration. - */ - const originatingConfigForAgent = ( - entries: ReadonlyArray, - agentId: AgentId, - seen: ReadonlySet = new Set(), - ): OriginatingConfig | null => { - if (seen.has(agentId)) return null - const started = findAgentStarted(entries, agentId) - if (started === null) return null - - if (started.agentType !== null) { - const entry = config.registry.resolveAgentType(started.agentType) - return entry === null ? null : OriginatingConfig.entry({ entry }) - } - if (started.mode === 'fork' && started.fork !== null) { - return originatingConfigForAgent(entries, started.fork.fromAgentId, new Set([...seen, agentId])) - } - if (started.parentAgentId === null) return OriginatingConfig.root() - - return null - } - - /** - * The (model, tools, hooks, prompt) snapshot behind one originating configuration. Entry origins - * resolve their model binding against the CURRENT profiles map, so fork/resume/continue of a - * role-bound type all see the live binding (a fork clones the caller's binding by definition). - */ - const agentSnapshotForOrigin = (origin: OriginatingConfig): Effect.Effect => - Match.valueTags(origin, { - root: () => config.currentRootAgent, - entry: ({ entry }) => - resolveModelBinding(entry.model).pipe( - Effect.map((model) => ({ - model, - promptCacheKey: null, - tools: entry.tools, - hooks: entry.hooks, - systemPrompt: entry.systemPrompt, - })), - ), - }) - - /** Reconstruct one agent's effective configuration, including a persisted fork tool override. */ - const agentSnapshotForAgent = ( - entries: ReadonlyArray, - agentId: AgentId, - ): Effect.Effect => - Effect.gen(function* () { - const origin = originatingConfigForAgent(entries, agentId) - if (origin === null) return null - - const snapshot = yield* agentSnapshotForOrigin(origin) - const started = findAgentStarted(entries, agentId) - if (started === null) return snapshot - const withCacheKey = { ...snapshot, promptCacheKey: started.promptCacheKey ?? snapshot.promptCacheKey } - const definitionId = started.fork?.definitionId - if (definitionId === undefined) return withCacheKey - - const definition = config.registry.resolveForkAgentDefinition(definitionId) - if (definition === null) { - return yield* Effect.die( - new Error(`fork agent definition "${definitionId}" required by ${agentId} is not registered`), - ) - } - - return { - ...withCacheKey, - tools: definition.tools, - } - }) - - /** Every ancestor of an agent (parent chain from agent_started rows), for the resume self-guard. */ - const ancestorAgentIds = (entries: ReadonlyArray, agentId: AgentId): ReadonlySet => { - const ancestors = new Set() - let current: AgentId | null = agentId - - while (current !== null && !ancestors.has(current)) { - const started = findAgentStarted(entries, current) - if (started === null) break - current = started.parentAgentId - if (current !== null) ancestors.add(current) - } - - return ancestors - } - - /** Load and render a preloaded skill through the dispatcher's own skillTool source (§2.3). */ - const preloadedSkillMessage = ( - dispatcherSnapshot: AgentConfigurationSnapshot, - skillName: string, - ): Effect.Effect => - Effect.gen(function* () { - const realized = config.realizeAgentTools(dispatcherSnapshot.tools) - - if (realized.skillSource === null) { - return yield* new SkillNotFoundError({ name: skillName, availableSkills: [] }) - } - - const skill = yield* realized.skillSource - .load(skillName) - .pipe(Effect.catchTag('SkillSourceError', (error) => Effect.die(error))) - - return renderSkillContent(skill) - }) - - /** Uninterruptible exit finalizer: durable interrupt/error markers + the turn-counting note. */ - const writeSubagentExitMarkers = - (params: LaunchSubagentParams) => - (exit: Exit.Exit): Effect.Effect => - Effect.gen(function* () { - if (Exit.isSuccess(exit)) return - - const cause = exit.cause - const entries = yield* collectEntries - const finishedThisRun = entries.some( - (entry) => - Predicate.isTagged(entry, 'agent-finished') && - entry.agentId === params.subagentId && - entry.toolCallId === params.toolCallId, - ) - - if (!finishedThisRun) { - if (Cause.hasInterrupts(cause)) { - yield* appendToEventLog( - LogEntryInputs['user-message']({ - agentId: params.subagentId, - parentAgentId: params.parentAgentId, - toolCallId: params.toolCallId, - messageId: yield* ids.makeMessageId, - message: encodeUserMessage( - Prompt.userMessage({ - content: [ - Prompt.textPart({ - text: 'You were interrupted by the user before completing this work.', - }), - ], - }), - ), - }), - ) - yield* appendToEventLog( - LogEntryInputs['agent-finished']({ - agentId: params.subagentId, - parentAgentId: params.parentAgentId, - toolCallId: params.toolCallId, - outcome: 'interrupted', - resultText: null, - reason: 'interrupted by the user', - }), - ) - } else { - yield* appendToEventLog( - LogEntryInputs['agent-finished']({ - agentId: params.subagentId, - parentAgentId: params.parentAgentId, - toolCallId: params.toolCallId, - outcome: 'error', - resultText: null, - reason: modelVisibleErrorDetailsFromCause(cause), - }), - ) - } - } - }) - - /** Fold a finished (or torn-down) subagent run into its result. Errors and interrupts are results. */ - const subagentResultFromExit = ( - params: LaunchSubagentParams, - exit: Exit.Exit, - entries: ReadonlyArray, - ): SubagentResult => { - const turns = countAssistantTurns(entries, params.subagentId, params.toolCallId) - - if (Exit.isSuccess(exit)) { - const finished = exit.value - return { - agentId: params.subagentId, - outcome: finished.outcome, - resultText: finished.resultText, - errorMessage: finished.outcome === 'error' ? finished.reason : null, - turnsThisRun: turns.thisRun, - turnsTotal: turns.total, - } - } - - const interrupted = Cause.hasInterrupts(exit.cause) - return { - agentId: params.subagentId, - outcome: interrupted ? 'interrupted' : 'error', - resultText: lastAssistantTextForRun(entries, params.subagentId, params.toolCallId), - errorMessage: interrupted ? null : modelVisibleErrorDetailsFromCause(exit.cause), - turnsThisRun: turns.thisRun, - turnsTotal: turns.total, - } - } - - /** - * Run one subagent launch/resume to its result: claim the id, provision this agent's runtime into - * the dispatch scope, write the start (or resume transition), fork the run fiber, await its Exit, - * and fold. The dispatch scope closing (dispatcher interrupted) interrupts the run fiber; the - * onExit finalizer then writes the durable markers before the interruption propagates. - */ - const runSubagentToResult = (params: LaunchSubagentParams): Effect.Effect => - Effect.scoped( - Effect.gen(function* () { - yield* Effect.acquireRelease(claimRunningSubagent(params.subagentId), () => - controls.releaseRunning(params.subagentId), - ) - - const agentRuntimeForSubagent = yield* provisioner.provisionAgentRuntime({ - model: params.model, - tools: params.tools, - hooks: params.hooks, - }) - - if (SubagentLaunch.$is('start')(params.launch)) { - yield* agentRuntimeForSubagent.start({ - agentId: params.subagentId, - parentAgentId: params.parentAgentId, - toolCallId: params.toolCallId, - mode: params.mode, - fork: params.fork, - skill: params.skillParam, - agentType: params.agentTypeName, - model: params.model.activeModel, - promptCacheKey: params.promptCacheKey, - systemPrompt: params.systemPrompt, - }) - } else if (params.launch.modelTransition !== null) { - yield* agentRuntimeForSubagent.switchModel({ - agentId: params.subagentId, - parentAgentId: params.parentAgentId, - toolCallId: params.toolCallId, - model: params.model.activeModel, - systemPrompt: params.launch.modelTransition.systemPrompt, - reason: 'resume: the configured model for this agent changed since it last ran', - }) - } - - const subagentRunFiber = yield* Effect.forkScoped( - agentRuntimeForSubagent - .run({ - agentId: params.subagentId, - parentAgentId: params.parentAgentId, - toolCallId: params.toolCallId, - messages: params.messages, - }) - .pipe(Effect.onExit(writeSubagentExitMarkers(params))), - ) - // Closing the dispatch scope interrupts the subagent and awaits its exit finalizers, - // so the durable interrupt/error markers land before the dispatch call returns. - yield* Effect.addFinalizer(() => Fiber.interrupt(subagentRunFiber)) - - // Keep the interrupt note current as the subagent works: each completed turn (one - // assistant-message row under this dispatch's tool call) bumps the count, so whenever - // an interruption strikes, the dispatcher's synthetic result already carries an - // accurate "interrupted after N turns" note - no dependence on teardown ordering. - const watchSubagentTurns = Effect.gen(function* () { - const turnsSeen = yield* Ref.make(0) - - yield* eventLog.subscribe().pipe( - Stream.filter( - (entry) => - Predicate.isTagged(entry, 'assistant-message') && - entry.agentId === params.subagentId && - entry.toolCallId === params.toolCallId, - ), - Stream.tap(() => - Ref.updateAndGet(turnsSeen, (count) => count + 1).pipe( - Effect.flatMap((turns) => - params.interruptNote.set( - interruptedSubagentNote(params.agentLabel, params.subagentId, turns), - ), - ), - ), - ), - Stream.runDrain, - Effect.orDie, - ) - }) - yield* Effect.forkScoped(watchSubagentTurns) - yield* controls.setRunningFiber(params.subagentId, subagentRunFiber) - - const exit = yield* Fiber.await(subagentRunFiber) - const entries = yield* collectEntries - - return subagentResultFromExit(params, exit, entries) - }), - ) - - /** A fresh id can never be running; a Busy claim failure on one is an engine bug. */ - const dieOnBusy = (error: SubagentBusyError): Effect.Effect => - Effect.die(new Error(`freshly minted subagent id ${error.agentId} was already running`)) - - /** - * Uninterruptible exit markers for a direct SDK continuation (D8): same honesty as dispatch - * markers, but with the null envelope (no dispatching tool call) and a seq baseline guard - a - * continuation reuses the null toolCallId across runs, so "did this run already write its - * terminal marker" is answered by seq position, not by call identity. - */ - const writeDirectExitMarkers = - (agentId: AgentId, baselineSeq: LogSeq) => - (exit: Exit.Exit): Effect.Effect => - Effect.gen(function* () { - if (Exit.isSuccess(exit)) return - - const entries = yield* collectEntries - const finishedThisRun = entries.some( - (entry) => - Predicate.isTagged(entry, 'agent-finished') && - entry.agentId === agentId && - entry.seq > baselineSeq, - ) - if (finishedThisRun) return - - if (Cause.hasInterrupts(exit.cause)) { - yield* appendToEventLog( - LogEntryInputs['user-message']({ - agentId, - parentAgentId: null, - toolCallId: null, - messageId: yield* ids.makeMessageId, - message: encodeUserMessage( - Prompt.userMessage({ - content: [ - Prompt.textPart({ - text: 'You were interrupted by the user before completing this work.', - }), - ], - }), - ), - }), - ) - yield* appendToEventLog( - LogEntryInputs['agent-finished']({ - agentId, - parentAgentId: null, - toolCallId: null, - outcome: 'interrupted', - resultText: null, - reason: 'interrupted by the user', - }), - ) - } else { - yield* appendToEventLog( - LogEntryInputs['agent-finished']({ - agentId, - parentAgentId: null, - toolCallId: null, - outcome: 'error', - resultText: null, - reason: modelVisibleErrorDetailsFromCause(exit.cause), - }), - ) - } - }) - - const continueSubagent: SubagentsService['continueSubagent'] = Effect.fn('fold.subagents.continue')( - (input: ContinueSubagentInput) => - Effect.gen(function* () { - const entries = yield* collectEntries - const started = findAgentStarted(entries, input.agentId) - if (started === null) { - return yield* new SubagentNotFoundError({ requested: input.agentId }) - } - - const snapshot = yield* agentSnapshotForAgent(entries, input.agentId) - if (snapshot === null) { - return yield* Effect.die( - new Error(`continuation target ${input.agentId} has no resolvable configuration`), - ) - } - const realized = config.realizeAgentTools(snapshot.tools) - - const projected = runtimeForAgent(entries, input.agentId) - const modelTransition = activeModelsDiffer(snapshot.model.activeModel, projected.activeModel) - ? { systemPrompt: leadingBlocksFor(snapshot.systemPrompt, realized) } - : null - - const lastEntry = entries.at(-1) - if (lastEntry === undefined) { - return yield* Effect.die(new Error('continuation requested on an empty session log')) - } - const baselineSeq = lastEntry.seq - - return yield* Effect.scoped( - Effect.gen(function* () { - yield* Effect.acquireRelease(claimRunningSubagent(input.agentId), () => - controls.releaseRunning(input.agentId), - ) - - const agentRuntimeForSubagent = yield* provisioner.provisionAgentRuntime({ - model: snapshot.model, - tools: realized.tools, - hooks: snapshot.hooks, - }) - - if (modelTransition !== null) { - yield* agentRuntimeForSubagent.switchModel({ - agentId: input.agentId, - parentAgentId: null, - toolCallId: null, - model: snapshot.model.activeModel, - systemPrompt: modelTransition.systemPrompt, - reason: 'continue: the configured model for this agent changed since it last ran', - }) - } - - const runFiber = yield* Effect.forkScoped( - agentRuntimeForSubagent - .run({ - agentId: input.agentId, - parentAgentId: null, - toolCallId: null, - messages: [input.prompt], - }) - .pipe(Effect.onExit(writeDirectExitMarkers(input.agentId, baselineSeq))), - ) - yield* Effect.addFinalizer(() => Fiber.interrupt(runFiber)) - yield* controls.setRunningFiber(input.agentId, runFiber) - - const exit = yield* Fiber.await(runFiber) - if (Exit.isSuccess(exit)) return exit.value - - // Interrupted or dead: the exit markers above wrote the terminal entry - return it. - const after = yield* collectEntries - const finished = after.findLast( - (entry): entry is AgentFinishedLogEntry => - Predicate.isTagged(entry, 'agent-finished') && entry.agentId === input.agentId, - ) - if (finished === undefined) { - return yield* Effect.die( - new Error(`continuation of ${input.agentId} ended without a terminal marker`), - ) - } - return finished - }), - ) - }), - ) - - const dispatch: SubagentsService['dispatch'] = Effect.fn('fold.subagents.dispatch')( - (input: DispatchSubagentInput) => - Effect.gen(function* () { - if (!input.allowedAgents.includes(input.agent)) { - return yield* new SubagentTypeNotInRosterError({ - requested: input.agent, - availableAgents: input.allowedAgents, - }) - } - - const entry = config.registry.resolveAgentType(input.agent) - if (entry === null) { - return yield* Effect.die( - new Error( - `agent type "${input.agent}" is in a roster but missing from the session registry`, - ), - ) - } - - const dispatcher = yield* CurrentAgent - const currentCall = yield* CurrentToolCall - const interruptNote = yield* InterruptNote - - // Preload resolves through the DISPATCHER's skill source, and fails before any durable - // subagent row exists (§2.3 step 6). - const entries = yield* collectEntries - const dispatcherSnapshot = yield* agentSnapshotForAgent(entries, dispatcher.agentId) - if (dispatcherSnapshot === null) { - return yield* Effect.die( - new Error(`dispatching agent ${dispatcher.agentId} has no resolvable configuration`), - ) - } - const preloaded = - input.skill === null ? null : yield* preloadedSkillMessage(dispatcherSnapshot, input.skill) - - const subagentId = yield* ids.makeAgentId - const promptCacheKey = - dispatcherSnapshot.promptCacheKey === null - ? null - : yield* deriveChildPromptCacheKey(dispatcherSnapshot.promptCacheKey, subagentId) - yield* interruptNote.set(interruptedSubagentNote(entry.name, subagentId, 0)) - - const realized = config.realizeAgentTools(entry.tools) - - return yield* runSubagentToResult({ - subagentId, - agentTypeName: entry.name, - agentLabel: entry.name, - parentAgentId: dispatcher.agentId, - toolCallId: currentCall.toolCallId, - mode: 'fresh', - fork: null, - // Role bindings resolve at dispatch time: a setProfile swap binds the NEXT dispatch. - model: yield* resolveModelBinding(entry.model), - promptCacheKey, - tools: realized.tools, - hooks: entry.hooks, - systemPrompt: leadingBlocksFor(entry.systemPrompt, realized), - skillParam: input.skill, - messages: preloaded === null ? [input.prompt] : [input.prompt, preloaded], - launch: SubagentLaunch.start(), - interruptNote, - }).pipe(Effect.catchTag('SubagentBusyError', dieOnBusy)) - }), - ) - - const fork: SubagentsService['fork'] = Effect.fn('fold.subagents.fork')((input: ForkSubagentInput) => - Effect.gen(function* () { - const dispatcher = yield* CurrentAgent - const currentCall = yield* CurrentToolCall - const interruptNote = yield* InterruptNote - - const entries = yield* collectEntries - const dispatcherSnapshot = yield* agentSnapshotForAgent(entries, dispatcher.agentId) - if (dispatcherSnapshot === null) { - return yield* Effect.die( - new Error(`fork dispatcher ${dispatcher.agentId} has no resolvable configuration`), - ) - } - - const preloaded = - input.skill === null ? null : yield* preloadedSkillMessage(dispatcherSnapshot, input.skill) - - const forkDefinition = - input.forkAgentDefinitionId === null - ? null - : config.registry.resolveForkAgentDefinition(input.forkAgentDefinitionId) - if (input.forkAgentDefinitionId !== null && forkDefinition === null) { - return yield* Effect.die( - new Error(`fork agent definition "${input.forkAgentDefinitionId}" is not registered`), - ) - } - const forkTools = forkDefinition?.tools ?? dispatcherSnapshot.tools - const realized = config.realizeAgentTools(forkTools) - - // The fork sees the caller's history up to the head observed here; rows appended by parallel - // work after this observation are deliberately outside the fork's view. - const lastEntry = entries.at(-1) - if (lastEntry === undefined) { - return yield* Effect.die(new Error('fork requested on an empty session log')) - } - - const subagentId = yield* ids.makeAgentId - const promptCacheKey = - dispatcherSnapshot.promptCacheKey === null - ? null - : yield* deriveChildPromptCacheKey(dispatcherSnapshot.promptCacheKey, subagentId) - const agentLabel = `fork of ${shortAgentId(dispatcher.agentId)}` - yield* interruptNote.set(interruptedSubagentNote(agentLabel, subagentId, 0)) - const fork: Mutable = { fromAgentId: dispatcher.agentId, atSeq: lastEntry.seq } - if (input.forkAgentDefinitionId !== null) { - fork.definitionId = input.forkAgentDefinitionId - } - if (input.history !== undefined) { - fork.history = input.history - } - - return yield* runSubagentToResult({ - subagentId, - agentTypeName: null, - agentLabel, - parentAgentId: dispatcher.agentId, - toolCallId: currentCall.toolCallId, - mode: 'fork', - fork, - model: dispatcherSnapshot.model, - promptCacheKey, - tools: realized.tools, - hooks: dispatcherSnapshot.hooks, - // Forks append no leading system message: the fold carries the caller's blocks (D21). - systemPrompt: null, - skillParam: input.skill, - messages: preloaded === null ? [input.prompt] : [input.prompt, preloaded], - launch: SubagentLaunch.start(), - interruptNote, - }).pipe(Effect.catchTag('SubagentBusyError', dieOnBusy)) - }), - ) - - const resume: SubagentsService['resume'] = Effect.fn('fold.subagents.resume')((input: ResumeSubagentInput) => - Effect.gen(function* () { - const dispatcher = yield* CurrentAgent - const currentCall = yield* CurrentToolCall - const interruptNote = yield* InterruptNote - - const entries = yield* collectEntries - // The wire carries a reference (full id or unique short prefix); resolve it against every - // started agent before anything else. Ambiguity is a not-found carrying the candidates. - const resolution = resolveAgentIdRef(agentIdsFromEntries(entries), input.agentId) - const agentId = yield* Match.valueTags(resolution, { - resolved: ({ agentId }) => Effect.succeed(agentId), - 'not-found': () => Effect.fail(new SubagentNotFoundError({ requested: input.agentId })), - ambiguous: ({ candidates }) => - Effect.fail(new SubagentNotFoundError({ requested: input.agentId, candidates })), - }) - - const started = findAgentStarted(entries, agentId) - if (started === null) { - return yield* new SubagentNotFoundError({ requested: input.agentId }) - } - - // Resuming yourself or a running ancestor would run one agent's loop inside itself. - if (agentId === dispatcher.agentId || ancestorAgentIds(entries, dispatcher.agentId).has(agentId)) { - return yield* new SubagentBusyError({ agentId }) - } - - const dispatcherSnapshot = yield* agentSnapshotForAgent(entries, dispatcher.agentId) - const preloaded = - input.skill === null - ? null - : dispatcherSnapshot === null - ? yield* new SkillNotFoundError({ name: input.skill, availableSkills: [] }) - : yield* preloadedSkillMessage(dispatcherSnapshot, input.skill) - - // The resumed agent's binding: its registry entry when its type is (still) registered, - // otherwise its fork-source chain, otherwise the dispatcher's own configuration. - const snapshot = (yield* agentSnapshotForAgent(entries, agentId)) ?? dispatcherSnapshot - if (snapshot === null) { - return yield* Effect.die(new Error(`resume target ${agentId} has no resolvable configuration`)) - } - const realized = config.realizeAgentTools(snapshot.tools) - - const projected = runtimeForAgent(entries, agentId) - const modelTransition = activeModelsDiffer(snapshot.model.activeModel, projected.activeModel) - ? { systemPrompt: leadingBlocksFor(snapshot.systemPrompt, realized) } - : null - - const agentLabel = started.agentType ?? `subagent ${shortAgentId(agentId)}` - yield* interruptNote.set(interruptedSubagentNote(agentLabel, agentId, 0)) - - return yield* runSubagentToResult({ - subagentId: agentId, - agentTypeName: started.agentType, - agentLabel, - parentAgentId: dispatcher.agentId, - toolCallId: currentCall.toolCallId, - mode: started.mode, - fork: started.fork, - model: snapshot.model, - promptCacheKey: snapshot.promptCacheKey, - tools: realized.tools, - hooks: snapshot.hooks, - systemPrompt: null, - skillParam: input.skill, - messages: preloaded === null ? [input.prompt] : [input.prompt, preloaded], - launch: SubagentLaunch.resume({ modelTransition }), - interruptNote, - }) - }), - ) - - return { dispatch, fork, resume, continueSubagent } - }) diff --git a/packages/fold-core/src/Subagents/SubagentsService.ts b/packages/fold-core/src/Subagents/SubagentsService.ts deleted file mode 100644 index f2330cc..0000000 --- a/packages/fold-core/src/Subagents/SubagentsService.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * This file defines the Subagents service contract (D21) - dispatch, fork, and resume subagents on the - * session log. The deep module behind it owns choreography, fibers, finalizers, roster enforcement, - * and result folding; the subagent tool is a thin adapter over these three methods. Every method runs - * inside a tool call: the executing call's identity and interrupt note arrive as ambient services - * (CurrentAgent / CurrentToolCall / InterruptNote, provided by ToolRuntime around each handler), and - * the dispatch authority - which types this caller may launch - arrives as `allowedAgents` from the - * executing subagentTool value's closure (round-five roster model). - */ -import { Context } from 'effect' -import type { Effect } from 'effect' - -import type { AgentFinishedLogEntry } from '../EventLog/Schemas' -import type { AgentId } from '../Ids' -import type { SkillNotFoundError } from '../Skills/SkillSource' -import type { CurrentAgent, CurrentToolCall, InterruptNote } from '../ToolRuntime/ToolContextServices' -import type { SubagentBusyError, SubagentNotFoundError, SubagentTypeNotInRosterError } from './Errors' -import type { ForkSubagentInput, SubagentResult } from './Schemas' - -/** Ambient per-tool-call services every Subagents method consumes. */ -export type SubagentAmbientServices = CurrentAgent | CurrentToolCall | InterruptNote - -/** Input for dispatching one fresh subagent of a registered type. */ -export type DispatchSubagentInput = { - /** The requested agent type name. */ - readonly agent: string - readonly prompt: string - /** Skill to preload after the prompt, resolved through the dispatcher's own skillTool source. */ - readonly skill: string | null - /** The executing subagentTool value's roster (from its closure) - the dispatch authority (§1a). */ - readonly allowedAgents: ReadonlyArray -} - -/** - * Input for resuming a previously dispatched subagent by reference: the full branded id, or a unique - * short prefix like `agent_ab12cd34` (the form rendered in subagent results). The engine resolves the - * reference against the log's `agent_started` rows - exact match first, then unique prefix. - */ -export type ResumeSubagentInput = { - readonly agentId: AgentId | string - readonly prompt: string - readonly skill: string | null -} - -/** Input for continuing a finished subagent directly from the SDK (D8 `send(message, { agentId })`). */ -export type ContinueSubagentInput = { - readonly agentId: AgentId - readonly prompt: string -} - -/** - * Subagent lifecycle operations (D21). - * - * A subagent that errors, dies with a defect, or is interrupted is a *result*, not a failure: its - * terminal `agent-finished` marker is durable and the returned SubagentResult carries the outcome, - * error message, and turn counts, so the dispatcher can render it and later resume the subagent by id. - * The typed failures below are the *dispatch-time* problems the model can act on: an out-of-roster - * type, an unknown id, a concurrent resume, or a bad skill preload. - */ -export type SubagentsService = { - readonly dispatch: ( - input: DispatchSubagentInput, - ) => Effect.Effect - readonly fork: ( - input: ForkSubagentInput, - ) => Effect.Effect - readonly resume: ( - input: ResumeSubagentInput, - ) => Effect.Effect< - SubagentResult, - SubagentNotFoundError | SubagentBusyError | SkillNotFoundError, - SubagentAmbientServices - > - /** - * Continue a finished subagent from the SDK, with no dispatching tool call (D8): the prompt appends - * as a user message with a null toolCallId/parentAgentId envelope, the agent's loop restarts under - * its own configuration (running the D17 model transition first when its binding changed), and the - * caller gets the durable terminal entry - interrupts and defects included, via their durable - * markers. Requires no ambient tool-call services; this is the facade's `send(text, { agentId })`. - */ - readonly continueSubagent: ( - input: ContinueSubagentInput, - ) => Effect.Effect -} - -/** - * Subagents service tag. Provided to tool handlers as an ambient per-call service by ToolRuntime (the - * subagentTool handler yields it); preset tests substitute a scripted implementation here. - */ -export class Subagents extends Context.Service()('fold/Subagents') {} diff --git a/packages/fold-core/src/SystemPrompt/SystemPromptLayer.ts b/packages/fold-core/src/SystemPrompt/SystemPromptLayer.ts index 0449318..831449c 100644 --- a/packages/fold-core/src/SystemPrompt/SystemPromptLayer.ts +++ b/packages/fold-core/src/SystemPrompt/SystemPromptLayer.ts @@ -1,7 +1,7 @@ /** * This file implements the default SystemPrompt layer - family-keyed base prompt selection plus the * agent's own blocks (D17). fold-core ships no baked base prompts yet, so the default layer composes - * the agent blocks unchanged; presets and hosts inject family base prompts through `makeSystemPrompt`. + * the agent blocks unchanged; presets and hosts inject family base prompts through `layerSystemPrompt`. */ import { Effect, Layer } from 'effect' @@ -9,13 +9,13 @@ import { modelFamilyFor, type ModelFamily } from '../Model/ModelFamily' import { SystemPrompt, type ComposeSystemPromptInput } from './SystemPromptService' /** Options for the default SystemPrompt implementation. */ -export type MakeSystemPromptOptions = { +export type SystemPromptOptions = { /** Family-keyed base prompts, rendered as the first block when the active model's family has one. */ readonly basePrompts?: Partial> } /** Build a SystemPrompt layer selecting a family base prompt and appending the agent's blocks. */ -export const makeSystemPrompt = (options?: MakeSystemPromptOptions): Layer.Layer => +export const layerSystemPrompt = (options?: SystemPromptOptions): Layer.Layer => Layer.succeed(SystemPrompt, { compose: Effect.fn('fold.system_prompt.compose')((input: ComposeSystemPromptInput) => Effect.sync(() => { @@ -27,4 +27,4 @@ export const makeSystemPrompt = (options?: MakeSystemPromptOptions): Layer.Layer }) /** Default SystemPrompt layer: no family base prompts, agent blocks pass through unchanged. */ -export const layerDefaultSystemPrompt: Layer.Layer = makeSystemPrompt() +export const layerDefaultSystemPrompt: Layer.Layer = layerSystemPrompt() diff --git a/packages/fold-core/src/ToolRuntime/ModelVisibleErrors.ts b/packages/fold-core/src/ToolRuntime/ModelVisibleErrors.ts index 1960e3a..07807fb 100644 --- a/packages/fold-core/src/ToolRuntime/ModelVisibleErrors.ts +++ b/packages/fold-core/src/ToolRuntime/ModelVisibleErrors.ts @@ -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, Predicate } from 'effect' +import { Cause } from 'effect' const maxModelVisibleErrorMessageLength = 300 @@ -24,30 +24,15 @@ export const truncateModelVisibleErrorMessage = (message: string): string => { return `${singleLine.slice(0, maxModelVisibleErrorMessageLength - 3)}...` } -const stringifyUnknown = (value: unknown): string => { - if (Predicate.isError(value)) return value.message - - try { - return JSON.stringify(value) - } catch { - return String(value) - } -} - -/** Render an unknown thrown/failed value as safe model-visible text. */ -export const modelVisibleErrorDetailsFromUnknown = (value: unknown): string => { - const raw = Predicate.isError(value) ? value.message : stringifyUnknown(value) - - return escapeSystemInformationContent(truncateModelVisibleErrorMessage(raw === '' ? 'unknown error' : raw)) -} - -/** Render the first non-interrupt reason of a Cause as safe model-visible text. */ -export const modelVisibleErrorDetailsFromCause = (cause: Cause.Cause): string => { - const reason = cause.reasons.find((reason) => !Cause.isInterruptReason(reason)) - - if (reason === undefined) return 'unknown error' - if (Cause.isDieReason(reason)) return modelVisibleErrorDetailsFromUnknown(reason.defect) - if (Cause.isFailReason(reason)) return modelVisibleErrorDetailsFromUnknown(reason.error) +/** + * Render the first failure or defect of a Cause as safe model-visible text. Effect's own + * `Cause.prettyErrors` turns each failure/defect into an Error (message kept, strings used as-is, + * other values JSON-rendered), so no raw thrown value is inspected here. Interrupt-only and empty + * causes carry no failure to show. + */ +export const modelVisibleErrorDetailsFromCause = (cause: Cause.Cause): string => { + const first = Cause.hasInterruptsOnly(cause) ? undefined : Cause.prettyErrors(cause)[0] + const message = first === undefined || first.message === '' ? 'unknown error' : first.message - return 'unknown error' + return escapeSystemInformationContent(truncateModelVisibleErrorMessage(message)) } diff --git a/packages/fold-core/src/ToolRuntime/ToolContextServices.ts b/packages/fold-core/src/ToolRuntime/ToolContextServices.ts index 1271020..3e6a656 100644 --- a/packages/fold-core/src/ToolRuntime/ToolContextServices.ts +++ b/packages/fold-core/src/ToolRuntime/ToolContextServices.ts @@ -5,7 +5,7 @@ */ import { Context, Effect, type Schema } from 'effect' -import type { AgentId, ToolCallId } from '../Ids.ts' +import type { AgentId, ToolCallId } from '../Ids' /** UI/progress event emitted by a running tool. This is not the durable final tool result. */ export type ToolRuntimeEvent = { @@ -13,7 +13,7 @@ export type ToolRuntimeEvent = { readonly parentAgentId: AgentId | null readonly toolCallId: ToolCallId readonly toolName: string - readonly payload: typeof Schema.Json.Type + readonly payload: Schema.Json } /** Session-level sink for ephemeral tool progress. ToolRuntime annotates events with runtime identity. */ @@ -34,7 +34,7 @@ export const noopToolEventSink: ToolEventSinkService = { /** Ambient service visible to tool handlers. Tools emit arbitrary JSON progress here. */ export type ToolEventsService = { /** Publish one JSON progress payload from the currently running tool call. */ - readonly emit: (payload: typeof Schema.Json.Type) => Effect.Effect + readonly emit: (payload: Schema.Json) => Effect.Effect } /** Per-call tool progress emitter. ToolRuntime provides this around each handler. */ diff --git a/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts b/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts index 9edc65b..9fc6f46 100644 --- a/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts +++ b/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts @@ -4,15 +4,14 @@ * per-call ToolState, ToolEvents, and StopController services while handlers run, then persists one durable * tool-result entry per call, including synthetic interruption results when a tool fiber is interrupted. */ -import { Data, Match, Predicate, Cause, Effect, Layer, Ref, Schema, Stream } from 'effect' +import { Data, Equal, Match, Predicate, Cause, Effect, Layer, Ref, Schema, Stream, Struct } from 'effect' import { Prompt } from 'effect/unstable/ai' import { EventLog } from '../EventLog/EventLogService' import { LogEntryInputs, type LogEntry, type ToolResultLogEntry } from '../EventLog/Schemas' -import { isHookExecutionError, type HookExecutionError } from '../HookRunner/Errors' +import type { HookExecutionError } from '../HookRunner/Errors' import { HookRunner } from '../HookRunner/HookRunnerService' import { Ids, ToolCallId, type AgentId } from '../Ids' -import { Subagents } from '../Subagents/SubagentsService' import { modelVisibleErrorDetailsFromCause, systemInformation } from './ModelVisibleErrors' import { CurrentAgent, @@ -24,28 +23,25 @@ import { } from './ToolContextServices' import { ToolRuntime, type ToolRuntimeService, type ToolSettlement } from './ToolRuntimeService' import { Toolset, type ToolHandlerOutput } from './ToolsetService' -import { toolStateServiceForHandler } from './ToolStateFactory' +import { toolStateServiceBuilder } from './ToolStateFactory' import { ToolState } from './ToolStateService' type ToolCallPart = Prompt.ToolCallPart -type PreparedToolCall = - | { - readonly _tag: 'execute' - readonly original: ToolCallPart - readonly params: unknown - } - | { - readonly _tag: 'replaceResult' - readonly original: ToolCallPart - readonly result: unknown - readonly isFailure: boolean - } +type PreparedToolCall = Data.TaggedEnum<{ + execute: { + readonly original: ToolCallPart + readonly params: unknown + } + replaceResult: { + readonly original: ToolCallPart + readonly result: unknown + readonly isFailure: boolean + } +}> const PreparedToolCall = Data.taggedEnum() -type Mutable = { -readonly [Key in keyof T]: T[Key] } - type FinalToolOutput = { readonly result: unknown readonly isFailure: boolean @@ -81,7 +77,7 @@ const toolCallsFromAssistantMessage = (assistantMessage: Prompt.AssistantMessage /** Decode and validate the provider-supplied tool call id as a Fold ToolCallId. */ const decodeToolCallId = (toolCall: ToolCallPart): Effect.Effect => - Schema.decodeUnknownEffect(ToolCallId)(toolCall.id).pipe(Effect.orDie) + Schema.decodeEffect(ToolCallId)(toolCall.id).pipe(Effect.orDie) /** Encode one persisted tool-result message in Effect AI's Prompt schema. */ const encodedToolResultMessage = (input: { @@ -90,65 +86,21 @@ const encodedToolResultMessage = (input: { readonly result: unknown readonly isFailure: boolean }) => - Effect.sync(() => - Schema.encodeUnknownSync(Prompt.ToolMessage)( - Prompt.toolMessage({ - content: [ - Prompt.toolResultPart({ - id: input.toolCallId, - name: input.toolName, - result: input.result, - isFailure: input.isFailure, - providerExecuted: false, - }), - ], - }), - ), - ) - -/** Build an appender that writes tool-result entries through EventLog with fresh message ids. */ -const appendToolResultToEventLog = (input: { - readonly agentId: AgentId - readonly parentAgentId: AgentId | null - readonly toolCallId: ToolCallId - readonly toolName: string - readonly result: unknown - readonly isFailure: boolean - readonly executedInput?: unknown -}): Effect.Effect => - Effect.gen(function* () { - const eventLog = yield* EventLog - const ids = yield* Ids - const message = yield* encodedToolResultMessage(input) - const entryInput: Mutable[0]> = { - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - messageId: yield* ids.makeMessageId, - message, - } - if (input.executedInput !== undefined) { - entryInput.executedInput = input.executedInput - } - - const entry = yield* eventLog.append(LogEntryInputs['tool-result'](entryInput)).pipe(Effect.orDie) - - if (Predicate.isTagged(entry, 'tool-result')) return entry - - // Invariant! - return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending tool-result`)) - }) - -/** Compare unknown values by stable JSON representation, falling back to object identity when needed. */ -const valuesHaveSameJsonRepresentation = (left: unknown, right: unknown): boolean => { - try { - return JSON.stringify(left) === JSON.stringify(right) - } catch { - return Object.is(left, right) - } -} + Schema.encodeEffect(Prompt.ToolMessage)( + Prompt.toolMessage({ + content: [ + Prompt.toolResultPart({ + id: input.toolCallId, + name: input.toolName, + result: input.result, + isFailure: input.isFailure, + providerExecuted: false, + }), + ], + }), + ).pipe(Effect.orDie) -const unexpectedToolFailureResult = (toolName: string, cause: Cause.Cause): string => +const unexpectedToolFailureResult = (toolName: string, cause: Cause.Cause): string => systemInformation(`Tool "${toolName}" failed unexpectedly: ${modelVisibleErrorDetailsFromCause(cause)}`) const hookFailureResult = (error: HookExecutionError, toolName: string): string => { @@ -160,22 +112,8 @@ const hookFailureResult = (error: HookExecutionError, toolName: string): string ) } -const hookExecutionErrorFromCause = (cause: Cause.Cause): HookExecutionError | undefined => { - const reason = cause.reasons.find(Cause.isFailReason) - - return isHookExecutionError(reason?.error) ? reason.error : undefined -} - -const failureResultFromCause = (toolName: string, cause: Cause.Cause): string => { - const hookError = hookExecutionErrorFromCause(cause) - - return hookError === undefined - ? unexpectedToolFailureResult(toolName, cause) - : hookFailureResult(hookError, toolName) -} - /** Build the final handler output used when a tool handler fails while streaming. */ -const failedToolHandlerOutput = (toolName: string, cause: Cause.Cause): ToolHandlerOutput => ({ +const failedToolHandlerOutput = (toolName: string, cause: Cause.Cause): ToolHandlerOutput => ({ result: unexpectedToolFailureResult(toolName, cause), encodedResult: unexpectedToolFailureResult(toolName, cause), isFailure: true, @@ -183,9 +121,9 @@ const failedToolHandlerOutput = (toolName: string, cause: Cause.Cause): }) /** Build the handler output for a failed or interrupted handler stream, reading the interrupt note. */ -const failedOrInterruptedToolHandlerOutput = ( +const failedOrInterruptedToolHandlerOutput = ( toolName: string, - cause: Cause.Cause, + cause: Cause.Cause, interruptNoteRef: Ref.Ref, ): Effect.Effect => Cause.hasInterrupts(cause) @@ -197,51 +135,6 @@ const failedOrInterruptedToolHandlerOutput = ( ) : Effect.succeed(failedToolHandlerOutput(toolName, cause)) -/** Apply pre-tool hooks and return either an executable call or a replacement result. */ -const toolCallPreparedByPreToolHooks = (input: { - readonly agentId: AgentId - readonly parentAgentId: AgentId | null - readonly toolCall: ToolCallPart -}): Effect.Effect => - Effect.gen(function* () { - const hooks = yield* HookRunner - const toolCallId = yield* decodeToolCallId(input.toolCall) - - const decision = yield* hooks.preToolUse({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId, - toolName: input.toolCall.name, - params: input.toolCall.params, - }) - - return Match.valueTags(decision, { - replaceResult: (replacement) => - PreparedToolCall.replaceResult({ - original: input.toolCall, - result: replacement.result, - isFailure: replacement.isFailure, - }), - continue: (continuation) => - PreparedToolCall.execute({ - original: input.toolCall, - params: continuation.params, - }), - }) - }).pipe( - Effect.catchCause((cause) => - Effect.succeed( - PreparedToolCall.replaceResult({ - original: input.toolCall, - result: Cause.hasInterrupts(cause) - ? interruptedToolResult - : failureResultFromCause(input.toolCall.name, cause), - isFailure: true, - }), - ), - ), - ) - /** Select the final non-preliminary handler output, or synthesize a failure when none exists. */ const finalToolOutputFromHandlerOutputs = ( outputs: ReadonlyArray, @@ -264,350 +157,387 @@ const finalToolOutputFromHandlerOutputs = ( } } -/** Forward one preliminary handler output to the session ToolEventSink as JSON progress. */ -const emitPreliminaryToolOutput = (input: { - readonly agentId: AgentId - readonly parentAgentId: AgentId | null - readonly toolCallId: ToolCallId - readonly toolName: string - readonly output: ToolHandlerOutput -}): Effect.Effect => { - if (!input.output.preliminary) return Effect.void - - return Effect.gen(function* () { - const sink = yield* ToolEventSink - const payload = yield* Effect.sync(() => Schema.decodeUnknownSync(Schema.Json)(input.output.encodedResult)) - - yield* sink.emit({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - toolName: input.toolName, - payload, - }) - }) -} +type SettleToolCallsInput = Parameters[0] -/** Execute one prepared tool handler and reduce its output stream to a final persisted output. */ -const finalOutputFromToolHandler = (input: { - readonly agentId: AgentId - readonly parentAgentId: AgentId | null - readonly toolCallId: ToolCallId - readonly toolName: string - readonly params: unknown - readonly interruptNoteRef: Ref.Ref -}): Effect.Effect< - FinalToolOutput, +/** + * Live ToolRuntime layer. It captures EventLog, Ids, hooks, the Toolset, and the progress sink once at + * construction; settlement closes over them and only provides the per-call services it creates. + */ +export const liveToolRuntimeLayer: Layer.Layer< + ToolRuntime, never, - | Toolset - | ToolEventSink - | ToolState - | ToolEvents - | StopController - | CurrentAgent - | CurrentToolCall - | InterruptNote - | Subagents -> => + EventLog | Ids | HookRunner | Toolset | ToolEventSink +> = Layer.effect( + ToolRuntime, Effect.gen(function* () { + const eventLog = yield* EventLog + const ids = yield* Ids + const hooks = yield* HookRunner const toolset = yield* Toolset - const stream = yield* toolset - .handle(input.toolName, input.params) - .pipe( + const sink = yield* ToolEventSink + const toolStateServiceForHandler = yield* toolStateServiceBuilder + + /** Append one tool-result entry through EventLog with a fresh message id. */ + const appendToolResultToEventLog = Effect.fn('fold.tool_runtime.append_tool_result')(function* (input: { + readonly agentId: AgentId + readonly parentAgentId: AgentId | null + readonly toolCallId: ToolCallId + readonly toolName: string + readonly result: unknown + readonly isFailure: boolean + readonly executedInput?: unknown + }) { + const message = yield* encodedToolResultMessage(input) + const entryInput = LogEntryInputs['tool-result']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + messageId: yield* ids.makeMessageId, + message, + ...Struct.pick(input, ['executedInput']), + }) + + const entry = yield* eventLog.append(entryInput).pipe(Effect.orDie) + + if (Predicate.isTagged(entry, 'tool-result')) return entry + + // Invariant! + return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending tool-result`)) + }) + + /** Apply pre-tool hooks and return either an executable call or a replacement result. */ + const toolCallPreparedByPreToolHooks = (input: { + readonly agentId: AgentId + readonly parentAgentId: AgentId | null + readonly toolCall: ToolCallPart + }): Effect.Effect => + Effect.gen(function* () { + const toolCallId = yield* decodeToolCallId(input.toolCall) + + const decision = yield* hooks.preToolUse({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId, + toolName: input.toolCall.name, + params: input.toolCall.params, + }) + + return Match.valueTags(decision, { + replaceResult: (replacement) => + PreparedToolCall.replaceResult({ + original: input.toolCall, + result: replacement.result, + isFailure: replacement.isFailure, + }), + continue: (continuation) => + PreparedToolCall.execute({ + original: input.toolCall, + params: continuation.params, + }), + }) + }).pipe( + Effect.catchTag('HookExecutionError', (error) => + Effect.succeed( + PreparedToolCall.replaceResult({ + original: input.toolCall, + result: hookFailureResult(error, input.toolCall.name), + isFailure: true, + }), + ), + ), Effect.catchCause((cause) => - failedOrInterruptedToolHandlerOutput(input.toolName, cause, input.interruptNoteRef).pipe( - Effect.map(Stream.succeed), + Effect.succeed( + PreparedToolCall.replaceResult({ + original: input.toolCall, + result: Cause.hasInterrupts(cause) + ? interruptedToolResult + : unexpectedToolFailureResult(input.toolCall.name, cause), + isFailure: true, + }), ), ), ) - const collectHandlerOutputs = stream.pipe( - Stream.tap((output) => - emitPreliminaryToolOutput({ + /** Forward one preliminary handler output to the session ToolEventSink as JSON progress. */ + const emitPreliminaryToolOutput = (input: { + readonly agentId: AgentId + readonly parentAgentId: AgentId | null + readonly toolCallId: ToolCallId + readonly toolName: string + readonly output: ToolHandlerOutput + }): Effect.Effect => { + if (!input.output.preliminary) return Effect.void + + return Effect.gen(function* () { + const payload = yield* Schema.decodeUnknownEffect(Schema.Json)(input.output.encodedResult).pipe( + Effect.orDie, + ) + + yield* sink.emit({ agentId: input.agentId, parentAgentId: input.parentAgentId, toolCallId: input.toolCallId, toolName: input.toolName, - output, - }), - ), - Stream.runCollect, - ) - - const outputs = yield* collectHandlerOutputs.pipe( - Effect.catchCause((cause) => - failedOrInterruptedToolHandlerOutput(input.toolName, cause, input.interruptNoteRef).pipe( - Effect.map((output): ReadonlyArray => [output]), - ), - ), - ) - - return finalToolOutputFromHandlerOutputs(outputs, input.toolName) - }) - -/** Apply post-tool hooks to a successful final output, leaving failures untouched. */ -const finalOutputAfterPostToolHooks = (input: { - readonly agentId: AgentId - readonly parentAgentId: AgentId | null - readonly toolCallId: ToolCallId - readonly toolName: string - readonly originalInput: unknown - readonly executedInput: unknown - readonly output: FinalToolOutput -}): Effect.Effect => - Effect.gen(function* () { - if (input.output.isFailure) return input.output + payload, + }) + }) + } - const hooks = yield* HookRunner - const decision = yield* hooks.postToolUse({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - toolName: input.toolName, - originalInput: input.originalInput, - executedInput: input.executedInput, - handlerResult: input.output.result, - result: input.output.result, - isFailure: input.output.isFailure, - }) + /** Execute one prepared tool handler and reduce its output stream to a final persisted output. */ + const finalOutputFromToolHandler = (input: { + readonly agentId: AgentId + readonly parentAgentId: AgentId | null + readonly toolCallId: ToolCallId + readonly toolName: string + readonly params: unknown + readonly interruptNoteRef: Ref.Ref + }): Effect.Effect< + FinalToolOutput, + never, + ToolState | ToolEvents | StopController | CurrentAgent | CurrentToolCall | InterruptNote + > => + Effect.gen(function* () { + const stream = yield* toolset + .handle(input.toolName, input.params) + .pipe( + Effect.catchCause((cause) => + failedOrInterruptedToolHandlerOutput(input.toolName, cause, input.interruptNoteRef).pipe( + Effect.map(Stream.succeed), + ), + ), + ) + + const collectHandlerOutputs = stream.pipe( + Stream.tap((output) => + emitPreliminaryToolOutput({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + toolName: input.toolName, + output, + }), + ), + Stream.runCollect, + ) + + const outputs = yield* collectHandlerOutputs.pipe( + Effect.catchCause((cause) => + failedOrInterruptedToolHandlerOutput(input.toolName, cause, input.interruptNoteRef).pipe( + Effect.map((output): ReadonlyArray => [output]), + ), + ), + ) - return Match.valueTags(decision, { - keep: () => input.output, - replace: ({ isFailure, result }) => ({ result, isFailure }), - }) - }) - -/** Execute or replace one prepared tool call and persist exactly one tool-result entry. */ -const settlePreparedToolCall = (input: { - readonly agentId: AgentId - readonly parentAgentId: AgentId | null - readonly prepared: PreparedToolCall - readonly stopRef: Ref.Ref - readonly stateSnapshot: ReadonlyArray -}): Effect.Effect => - Effect.gen(function* () { - const toolCallId = yield* decodeToolCallId(input.prepared.original) - const toolName = input.prepared.original.name - const sink = yield* ToolEventSink + return finalToolOutputFromHandlerOutputs(outputs, input.toolName) + }) - const toolState = yield* toolStateServiceForHandler({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId, - snapshot: input.stateSnapshot, - }) + /** Apply post-tool hooks to a successful final output, leaving failures untouched. */ + const finalOutputAfterPostToolHooks = (input: { + readonly agentId: AgentId + readonly parentAgentId: AgentId | null + readonly toolCallId: ToolCallId + readonly toolName: string + readonly originalInput: unknown + readonly executedInput: unknown + readonly output: FinalToolOutput + }): Effect.Effect => + Effect.gen(function* () { + if (input.output.isFailure) return input.output + + const decision = yield* hooks.postToolUse({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + toolName: input.toolName, + originalInput: input.originalInput, + executedInput: input.executedInput, + handlerResult: input.output.result, + result: input.output.result, + isFailure: input.output.isFailure, + }) + + return Match.valueTags(decision, { + keep: () => input.output, + replace: ({ isFailure, result }) => ({ result, isFailure }), + }) + }) - const toolEvents = { - /** Annotate and forward one JSON progress payload from the running handler. */ - emit: (payload: typeof Schema.Json.Type) => - sink.emit({ + /** Execute or replace one prepared tool call and persist exactly one tool-result entry. */ + const settlePreparedToolCall = (input: { + readonly agentId: AgentId + readonly parentAgentId: AgentId | null + readonly prepared: PreparedToolCall + readonly stopRef: Ref.Ref + readonly stateSnapshot: ReadonlyArray + }): Effect.Effect => + Effect.gen(function* () { + const toolCallId = yield* decodeToolCallId(input.prepared.original) + const toolName = input.prepared.original.name + + const toolState = yield* toolStateServiceForHandler({ agentId: input.agentId, parentAgentId: input.parentAgentId, toolCallId, - toolName, - payload, - }), - } - - const stopController = { - /** Record a cooperative stop request shared by all concurrently running tool calls. */ - requestStop: (reason: string) => Ref.set(input.stopRef, reason), - /** Report whether any tool or hook has requested a cooperative stop. */ - isStopRequested: Ref.get(input.stopRef).pipe(Effect.map((reason) => reason !== null)), - } - - // The per-call interrupt note: handlers set it as soon as resumable/externally-visible work - // exists, and finalizers may refine it during unwind; interrupt synthesis below reads it last. - const interruptNoteRef = yield* Ref.make(null) - const interruptNote = { - /** Record the note appended inside this call's synthetic result if it is interrupted. */ - set: (note: string) => Ref.set(interruptNoteRef, note), - } + snapshot: input.stateSnapshot, + }) + + const toolEvents = { + /** Annotate and forward one JSON progress payload from the running handler. */ + emit: (payload: Schema.Json) => + sink.emit({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId, + toolName, + payload, + }), + } - const output: Effect.Effect< - ToolResultAppendInput, - HookExecutionError, - | HookRunner - | StopController - | Toolset - | ToolEventSink - | ToolState - | ToolEvents - | CurrentAgent - | CurrentToolCall - | InterruptNote - | Subagents - > = Effect.gen(function* () { - if (Predicate.isTagged(input.prepared, 'replaceResult')) { - return { - result: input.prepared.result, - isFailure: input.prepared.isFailure, + const stopController = { + /** Record a cooperative stop request shared by all concurrently running tool calls. */ + requestStop: (reason: string) => Ref.set(input.stopRef, reason), + /** Report whether any tool or hook has requested a cooperative stop. */ + isStopRequested: Ref.get(input.stopRef).pipe(Effect.map((reason) => reason !== null)), } - } - const handlerOutput = yield* finalOutputFromToolHandler({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId, - toolName, - params: input.prepared.params, - interruptNoteRef, - }) + // The per-call interrupt note: handlers set it as soon as resumable/externally-visible work + // exists, and finalizers may refine it during unwind; interrupt synthesis below reads it last. + const interruptNoteRef = yield* Ref.make(null) + const interruptNote = { + /** Record the note appended inside this call's synthetic result if it is interrupted. */ + set: (note: string) => Ref.set(interruptNoteRef, note), + } - const finalOutput = yield* finalOutputAfterPostToolHooks({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId, - toolName, - originalInput: input.prepared.original.params, - executedInput: input.prepared.params, - output: handlerOutput, + const output: Effect.Effect< + ToolResultAppendInput, + HookExecutionError, + StopController | ToolState | ToolEvents | CurrentAgent | CurrentToolCall | InterruptNote + > = Effect.gen(function* () { + if (Predicate.isTagged(input.prepared, 'replaceResult')) { + return { + result: input.prepared.result, + isFailure: input.prepared.isFailure, + } + } + + const handlerOutput = yield* finalOutputFromToolHandler({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId, + toolName, + params: input.prepared.params, + interruptNoteRef, + }) + + const finalOutput = yield* finalOutputAfterPostToolHooks({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId, + toolName, + originalInput: input.prepared.original.params, + executedInput: input.prepared.params, + output: handlerOutput, + }) + + const result: ToolResultAppendInput = { + result: finalOutput.result, + isFailure: finalOutput.isFailure, + } + // The executed input is recorded only when a hook rewrote what the model sent. + return Equal.equals(input.prepared.original.params, input.prepared.params) + ? result + : { ...result, executedInput: input.prepared.params } + }) + + const append = (result: ToolResultAppendInput) => + appendToolResultToEventLog({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId, + toolName, + ...result, + }) + + const runnable = output.pipe( + Effect.provideService(ToolState, toolState), + Effect.provideService(ToolEvents, toolEvents), + Effect.provideService(StopController, stopController), + Effect.provideService(CurrentAgent, { agentId: input.agentId, parentAgentId: input.parentAgentId }), + Effect.provideService(CurrentToolCall, { toolCallId }), + Effect.provideService(InterruptNote, interruptNote), + ) + + return yield* Effect.uninterruptibleMask((restore) => + restore(runnable).pipe( + Effect.catchTag('HookExecutionError', (error) => + Effect.succeed({ result: hookFailureResult(error, toolName), isFailure: true }), + ), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? // Handler finalizers already ran during unwind, so the note carries their final + // word (the subagent tool's agent_id + turn count, bash's spill-file path). + Ref.get(interruptNoteRef).pipe( + Effect.map((note) => ({ + result: interruptedToolResultWithNote(note), + isFailure: true, + })), + ) + : Effect.succeed({ + result: unexpectedToolFailureResult(toolName, cause), + isFailure: true, + }), + ), + Effect.flatMap(append), + ), + ) }) - const result: Mutable = { - result: finalOutput.result, - isFailure: finalOutput.isFailure, - } - if (!valuesHaveSameJsonRepresentation(input.prepared.original.params, input.prepared.params)) { - result.executedInput = input.prepared.params - } - - return result - }) - - const append = (result: ToolResultAppendInput) => { - const appendInput: Mutable[0]> = { - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId, - toolName, - result: result.result, - isFailure: result.isFailure, - } - if (result.executedInput !== undefined) { - appendInput.executedInput = result.executedInput + /** Settle every tool call in one assistant message and report whether a stop was requested. */ + const settle = Effect.fn('fold.tool_runtime.settle')(function* (input: SettleToolCallsInput) { + yield* Effect.annotateCurrentSpan({ agentId: input.agentId, parentAgentId: input.parentAgentId ?? 'none' }) + const stopRef = yield* Ref.make(null) + const stopController = { + requestStop: (reason: string) => Ref.set(stopRef, reason), + isStopRequested: Ref.get(stopRef).pipe(Effect.map((reason) => reason !== null)), } + const toolCalls = toolCallsFromAssistantMessage(input.assistantMessage) + + const prepared = yield* Effect.forEach( + toolCalls, + (toolCall) => + toolCallPreparedByPreToolHooks({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCall, + }).pipe(Effect.provideService(StopController, stopController)), + { concurrency: 1 }, + ) - return appendToolResultToEventLog(appendInput) - } - - const runnable = output.pipe( - Effect.provideService(ToolState, toolState), - Effect.provideService(ToolEvents, toolEvents), - Effect.provideService(StopController, stopController), - Effect.provideService(CurrentAgent, { agentId: input.agentId, parentAgentId: input.parentAgentId }), - Effect.provideService(CurrentToolCall, { toolCallId }), - Effect.provideService(InterruptNote, interruptNote), - ) - - return yield* Effect.uninterruptibleMask((restore) => - restore(runnable).pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? // Handler finalizers already ran during unwind, so the note carries their final - // word (the subagent tool's agent_id + turn count, bash's spill-file path). - Ref.get(interruptNoteRef).pipe( - Effect.map((note) => ({ - result: interruptedToolResultWithNote(note), - isFailure: true, - })), - ) - : Effect.succeed({ - result: failureResultFromCause(toolName, cause), - isFailure: true, - }), - ), - Effect.flatMap(append), - ), - ) - }) - -type SettleToolCallsInput = Parameters[0] - -/** Settle every tool call in one assistant message and report whether a stop was requested. */ -const settleToolCalls = ( - input: SettleToolCallsInput, -): Effect.Effect => - Effect.gen(function* () { - const stopRef = yield* Ref.make(null) - const stopController = { - requestStop: (reason: string) => Ref.set(stopRef, reason), - isStopRequested: Ref.get(stopRef).pipe(Effect.map((reason) => reason !== null)), - } - const toolCalls = toolCallsFromAssistantMessage(input.assistantMessage) - - const prepared = yield* Effect.forEach( - toolCalls, - (toolCall) => - toolCallPreparedByPreToolHooks({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCall, - }).pipe(Effect.provideService(StopController, stopController)), - { concurrency: 1 }, - ) - - // Snapshot state once at the fork point, after all preToolUse chains and before any handler runs, - // so parallel handlers read a stable view instead of each other's mid-batch writes. - const eventLog = yield* EventLog - const stateSnapshot = yield* Stream.runCollect(eventLog.entries()).pipe( - Effect.orDie, - Effect.map((entries): ReadonlyArray => entries), - ) - - const toolResults = yield* Effect.forEach( - prepared, - (preparedCall) => - settlePreparedToolCall({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - prepared: preparedCall, - stopRef, - stateSnapshot, - }), - { concurrency: 'unbounded' }, - ) + // Snapshot state once at the fork point, after all preToolUse chains and before any handler runs, + // so parallel handlers read a stable view instead of each other's mid-batch writes. + const stateSnapshot = yield* Stream.runCollect(eventLog.entries()).pipe( + Effect.orDie, + Effect.map((entries): ReadonlyArray => entries), + ) - const stopRequested = yield* Ref.get(stopRef).pipe(Effect.map((reason) => reason !== null)) + const toolResults = yield* Effect.forEach( + prepared, + (preparedCall) => + settlePreparedToolCall({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + prepared: preparedCall, + stopRef, + stateSnapshot, + }), + { concurrency: 'unbounded' }, + ) - return { - toolResults, - stopRequested, - } - }).pipe( - Effect.withSpan('fold.tool_runtime.settle', { - attributes: { - agentId: input.agentId, - parentAgentId: input.parentAgentId ?? 'none', - }, - }), - ) + const stopRequested = yield* Ref.get(stopRef).pipe(Effect.map((reason) => reason !== null)) -/** Live ToolRuntime layer that wires EventLog, Ids, hooks, tools, and progress sinks into settlement. */ -export const liveToolRuntimeLayer: Layer.Layer< - ToolRuntime, - never, - EventLog | Ids | HookRunner | Toolset | ToolEventSink | Subagents -> = Layer.effect( - ToolRuntime, - Effect.gen(function* () { - const eventLog = yield* EventLog - const ids = yield* Ids - const hooks = yield* HookRunner - const toolset = yield* Toolset - const sink = yield* ToolEventSink - const subagents = yield* Subagents - - const settle: ToolRuntimeService['settle'] = Effect.fn('fold.tool_runtime.settle')((input) => - settleToolCalls(input).pipe( - Effect.provideService(EventLog, eventLog), - Effect.provideService(Ids, ids), - Effect.provideService(HookRunner, hooks), - Effect.provideService(Toolset, toolset), - Effect.provideService(ToolEventSink, sink), - Effect.provideService(Subagents, subagents), - ), - ) + const settlement: ToolSettlement = { toolResults, stopRequested } + return settlement + }) return { settle } }), diff --git a/packages/fold-core/src/ToolRuntime/ToolRuntimeService.ts b/packages/fold-core/src/ToolRuntime/ToolRuntimeService.ts index 3ea1675..07002bc 100644 --- a/packages/fold-core/src/ToolRuntime/ToolRuntimeService.ts +++ b/packages/fold-core/src/ToolRuntime/ToolRuntimeService.ts @@ -8,8 +8,8 @@ import { Context } from 'effect' import type { Effect } from 'effect' import type { Prompt } from 'effect/unstable/ai' -import type { ToolResultLogEntry } from '../EventLog/Schemas.ts' -import type { AgentId } from '../Ids.ts' +import type { ToolResultLogEntry } from '../EventLog/Schemas' +import type { AgentId } from '../Ids' /** Result of settling all tool calls from one persisted assistant message. */ export type ToolSettlement = { diff --git a/packages/fold-core/src/ToolRuntime/ToolStateFactory.ts b/packages/fold-core/src/ToolRuntime/ToolStateFactory.ts index e32943d..17dd451 100644 --- a/packages/fold-core/src/ToolRuntime/ToolStateFactory.ts +++ b/packages/fold-core/src/ToolRuntime/ToolStateFactory.ts @@ -5,8 +5,9 @@ * as of the handler fork point plus the call's own writes, so parallel sibling calls cannot leak * mid-batch state into each other. Both variants append durable tool_state entries immediately. */ -import { Effect, Ref, Stream } from 'effect' +import { Effect, Ref, type Schema, Stream } from 'effect' +import type { EventLogError } from '../EventLog/Errors' import { EventLog, type EventLogService } from '../EventLog/EventLogService' import { LogEntryInputs, type LogEntry } from '../EventLog/Schemas' import { Ids, type AgentId, type IdsService, type ToolCallId } from '../Ids' @@ -15,7 +16,7 @@ import type { ToolStateService } from './ToolStateService' /** Collect every persisted event so ToolState reads can derive the latest value from projections. */ const collectEventLogEntries = Effect.fn('fold.tool_state.collect_entries')( - (entries: Stream.Stream) => + (entries: Stream.Stream) => Stream.runCollect(entries).pipe( Effect.orDie, Effect.map((entries): ReadonlyArray => entries), @@ -98,54 +99,73 @@ export const toolStateServiceForToolCall = ( } }) -/** - * Build the snapshot ToolState service for one tool handler. Reads see the state folded from the given - * snapshot (the log as of the handler fork point, after preToolUse chains) plus this call's own writes, - * both resolved against the namespace supplied per operation; writes made by concurrently running sibling - * calls stay invisible until the next batch. Writes are still appended to the EventLog immediately - they - * are durable facts even if this call is later interrupted. - */ -export const toolStateServiceForHandler = (input: { +type ToolStateForHandlerInput = { readonly agentId: AgentId readonly parentAgentId: AgentId | null readonly toolCallId: ToolCallId readonly snapshot: ReadonlyArray -}): Effect.Effect => - Effect.gen(function* () { - const eventLog = yield* EventLog - const ids = yield* Ids - const ownWrites = yield* Ref.make< - ReadonlyArray<{ readonly namespace: string; readonly key: string; readonly value: unknown }> - >([]) - const appendDurable = appendToolStateEntry(input, eventLog, ids) +} - return { - /** Read one key from the fork-point snapshot overlaid with this call's own writes. */ - get: Effect.fn('fold.tool_state.get')((namespace, key) => - Ref.get(ownWrites).pipe( - Effect.map((writes) => { - const ownWrite = writes.findLast((write) => write.namespace === namespace && write.key === key) - if (ownWrite !== undefined) return ownWrite.value +/** + * Capture EventLog and Ids once and return a builder for per-handler snapshot ToolState services. Reads + * see the state folded from the given snapshot (the log as of the handler fork point, after preToolUse + * chains) plus this call's own writes, both resolved against the namespace supplied per operation; + * writes made by concurrently running sibling calls stay invisible until the next batch. Writes are + * still appended to the EventLog immediately - they are durable facts even if this call is later + * interrupted. + */ +export const toolStateServiceBuilder: Effect.Effect< + (input: ToolStateForHandlerInput) => Effect.Effect, + never, + EventLog | Ids +> = Effect.gen(function* () { + const eventLog = yield* EventLog + const ids = yield* Ids - return toolStateForAgent(input.snapshot, input.agentId, namespace)[key] ?? null - }), - Effect.withSpan('fold.tool_state.get', { - attributes: { - agentId: input.agentId, - toolCallId: input.toolCallId, - namespace, - key, - snapshot: true, - }, - }), + return (input: ToolStateForHandlerInput): Effect.Effect => + Effect.gen(function* () { + const ownWrites = yield* Ref.make< + ReadonlyArray<{ readonly namespace: string; readonly key: string; readonly value: Schema.Json }> + >([]) + const appendDurable = appendToolStateEntry(input, eventLog, ids) + + const service: ToolStateService = { + /** Read one key from the fork-point snapshot overlaid with this call's own writes. */ + get: Effect.fn('fold.tool_state.get')((namespace, key) => + Ref.get(ownWrites).pipe( + Effect.map((writes) => { + const ownWrite = writes.findLast( + (write) => write.namespace === namespace && write.key === key, + ) + if (ownWrite !== undefined) return ownWrite.value + + return toolStateForAgent(input.snapshot, input.agentId, namespace)[key] ?? null + }), + Effect.withSpan('fold.tool_state.get', { + attributes: { + agentId: input.agentId, + toolCallId: input.toolCallId, + namespace, + key, + snapshot: true, + }, + }), + ), ), - ), - /** Persist one value durably and record it in this call's own-writes overlay. */ - set: Effect.fn('fold.tool_state.set')((namespace, key, value) => - appendDurable(namespace, key, value).pipe( - Effect.flatMap(() => Ref.update(ownWrites, (writes) => [...writes, { namespace, key, value }])), + /** Persist one value durably and record it in this call's own-writes overlay. */ + set: Effect.fn('fold.tool_state.set')((namespace, key, value) => + appendDurable(namespace, key, value).pipe( + Effect.flatMap(() => Ref.update(ownWrites, (writes) => [...writes, { namespace, key, value }])), + ), ), - ), - } - }) + } + return service + }) +}) + +/** Build the snapshot ToolState service for one tool handler (see {@link toolStateServiceBuilder}). */ +export const toolStateServiceForHandler = ( + input: ToolStateForHandlerInput, +): Effect.Effect => + toolStateServiceBuilder.pipe(Effect.flatMap((forHandler) => forHandler(input))) diff --git a/packages/fold-core/src/ToolRuntime/ToolStateService.ts b/packages/fold-core/src/ToolRuntime/ToolStateService.ts index 3031123..8444726 100644 --- a/packages/fold-core/src/ToolRuntime/ToolStateService.ts +++ b/packages/fold-core/src/ToolRuntime/ToolStateService.ts @@ -5,7 +5,13 @@ */ import { Context, Effect, Schema } from 'effect' -type ToolStateKeySchemas = Readonly>> +/** A key schema: any service-free codec whose encoded form is JSON, so values persist in tool_state entries. */ +type ToolStateKeySchema = Schema.Top & { + readonly Encoded: Schema.Json + readonly DecodingServices: never + readonly EncodingServices: never +} +type ToolStateKeySchemas = Readonly> type ToolStateKey = Extract type ToolStateValue> = Keys[Key]['Type'] @@ -15,10 +21,10 @@ type ToolStateValue Effect.Effect - /** Write or clear a raw state value by namespace and key. */ - readonly set: (namespace: string, key: string, value: unknown) => Effect.Effect + /** Read an encoded state value by namespace and key, returning null when no value has been written. */ + readonly get: (namespace: string, key: string) => Effect.Effect + /** Write (encoded) or clear (null) a state value by namespace and key. */ + readonly set: (namespace: string, key: string, value: Schema.Json) => Effect.Effect } /** Typed ToolState namespace definition for a tool or hook. */ @@ -56,11 +62,17 @@ export const defineToolState = (input: { return schema } - /** Decode one candidate value with the schema declared for the requested key. */ + /** Decode one persisted value with the schema declared for the requested key. */ const decodeValue = >( key: Key, - value: unknown, - ): Effect.Effect> => Effect.sync(() => Schema.decodeUnknownSync(schemaFor(key))(value)) + encoded: Schema.Json, + ): Effect.Effect> => Schema.decodeEffect(schemaFor(key))(encoded).pipe(Effect.orDie) + + /** Validate and encode one value with the schema declared for the requested key. */ + const encodeValue = >( + key: Key, + value: ToolStateValue, + ): Effect.Effect => Schema.encodeEffect(schemaFor(key))(value).pipe(Effect.orDie) return { namespace: input.namespace, @@ -86,8 +98,8 @@ export const defineToolState = (input: { Effect.flatMap((state) => { if (value === null) return state.set(input.namespace, key, null) - return decodeValue(key, value).pipe( - Effect.flatMap((decoded) => state.set(input.namespace, key, decoded)), + return encodeValue(key, value).pipe( + Effect.flatMap((encoded) => state.set(input.namespace, key, encoded)), ) }), ), diff --git a/packages/fold-core/src/ToolRuntime/ToolsetFactory.ts b/packages/fold-core/src/ToolRuntime/ToolsetFactory.ts index f4f812b..4748127 100644 --- a/packages/fold-core/src/ToolRuntime/ToolsetFactory.ts +++ b/packages/fold-core/src/ToolRuntime/ToolsetFactory.ts @@ -17,9 +17,9 @@ const unavailableToolFailureOutput = (name: string, names: ReadonlyArray }) /** Build a final failure output when handler lookup or stream creation fails before execution starts. */ -const toolStartupFailureOutput = (name: string, cause: unknown): ToolHandlerOutput => ({ - result: { message: `Tool "${name}" failed before execution.`, cause: String(cause) }, - encodedResult: { message: `Tool "${name}" failed before execution.`, cause: String(cause) }, +const toolStartupFailureOutput = (name: string, reason: string): ToolHandlerOutput => ({ + result: { message: `Tool "${name}" failed before execution.`, cause: reason }, + encodedResult: { message: `Tool "${name}" failed before execution.`, cause: reason }, isFailure: true, preliminary: false, }) @@ -35,7 +35,7 @@ export const toolsetLayerFromToolkit = >( // dispatches model-supplied names at runtime (guarded by `names.includes` below). TypeScript // cannot express dynamic dispatch over a heterogeneous toolkit; the library erases internally // for the same reason (Toolkit.ts: `handle: handle as any`). The one sanctioned assertion. - // oxlint-disable-next-line typescript/consistent-type-assertions + // oxlint-disable-next-line typescript/consistent-type-assertions, automation/no-type-assertion, automation/no-banned-type-assertions, anti-slop/no-chained-type-assertions const withHandlers = (yield* toolkit) as unknown as Toolkit.WithHandler> const names = Object.keys(toolkit.tools) @@ -50,21 +50,19 @@ export const toolsetLayerFromToolkit = >( return Effect.succeed(Stream.succeed(unavailableToolFailureOutput(name, names))) return withHandlers.handle(name, params).pipe( - Effect.matchEffect({ - onFailure: (cause) => Effect.succeed(Stream.succeed(toolStartupFailureOutput(name, cause))), - onSuccess: (stream) => - Effect.succeed( - stream.pipe( - Stream.map((output): ToolHandlerOutput => ({ - result: output.result, - encodedResult: output.encodedResult, - isFailure: output.isFailure, - preliminary: output.preliminary, - })), - Stream.mapError((error): unknown => error), - ), - ), - }), + Effect.map((stream) => + stream.pipe( + Stream.map((output): ToolHandlerOutput => ({ + result: output.result, + encodedResult: output.encodedResult, + isFailure: output.isFailure, + preliminary: output.preliminary, + })), + ), + ), + Effect.catchTag('AiError', (error) => + Effect.succeed(Stream.succeed(toolStartupFailureOutput(name, error.message))), + ), ) }, } diff --git a/packages/fold-core/src/ToolRuntime/ToolsetResolverLayer.ts b/packages/fold-core/src/ToolRuntime/ToolsetResolverLayer.ts index 0231e00..8421d8b 100644 --- a/packages/fold-core/src/ToolRuntime/ToolsetResolverLayer.ts +++ b/packages/fold-core/src/ToolRuntime/ToolsetResolverLayer.ts @@ -12,23 +12,21 @@ import { ToolsetResolver, type ResolveToolsetInput } from './ToolsetResolverServ import { Toolset } from './ToolsetService' /** Default family-exclusion policy: which installed tool names are hidden from each family (D17/D18). */ -export const defaultExcludedToolsByFamily: Record> = { +export const defaultExcludedToolsByFamily = { claude: ['apply_patch'], unknown: ['apply_patch'], gpt: ['write', 'edit'], codex: ['write', 'edit'], -} +} as const satisfies Record> /** Options for the default ToolsetResolver implementation. */ -export type MakeToolsetResolverOptions = { +export type ToolsetResolverOptions = { /** Per-family exclusion overrides, merged over the default policy. */ readonly excludedToolsByFamily?: Partial>> } /** Build a ToolsetResolver layer filtering the installed Toolset by family-exclusion policy. */ -export const makeToolsetResolver = ( - options?: MakeToolsetResolverOptions, -): Layer.Layer => +export const layerToolsetResolver = (options?: ToolsetResolverOptions): Layer.Layer => Layer.effect( ToolsetResolver, Effect.gen(function* () { @@ -49,4 +47,4 @@ export const makeToolsetResolver = ( ) /** Default ToolsetResolver layer with the standard family-exclusion policy. */ -export const liveToolsetResolverLayer: Layer.Layer = makeToolsetResolver() +export const liveToolsetResolverLayer: Layer.Layer = layerToolsetResolver() diff --git a/packages/fold-core/src/ToolRuntime/ToolsetService.ts b/packages/fold-core/src/ToolRuntime/ToolsetService.ts index f119846..c6fdec2 100644 --- a/packages/fold-core/src/ToolRuntime/ToolsetService.ts +++ b/packages/fold-core/src/ToolRuntime/ToolsetService.ts @@ -36,7 +36,7 @@ export type ToolsetService = { */ readonly handle: ( name: string, - params: unknown, + params: Tool.ParametersEncoded, ) => Effect.Effect< Stream.Stream< ToolHandlerOutput, diff --git a/packages/fold-core/src/Tools/Contracts.ts b/packages/fold-core/src/Tools/Contracts.ts index bcd0bc2..c48e9d8 100644 --- a/packages/fold-core/src/Tools/Contracts.ts +++ b/packages/fold-core/src/Tools/Contracts.ts @@ -31,10 +31,10 @@ export type ToolFailure = typeof ToolFailure.Type const ReadParameters = Schema.Struct({ path: Schema.String.annotate({ description: 'Path to the file to read (relative or absolute)' }), - offset: Schema.optionalKey(Schema.Number).annotate({ + offset: Schema.optionalKey(Schema.Finite).annotate({ description: 'Line number to start reading from (1-indexed)', }), - limit: Schema.optionalKey(Schema.Number).annotate({ description: 'Maximum number of lines to read' }), + limit: Schema.optionalKey(Schema.Finite).annotate({ description: 'Maximum number of lines to read' }), }) /** Contract for the read tool (pi port): text head-truncated, images returned as content blocks. */ @@ -83,7 +83,11 @@ const EditPair = Schema.Struct({ const EditParameters = Schema.Struct({ path: Schema.String.annotate({ description: 'Path to the file to edit (relative or absolute)' }), - edits: Schema.optionalKey(Schema.Union([Schema.Array(EditPair), Schema.String])).annotate({ + // Some models stringify the edits array; the JSON-string form decodes to the same array here, so the + // handler only ever sees `ReadonlyArray`. + edits: Schema.optionalKey( + Schema.Union([Schema.Array(EditPair), Schema.fromJsonString(Schema.Array(EditPair))]), + ).annotate({ description: 'One or more targeted replacements. Every oldText matches against the original file content, so ' + 'edits must target disjoint regions.', @@ -164,7 +168,7 @@ const WebFetchParameters = Schema.Struct({ format: Schema.optionalKey(Schema.Literals(['markdown', 'text', 'html'])).annotate({ description: 'Output format. Defaults to markdown; html returns raw HTML; text strips HTML tags.', }), - timeout_seconds: Schema.optionalKey(Schema.Number).annotate({ + timeout_seconds: Schema.optionalKey(Schema.Finite).annotate({ description: 'Request timeout in seconds. Defaults to 30 seconds; capped at 120 seconds.', }), }) @@ -184,11 +188,11 @@ export const webFetchToolContract = { const WebSearchParameters = Schema.Struct({ query: Schema.String.annotate({ description: 'Search query to run against the web.' }), - timeout_seconds: Schema.optionalKey(Schema.Number).annotate({ + timeout_seconds: Schema.optionalKey(Schema.Finite).annotate({ description: 'Request timeout in seconds. Overrides the configured timeout; defaults to 25 seconds when unconfigured.', }), - numResults: Schema.optionalKey(Schema.Number).annotate({ + numResults: Schema.optionalKey(Schema.Finite).annotate({ description: 'Number of search results to return. Defaults to 8; maximum 20.', }), livecrawl: Schema.optionalKey(Schema.Literals(['fallback', 'preferred'])).annotate({ @@ -197,7 +201,7 @@ const WebSearchParameters = Schema.Struct({ type: Schema.optionalKey(Schema.Literals(['auto', 'fast', 'deep'])).annotate({ description: "Search type: 'auto' balances search, 'fast' is quicker, 'deep' is more comprehensive.", }), - contextMaxCharacters: Schema.optionalKey(Schema.Number).annotate({ + contextMaxCharacters: Schema.optionalKey(Schema.Finite).annotate({ description: 'Maximum characters for the LLM-optimized context string. Defaults to 10000; maximum 50000.', }), }) diff --git a/packages/fold-core/src/Tools/EditEngine.ts b/packages/fold-core/src/Tools/EditEngine.ts index 891b1fd..8c94262 100644 --- a/packages/fold-core/src/Tools/EditEngine.ts +++ b/packages/fold-core/src/Tools/EditEngine.ts @@ -6,13 +6,14 @@ * 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 { Array as Arr, Effect, Option, Schema } from 'effect' /** One targeted replacement: exact old text and its replacement. */ -export type EditPair = { - readonly oldText: string - readonly newText: string -} +export const EditPair = Schema.Struct({ + oldText: Schema.String, + newText: Schema.String, +}) +export type EditPair = typeof EditPair.Type /** A model-visible edit failure. `message` strings are pi's, verbatim. */ export class EditEngineError extends Schema.TaggedError()('EditEngineError', { @@ -107,11 +108,7 @@ const applyReplacements = (content: string, replacements: ReadonlyArray => content.match(/[^\n]*\n|[^\n]+/g) ?? [] /** Map a character span in the base content to the inclusive line range it touches. */ -const lineRangeFor = ( - lineOffsets: ReadonlyArray, - matchIndex: number, - matchLength: number, -): { readonly first: number; readonly last: number } => { +const lineRangeFor = (lineOffsets: ReadonlyArray, matchIndex: number, matchLength: number) => { let first = 0 let last = 0 for (let line = 0; line < lineOffsets.length; line += 1) { @@ -298,49 +295,27 @@ 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' -} - /** - * 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. + * Normalize decoded edit-tool input into an edit batch (pi's `prepareEditArguments` + + * `validateEditInput`): the batch form, plus the legacy top-level oldText/newText pair, which appends as + * the final edit. A JSON-string edits array is already decoded to the batch form by the edit tool's + * parameter schema. */ export const normalizeEditInput = (input: { - readonly edits?: ReadonlyArray | string | undefined + readonly edits?: ReadonlyArray | undefined readonly oldText?: string | undefined readonly newText?: string | undefined }): Effect.Effect, EditEngineError> => Effect.gen(function* () { - const invalidEdits = new EditEngineError({ - message: 'Edit tool input is invalid. edits must be an array of {oldText, newText}.', + const { edits: batch = [] } = input + // The legacy single-edit form counts only when both halves are present. + const single = Option.all({ + oldText: Option.fromUndefinedOr(input.oldText), + newText: Option.fromUndefinedOr(input.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 edits: ReadonlyArray = [...batch, ...Option.toArray(single)] - if (edits.length === 0) { + if (Arr.isReadonlyArrayEmpty(edits)) { return yield* new EditEngineError({ message: 'Edit tool input is invalid. edits must contain at least one replacement.', }) diff --git a/packages/fold-core/src/Tools/PatchEngine.ts b/packages/fold-core/src/Tools/PatchEngine.ts index 39f52f4..c7b523a 100644 --- a/packages/fold-core/src/Tools/PatchEngine.ts +++ b/packages/fold-core/src/Tools/PatchEngine.ts @@ -6,7 +6,7 @@ * 4-pass line matcher (exact, rstrip, trim, unicode-fold) - plus clanka's strict superset of accepting * raw git/unified diffs. Failures are typed tagged errors, not defects (contrast clanka's orDie). */ -import { Data, Effect, Match, Schema } from 'effect' +import { Array as Arr, Data, Effect, Match, Schema } from 'effect' /** The patch text could not be parsed into file operations. */ export class PatchParseError extends Schema.TaggedError()('PatchParseError', { @@ -37,15 +37,15 @@ export type PatchChunk = { } /** One parsed file operation. Move is an update carrying `movePath` (opencode/clanka shape). */ -export type PatchOp = - | { readonly _tag: 'add'; readonly path: string; readonly content: string } - | { readonly _tag: 'delete'; readonly path: string } - | { - readonly _tag: 'update' - readonly path: string - readonly movePath: string | null - readonly chunks: ReadonlyArray - } +export type PatchOp = Data.TaggedEnum<{ + add: { readonly path: string; readonly content: string } + delete: { readonly path: string } + update: { + readonly path: string + readonly movePath: string | null + readonly chunks: ReadonlyArray + } +}> const PatchOp = Data.taggedEnum() @@ -115,7 +115,7 @@ export const seekSequence = ( startIndex: number, eof: boolean, ): number => { - if (pattern.length === 0) return -1 + if (Arr.isReadonlyArrayEmpty(pattern)) return -1 for (const compare of matchPasses) { if (eof) { @@ -172,6 +172,57 @@ const feedChunkLine = (state: ChunkParseState, line: string): void => { } } +type ParsedV4AOp = { readonly op: PatchOp; readonly nextIndex: number } + +/** Parse an `*** Add File` section starting at `start`; only `+` lines contribute content. */ +const parseV4AAdd = (lines: ReadonlyArray, start: number, endIndex: number): ParsedV4AOp => { + const path = (lines[start] ?? '').slice(addMarker.length).trim() + const content: Array = [] + let index = start + 1 + while (index < endIndex && !(lines[index] ?? '').startsWith('***')) { + const contentLine = lines[index] ?? '' + if (contentLine.startsWith('+')) content.push(contentLine.slice(1)) + index += 1 + } + return { op: PatchOp.add({ path, content: content.join('\n') }), nextIndex: index } +} + +/** Parse an `*** Update File` section starting at `start`, including an optional move and its hunks. */ +const parseV4AUpdate = (lines: ReadonlyArray, start: number, endIndex: number): ParsedV4AOp => { + const path = (lines[start] ?? '').slice(updateMarker.length).trim() + let index = start + 1 + + let movePath: string | null = null + if (index < endIndex && (lines[index] ?? '').startsWith(moveMarker)) { + movePath = (lines[index] ?? '').slice(moveMarker.length).trim() + index += 1 + } + + const state: ChunkParseState = { chunks: [], current: emptyChunk() } + while (index < endIndex) { + const bodyLine = lines[index] ?? '' + if (bodyLine.trim() === eofMarker) { + state.current.isEndOfFile = true + state.current.touched = true + index += 1 + continue + } + if (bodyLine.startsWith('***')) break + if (bodyLine.startsWith('@@')) { + flushChunk(state) + state.current.context = bodyLine.slice(2).trim() + state.current.touched = true + index += 1 + continue + } + feedChunkLine(state, bodyLine) + index += 1 + } + flushChunk(state) + + return { op: PatchOp.update({ path, movePath, chunks: state.chunks }), nextIndex: index } +} + const parseV4A = (lines: ReadonlyArray): Effect.Effect, PatchParseError> => Effect.gen(function* () { const beginIndex = lines.findIndex((line) => line.trim() === beginMarker) @@ -188,15 +239,9 @@ const parseV4A = (lines: ReadonlyArray): Effect.Effect = [] - index += 1 - while (index < endIndex && !(lines[index] ?? '').startsWith('***')) { - const contentLine = lines[index] ?? '' - if (contentLine.startsWith('+')) content.push(contentLine.slice(1)) - index += 1 - } - ops.push(PatchOp.add({ path, content: content.join('\n') })) + const parsed = parseV4AAdd(lines, index, endIndex) + ops.push(parsed.op) + index = parsed.nextIndex continue } @@ -207,38 +252,9 @@ const parseV4A = (lines: ReadonlyArray): Effect.Effect => { + const header = line.slice('diff --git '.length).match(/^a\/(\S+)\s+b\/(\S+)\s*$/) + if (header === null) { + return Effect.fail(new PatchParseError({ message: `invalid git diff header: ${line}` })) + } + return Effect.succeed({ + fromPath: header[1] ?? null, + toPath: header[2] ?? null, + renameFrom: null, + renameTo: null, + sawHeader: false, + chunks: { chunks: [], current: emptyChunk() }, + }) +} + +/** Apply a `--- ` header, opening a new file when no `diff --git` line preceded it. */ +const withOldFileHeader = (file: GitFileState | null, path: string | null): GitFileState => { + if (file === null) { + return { + fromPath: path, + toPath: null, + renameFrom: null, + renameTo: null, + sawHeader: true, + chunks: { chunks: [], current: emptyChunk() }, + } + } + file.fromPath = path + file.sawHeader = true + return file +} + +const startGitHunk = (state: ChunkParseState, hunk: RegExpMatchArray): void => { + flushChunk(state) + state.current.context = hunk[1]?.trim() ?? '' + state.current.touched = true +} + const parseGitDiff = (lines: ReadonlyArray): Effect.Effect, PatchParseError> => Effect.gen(function* () { const ops: Array = [] @@ -323,7 +377,7 @@ const parseGitDiff = (lines: ReadonlyArray): Effect.Effect): Effect.Effect): Effect.Effect): Effect.Effect): Effect.Effect line.trim() === beginMarker)) { const ops = yield* parseV4A(lines) - if (ops.length === 0) return yield* new PatchParseError({ message: 'patch rejected: empty patch' }) + if (Arr.isReadonlyArrayEmpty(ops)) + return yield* new PatchParseError({ message: 'patch rejected: empty patch' }) return ops } @@ -446,7 +475,7 @@ export const applyChunks = (input: { // A trailing newline parses as a trailing empty line; drop it for matching (references), and the // exactly-one-trailing-newline rule below restores it. - if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop() + if (Arr.isArrayNonEmpty(lines) && lines[lines.length - 1] === '') lines.pop() type Replacement = { readonly index: number @@ -468,7 +497,7 @@ export const applyChunks = (input: { lineIndex = contextIndex + 1 } - if (chunk.oldLines.length === 0) { + if (Arr.isReadonlyArrayEmpty(chunk.oldLines)) { // Pure insertion: anchored at end of file (all three reference implementations). replacements.push({ index: lines.length, deleteCount: 0, newLines: chunk.newLines }) continue @@ -482,7 +511,8 @@ export const applyChunks = (input: { if (matchIndex === -1 && oldLines[oldLines.length - 1] === '') { oldLines = oldLines.slice(0, -1) if (newLines[newLines.length - 1] === '') newLines = newLines.slice(0, -1) - if (oldLines.length > 0) matchIndex = seekSequence(lines, oldLines, lineIndex, chunk.isEndOfFile) + if (Arr.isReadonlyArrayNonEmpty(oldLines)) + matchIndex = seekSequence(lines, oldLines, lineIndex, chunk.isEndOfFile) } if (matchIndex === -1) { @@ -508,10 +538,11 @@ export const applyChunks = (input: { }) /** One concrete filesystem step the handler performs after a successful dry run. */ -export type PatchStep = - | { readonly _tag: 'write'; readonly path: string; readonly content: string } - | { readonly _tag: 'delete'; readonly path: string } - | { readonly _tag: 'move'; readonly fromPath: string; readonly toPath: string; readonly content: string } +export type PatchStep = Data.TaggedEnum<{ + write: { readonly path: string; readonly content: string } + delete: { readonly path: string } + move: { readonly fromPath: string; readonly toPath: string; readonly content: string } +}> const PatchStep = Data.taggedEnum() diff --git a/packages/fold-core/src/Tools/Truncation.ts b/packages/fold-core/src/Tools/Truncation.ts index 47d37b8..318f6b4 100644 --- a/packages/fold-core/src/Tools/Truncation.ts +++ b/packages/fold-core/src/Tools/Truncation.ts @@ -7,6 +7,7 @@ * one). Truncation happens exactly once, at result creation; the truncated text is then immortal in * log and prompt (the prefix-stability rule). */ +import { Array as Arr } from 'effect' /** Default line limit shared by read and bash truncation (pi parity). */ export const defaultMaxLines = 2000 @@ -83,7 +84,7 @@ export const truncateHead = (text: string, options?: TruncateOptions): Truncatio } } - if (lines.length > 0 && utf8ByteLength(lines[0] ?? '') > maxBytes) { + if (Arr.isReadonlyArrayNonEmpty(lines) && utf8ByteLength(lines[0]) > maxBytes) { return { content: '', truncated: true, @@ -105,7 +106,7 @@ export const truncateHead = (text: string, options?: TruncateOptions): Truncatio break } - const lineBytes = utf8ByteLength(line) + (kept.length > 0 ? 1 : 0) + const lineBytes = utf8ByteLength(line) + (Arr.isArrayNonEmpty(kept) ? 1 : 0) if (bytes + lineBytes > maxBytes) { truncatedBy = 'bytes' break @@ -184,7 +185,7 @@ export const truncateTail = (text: string, options?: TruncateOptions): Truncatio } const line = lines[index] ?? '' - const lineBytes = utf8ByteLength(line) + (kept.length > 0 ? 1 : 0) + const lineBytes = utf8ByteLength(line) + (Arr.isArrayNonEmpty(kept) ? 1 : 0) if (bytes + lineBytes > maxBytes) { truncatedBy = 'bytes' break diff --git a/packages/fold-core/src/index.ts b/packages/fold-core/src/index.ts index daf235c..883be8c 100644 --- a/packages/fold-core/src/index.ts +++ b/packages/fold-core/src/index.ts @@ -13,8 +13,8 @@ export * from './Subagents/Errors' export * from './Subagents/ForkAgentDefinition' export * from './Subagents/Schemas' export * from './Subagents/SubagentDefinition' -export * from './Subagents/SubagentsLayer' -export * from './Subagents/SubagentsService' +export * from './Subagents/SessionAgents' +export * from './Subagents/SubagentEngine' export * from './Subagents/SubagentTool' export * from './AgentEvents/AgentEventsLayer' export * from './AgentEvents/AgentEventsService' @@ -27,6 +27,7 @@ export * from './EventLog/Errors' export * from './EventLog/EventLogLayerMemory' export * from './EventLog/EventLogService' export * from './EventLog/LogEntryFactory' +export * from './EventLog/MessageContent' export * from './EventLog/Schemas' export * from './EventLog/StoredLogEntryDecoder' export * from './EventLog/Usage' diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimeDoomLoop.vi.test.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimeDoomLoop.vi.test.ts index de98962..1cd8d4d 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimeDoomLoop.vi.test.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimeDoomLoop.vi.test.ts @@ -9,7 +9,7 @@ import { agentRuntimeBaseLayer, runInput, startInput } from './AgentRuntimeTestH it.effect('stops gracefully after repeated identical tool-call batches', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const scripted = yield* makeScriptedLanguageModel([ toolCallTurn([{ id: 'provider-call-1', name: 'echo', params: { text: 'loop' } }]), toolCallTurn([{ id: 'provider-call-2', name: 'echo', params: { text: 'loop' } }]), diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimeModelError.vi.test.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimeModelError.vi.test.ts index c731b71..7557931 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimeModelError.vi.test.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimeModelError.vi.test.ts @@ -9,7 +9,7 @@ import { agentRuntimeBaseLayer, runInput, startInput } from './AgentRuntimeTestH it.effect('records a model provider failure as durable facts and resolves the run', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const scripted = yield* makeScriptedLanguageModel([failureTurn('boom: provider unavailable')]) const layer = agentRuntimeBaseLayer(scripted.layer, layerEchoTool(recorder)) diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimeModelSettings.vi.test.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimeModelSettings.vi.test.ts index 2ee3bf4..25db8a8 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimeModelSettings.vi.test.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimeModelSettings.vi.test.ts @@ -13,13 +13,11 @@ import { liveAgentRuntimeLayer, liveModelRequestSettingsLayer, liveToolRuntimeLayer, - makeHookRunner, - makeSessionControls, - makeSystemPrompt, - makeToolsetResolver, + layerHookRunner, + layerSessionControls, + layerSystemPrompt, + layerToolsetResolver, noopToolEventSink, - SessionControls, - Subagents, type SystemPrompt, ToolEventSink, toolsetLayerFromToolkit, @@ -30,14 +28,7 @@ import { layerDeterministicRuntime } from '../TestLayers/DeterministicRuntime' import { makeScriptedLanguageModel, textTurn, type ScriptedRequest } from '../TestLayers/ScriptedLanguageModel' import { layerEchoTool, makeEchoRecorder } from '../TestLayers/TestTools' import { collectEntries } from '../ToolRuntime/ToolRuntimeTestHelpers' -import { - agentId, - agentRuntimeBaseLayer, - noSubagentsStub, - runInput, - startInput, - testModel, -} from './AgentRuntimeTestHelpers' +import { agentId, agentRuntimeBaseLayer, runInput, startInput, testModel } from './AgentRuntimeTestHelpers' const openAiMediumModel: ActiveModel = { providerId: 'openai', @@ -50,7 +41,7 @@ const openAiMediumModel: ActiveModel = { it.effect('applies the projected reasoning to requests and rebinds after a thinking-change', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const scripted = yield* makeScriptedLanguageModel([textTurn('one'), textTurn('two')]) const layer = agentRuntimeBaseLayer(scripted.layer, layerEchoTool(recorder)) @@ -83,7 +74,7 @@ it.effect('applies the projected reasoning to requests and rebinds after a think it.effect('binds the model but sends no reasoning config when the active level is off', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const scripted = yield* makeScriptedLanguageModel([textTurn('one')]) const layer = agentRuntimeBaseLayer(scripted.layer, layerEchoTool(recorder)) @@ -101,7 +92,7 @@ it.effect('binds the model but sends no reasoning config when the active level i it.effect('a tools-change entry rebinds the advertised toolkit on the next request', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const scripted = yield* makeScriptedLanguageModel([textTurn('one'), textTurn('two')]) const layer = agentRuntimeBaseLayer(scripted.layer, layerEchoTool(recorder)) @@ -171,7 +162,7 @@ const familyToolkitLayer = FamilyToolkit.toLayer( }), ) -const familyBasePrompts = makeSystemPrompt({ +const familyBasePrompts = layerSystemPrompt({ basePrompts: { gpt: 'GPT BASE PROMPT', claude: 'CLAUDE BASE PROMPT', @@ -193,13 +184,12 @@ const familyAgentLayer = ( idsLayer, liveAgentEventsLayer, toolsetLayer, - makeToolsetResolver().pipe(Layer.provide(toolsetLayer)), + layerToolsetResolver().pipe(Layer.provide(toolsetLayer)), systemPromptLayer, liveModelRequestSettingsLayer, - makeHookRunner({}).pipe(Layer.provide(Layer.mergeAll(memoryLayer, idsLayer))), + layerHookRunner({}).pipe(Layer.provide(Layer.mergeAll(memoryLayer, idsLayer))), Layer.succeed(ToolEventSink, noopToolEventSink), - Layer.succeed(Subagents, noSubagentsStub), - Layer.effect(SessionControls, makeSessionControls()), + layerSessionControls(), NodeFileSystem.layer, ) @@ -249,7 +239,9 @@ it.effect('openai agents start with the gpt base prompt, apply_patch toolset, an const result = yield* Effect.gen(function* () { const runtime = yield* AgentRuntime - const started = yield* runtime.start(startInput({ model: openAiMediumModel, systemPrompt: 'agent rules' })) + const started = yield* runtime.start( + startInput({ model: openAiMediumModel, systemPrompt: ['agent rules'] }), + ) yield* runtime.run(runInput('go')) const entries = yield* collectEntries @@ -280,7 +272,7 @@ it.effect('anthropic agents start with the claude base prompt, write/edit toolse const runtime = yield* AgentRuntime const started = yield* runtime.start( - startInput({ model: claudeAdaptiveModel, systemPrompt: 'agent rules' }), + startInput({ model: claudeAdaptiveModel, systemPrompt: ['agent rules'] }), ) yield* runtime.run(runInput('go')) const entries = yield* collectEntries @@ -314,7 +306,7 @@ it.effect('switchModel from openai to anthropic rebinds prompt, toolset, and pro const entries = yield* Effect.gen(function* () { const runtime = yield* AgentRuntime - yield* runtime.start(startInput({ model: openAiMediumModel, systemPrompt: 'agent rules' })) + yield* runtime.start(startInput({ model: openAiMediumModel, systemPrompt: ['agent rules'] })) yield* runtime.run(runInput('first')) yield* runtime.switchModel({ @@ -322,7 +314,7 @@ it.effect('switchModel from openai to anthropic rebinds prompt, toolset, and pro parentAgentId: null, toolCallId: null, model: claudeAdaptiveModel, - systemPrompt: 'agent rules', + systemPrompt: ['agent rules'], reason: 'test switches models', }) @@ -367,7 +359,7 @@ it.effect('switchModel appends thinking-change when the requested level changes const entries = yield* Effect.gen(function* () { const runtime = yield* AgentRuntime - yield* runtime.start(startInput({ model: openAiMediumModel, systemPrompt: 'agent rules' })) + yield* runtime.start(startInput({ model: openAiMediumModel, systemPrompt: ['agent rules'] })) yield* runtime.run(runInput('first')) yield* runtime.switchModel({ @@ -375,7 +367,7 @@ it.effect('switchModel appends thinking-change when the requested level changes parentAgentId: null, toolCallId: null, model: openAiHighModel, - systemPrompt: 'agent rules', + systemPrompt: ['agent rules'], reason: 'test raises reasoning', }) @@ -414,7 +406,7 @@ it.effect('switchModel from anthropic to codex flips write/edit to apply_patch', yield* Effect.gen(function* () { const runtime = yield* AgentRuntime - yield* runtime.start(startInput({ model: claudeAdaptiveModel, systemPrompt: 'agent rules' })) + yield* runtime.start(startInput({ model: claudeAdaptiveModel, systemPrompt: ['agent rules'] })) yield* runtime.run(runInput('first')) yield* runtime.switchModel({ @@ -422,7 +414,7 @@ it.effect('switchModel from anthropic to codex flips write/edit to apply_patch', parentAgentId: null, toolCallId: null, model: codexModel, - systemPrompt: 'agent rules', + systemPrompt: ['agent rules'], reason: 'test switches models', }) diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimeOnComplete.vi.test.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimeOnComplete.vi.test.ts index 988e86c..e10cfe1 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimeOnComplete.vi.test.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimeOnComplete.vi.test.ts @@ -9,7 +9,7 @@ import { agentRuntimeBaseLayer, runInput, startInput } from './AgentRuntimeTestH it.effect('onComplete continueWith appends a continuation user message and loops one more turn', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const completions = yield* Ref.make(0) const hooks: HookConfig = { diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimePrefix.vi.test.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimePrefix.vi.test.ts index f186b49..d8a43cc 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimePrefix.vi.test.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimePrefix.vi.test.ts @@ -1,37 +1,37 @@ import { expect, it } from '@effect/vitest' -import { Effect } from 'effect' +import { Array as Arr, Effect, Predicate, Schema } from 'effect' +import { Prompt } from 'effect/unstable/ai' import { AgentRuntime } from '../../src/index' import { makeScriptedLanguageModel, textTurn } from '../TestLayers/ScriptedLanguageModel' import { layerEchoTool, makeEchoRecorder } from '../TestLayers/TestTools' import { agentRuntimeBaseLayer, runInput, startInput } from './AgentRuntimeTestHelpers' -const withoutCacheControl = (value: unknown): unknown => { - if (Array.isArray(value)) return value.map(withoutCacheControl) +/** Drop cache-control metadata and sort object keys so equal prompts encode to identical JSON. */ +const withoutCacheControl = (value: Schema.Json): Schema.Json => { + if (Arr.isArray(value)) return value.map(withoutCacheControl) if (typeof value !== 'object' || value === null) return value - const out: Record = {} - for (const [key, nested] of Object.entries(value)) { + const out: Record = {} + for (const [key, nested] of Object.entries(value).sort(([left], [right]) => left.localeCompare(right))) { if (key === 'cacheControl') continue const normalized = withoutCacheControl(nested) - if (key === 'anthropic' && typeof normalized === 'object' && normalized !== null) { - if (Object.keys(normalized).length === 0) continue - } + if (key === 'anthropic' && Predicate.isObject(normalized) && Arr.isArrayEmpty(Object.keys(normalized))) continue out[key] = normalized } return out } -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 +const encodeMessages = Schema.encodeSync(Schema.Array(Prompt.Message)) +const decodeJson = Schema.decodeUnknownSync(Schema.Json) +const encodeJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Json)) - return Object.fromEntries(Object.entries(nested).sort(([left], [right]) => left.localeCompare(right))) - }) +const stablePromptJson = (messages: ReadonlyArray): string => + encodeJsonString(withoutCacheControl(decodeJson(encodeMessages(messages)))) it.effect('keeps the second request prompt a byte-stable extension of the first', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const scripted = yield* makeScriptedLanguageModel([textTurn('One'), textTurn('Two')]) const layer = agentRuntimeBaseLayer(scripted.layer, layerEchoTool(recorder)) diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimeStreamingDeltas.vi.test.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimeStreamingDeltas.vi.test.ts index 309809f..440a072 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimeStreamingDeltas.vi.test.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimeStreamingDeltas.vi.test.ts @@ -11,7 +11,7 @@ const durableTags = ['agent_started', 'system-message', 'user-message', 'assista it.effect('publishes streamed reasoning/text deltas through AgentEvents without persisting them', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const scripted = yield* makeScriptedLanguageModel([ rawTurn([ { type: 'reasoning-start', id: 'reasoning-1' }, diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimeTestHelpers.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimeTestHelpers.ts index 7701e27..1806890 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimeTestHelpers.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimeTestHelpers.ts @@ -1,5 +1,5 @@ import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' -import { Effect, Layer } from 'effect' +import { Layer } from 'effect' import type { LanguageModel, Tool } from 'effect/unstable/ai' import { @@ -10,13 +10,11 @@ import { liveAgentRuntimeLayer, liveModelRequestSettingsLayer, liveToolRuntimeLayer, - makeHookRunner, - makeSessionControls, - makeToolsetResolver, + layerHookRunner, + layerSessionControls, + layerToolsetResolver, noopToolEventSink, - SessionControls, StopConditions, - Subagents, ToolEventSink, toolsetLayerFromToolkit, type ActiveModel, @@ -24,7 +22,6 @@ import { type RunAgentInput, type StartAgentInput, type StopConditionConfig, - type SubagentsService, } from '../../src/index' import { layerDeterministicRuntime } from '../TestLayers/DeterministicRuntime' import { TestToolkit } from '../TestLayers/TestTools' @@ -51,7 +48,7 @@ export const startInput = (overrides?: Partial): StartAgentInpu skill: null, agentType: null, model: testModel, - systemPrompt: 'You are a test agent.', + systemPrompt: ['You are a test agent.'], ...overrides, }) @@ -63,13 +60,6 @@ export const runInput = (text: string): RunAgentInput => ({ }) /** Die-on-use Subagents stub: the runtime harness tests exercise no subagent dispatches. */ -export const noSubagentsStub: SubagentsService = { - dispatch: () => Effect.die(new Error('Subagents.dispatch not available in this test harness')), - fork: () => Effect.die(new Error('Subagents.fork not available in this test harness')), - resume: () => Effect.die(new Error('Subagents.resume not available in this test harness')), - continueSubagent: () => Effect.die(new Error('Subagents.continueSubagent not available in this test harness')), -} - /** * Real AgentRuntime over real ToolRuntime, EventLog, projections, and the live HookRunner * interpreter. Only true externals vary per test: the scripted model layer, the tool handler @@ -92,14 +82,13 @@ export const agentRuntimeBaseLayer = ( idsLayer, agentEventsLayer, toolsetLayer, - makeToolsetResolver().pipe(Layer.provide(toolsetLayer)), + layerToolsetResolver().pipe(Layer.provide(toolsetLayer)), layerDefaultSystemPrompt, liveModelRequestSettingsLayer, - makeHookRunner(hooks).pipe(Layer.provide(hookDeps)), + layerHookRunner(hooks).pipe(Layer.provide(hookDeps)), Layer.succeed(ToolEventSink, noopToolEventSink), - Layer.succeed(Subagents, noSubagentsStub), Layer.succeed(StopConditions, stopConditions), - Layer.effect(SessionControls, makeSessionControls()), + layerSessionControls(), NodeFileSystem.layer, ) diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimeTextRun.vi.test.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimeTextRun.vi.test.ts index 7182afd..84f9bcf 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimeTextRun.vi.test.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimeTextRun.vi.test.ts @@ -9,7 +9,7 @@ import { agentRuntimeBaseLayer, runInput, startInput } from './AgentRuntimeTestH it.effect('completes a text-only run with the full log shape', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const scripted = yield* makeScriptedLanguageModel([textTurn('Hello!')]) const layer = agentRuntimeBaseLayer(scripted.layer, layerEchoTool(recorder)) @@ -50,7 +50,7 @@ it.effect('completes a text-only run with the full log shape', () => it.effect('persists a multi-block system prompt as one leading entry with one message per block', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const scripted = yield* makeScriptedLanguageModel([textTurn('Hello!')]) const layer = agentRuntimeBaseLayer(scripted.layer, layerEchoTool(recorder)) diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimeToolRun.vi.test.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimeToolRun.vi.test.ts index b271439..0b4eb73 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimeToolRun.vi.test.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimeToolRun.vi.test.ts @@ -10,7 +10,7 @@ import { agentRuntimeBaseLayer, runInput, startInput } from './AgentRuntimeTestH it.effect('runs a tool turn end to end, rewriting and restoring provider tool-call ids', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const scripted = yield* makeScriptedLanguageModel([ toolCallTurn([{ id: 'provider-call-1', name: 'echo', params: { text: 'hi' } }]), textTurn('Tool said hi'), diff --git a/packages/fold-core/test/Api/Provisioning.vi.test.ts b/packages/fold-core/test/Api/Provisioning.vi.test.ts index 095bd81..8bbe604 100644 --- a/packages/fold-core/test/Api/Provisioning.vi.test.ts +++ b/packages/fold-core/test/Api/Provisioning.vi.test.ts @@ -88,7 +88,11 @@ it.live('sends custom authentication and reasoning summary through the OpenAI-co reasoning: { effort: 'medium', summary: 'auto' }, }) }).pipe( - Effect.provide(Layer.merge(languageModelLayerFor(model), liveModelRequestSettingsLayer)), + Effect.provide( + Layer.merge(languageModelLayerFor(model), liveModelRequestSettingsLayer).pipe( + Layer.provide(FetchHttpClient.layer), + ), + ), Effect.provideService(FetchHttpClient.Fetch, capturingFetch), ) }) diff --git a/packages/fold-core/test/Api/StartSession.vi.test.ts b/packages/fold-core/test/Api/StartSession.vi.test.ts index 75a7f05..f742742 100644 --- a/packages/fold-core/test/Api/StartSession.vi.test.ts +++ b/packages/fold-core/test/Api/StartSession.vi.test.ts @@ -14,7 +14,6 @@ import { defineTool, defineToolState, eventLogSource, - platformToolDependencies, startSession, EventLog, layerInMemoryEventLog, @@ -359,7 +358,7 @@ it.effect('runs a tool-calling session without a FileSystem when no descriptor d }).pipe(Effect.scoped), ) -it.effect('tool handlers that declare platform services read the FileSystem the caller provided', () => +it.effect('a tool handler reads the FileSystem the session caller provided', () => Effect.gen(function* () { const { model } = yield* scriptedModel(gptActiveModel, [ toolCallTurn([{ id: 'provider-call-1', name: 'read_note', params: {} }]), @@ -369,7 +368,6 @@ it.effect('tool handlers that declare platform services read the FileSystem the name: 'read_note', description: 'Reads the note file.', success: Schema.String, - dependencies: platformToolDependencies, handler: () => Effect.flatMap(FileSystem.FileSystem, (fs) => fs.readFileString('/note.txt')).pipe(Effect.orDie), }) @@ -399,7 +397,6 @@ it.effect('tool handlers keep the FileSystem the session started with when a tur name: 'read_note', description: 'Reads the note file.', success: Schema.String, - dependencies: platformToolDependencies, handler: () => Effect.flatMap(FileSystem.FileSystem, (fs) => fs.readFileString('/note.txt')).pipe(Effect.orDie), }) diff --git a/packages/fold-core/test/EventLog/EventLogMemory.vi.test.ts b/packages/fold-core/test/EventLog/EventLogMemory.vi.test.ts index b13ee38..882cbfc 100644 --- a/packages/fold-core/test/EventLog/EventLogMemory.vi.test.ts +++ b/packages/fold-core/test/EventLog/EventLogMemory.vi.test.ts @@ -7,7 +7,7 @@ import { EventLogInvalidEntryError, Ids, layerInMemoryEventLogWithIds, - makeStoredLogEntry, + storedLogEntry, type LogEntryInput, } from '../../src/index' import { layerDeterministicRuntime } from '../TestLayers/DeterministicRuntime' @@ -38,12 +38,10 @@ it.effect('the ID service creates branded event IDs', () => }).pipe(Effect.provide(testLayer)), ) -it.effect('stored entries use the supplied event ID service', () => +it.effect('stored entries carry the supplied event ID', () => Effect.gen(function* () { const eventId = EventId.make('event_aaaaaaaaaaaaaaaaaaaaaaaa') - const entry = yield* makeStoredLogEntry(yield* makeSessionStarted('/tmp/one'), 0, { - makeEventId: Effect.succeed(eventId), - }) + const entry = yield* storedLogEntry(yield* makeSessionStarted('/tmp/one'), 0, eventId) expect(entry.eventId).toBe(eventId) }).pipe(Effect.provide(testLayer)), diff --git a/packages/fold-core/test/HookRunner/HookScope.vi.test.ts b/packages/fold-core/test/HookRunner/HookScope.vi.test.ts index 5de1b97..78e6587 100644 --- a/packages/fold-core/test/HookRunner/HookScope.vi.test.ts +++ b/packages/fold-core/test/HookRunner/HookScope.vi.test.ts @@ -6,7 +6,7 @@ import { defineToolState, HookRunner, layerInMemoryEventLog, - makeHookRunner, + layerHookRunner, StopController, ToolCallId, type HookConfig, @@ -21,7 +21,7 @@ const toolCallId = ToolCallId.make('tool_call_aaaaaaaaaaaaaaaaaaaaaaaa') const GuardState = defineToolState({ namespace: 'guard', keys: { - count: Schema.Number, + count: Schema.Finite, }, }) @@ -32,7 +32,7 @@ const hookScopeLayer = (config: HookConfig) => { layerDeterministicRuntime({ startMillis: 1_000, stepMillis: 0 }), ) - return Layer.mergeAll(makeHookRunner(config).pipe(Layer.provide(infra)), infra) + return Layer.mergeAll(layerHookRunner(config).pipe(Layer.provide(infra)), infra) } describe('HookRunner hook scope services', () => { @@ -100,7 +100,7 @@ describe('HookRunner hook scope services', () => { const JudgeState = defineToolState({ namespace: 'judge', keys: { - attempts: Schema.Number, + attempts: Schema.Finite, }, }) diff --git a/packages/fold-core/test/HookRunner/HookStateResume.vi.test.ts b/packages/fold-core/test/HookRunner/HookStateResume.vi.test.ts index 7b28187..3feac6f 100644 --- a/packages/fold-core/test/HookRunner/HookStateResume.vi.test.ts +++ b/packages/fold-core/test/HookRunner/HookStateResume.vi.test.ts @@ -7,7 +7,7 @@ import { EventLog, HookRunner, layerInMemoryEventLog, - makeHookRunner, + layerHookRunner, StateId, StopController, ToolCallId, @@ -23,7 +23,7 @@ const toolCallId = ToolCallId.make('tool_call_aaaaaaaaaaaaaaaaaaaaaaaa') const GuardState = defineToolState({ namespace: 'guard', keys: { - count: Schema.Number, + count: Schema.Finite, }, }) @@ -48,7 +48,7 @@ it.effect('a hook reproduces its state from tool_state entries already persisted layerInMemoryEventLog, layerDeterministicRuntime({ startMillis: 1_000, stepMillis: 0 }), ) - const layer = Layer.mergeAll(makeHookRunner(config).pipe(Layer.provide(infra)), infra) + const layer = Layer.mergeAll(layerHookRunner(config).pipe(Layer.provide(infra)), infra) const result = yield* Effect.gen(function* () { // Seed the log directly, as if a previous process had persisted hook state before shutdown. diff --git a/packages/fold-core/test/HookRunner/PassThrough.vi.test.ts b/packages/fold-core/test/HookRunner/PassThrough.vi.test.ts index 332d9cd..2f7ca4a 100644 --- a/packages/fold-core/test/HookRunner/PassThrough.vi.test.ts +++ b/packages/fold-core/test/HookRunner/PassThrough.vi.test.ts @@ -70,23 +70,23 @@ it.effect('layerNoHooks returns pass-through decisions', () => it.effect('defines schema-derived hook decision types', () => Effect.gen(function* () { - const preRequest = yield* Schema.decodeUnknownEffect(PreRequestHookDecision)({ + const preRequest = yield* Schema.decodeEffect(PreRequestHookDecision)({ _tag: 'unchanged', }) - const preToolUse = yield* Schema.decodeUnknownEffect(PreToolUseHookDecision)({ + const preToolUse = yield* Schema.decodeEffect(PreToolUseHookDecision)({ _tag: 'replaceResult', result: { blocked: true }, isFailure: true, }) - const postToolUse = yield* Schema.decodeUnknownEffect(PostToolUseHookDecision)({ + const postToolUse = yield* Schema.decodeEffect(PostToolUseHookDecision)({ _tag: 'replace', result: { rewritten: true }, isFailure: false, }) - const onComplete = yield* Schema.decodeUnknownEffect(OnCompleteHookDecision)({ + const onComplete = yield* Schema.decodeEffect(OnCompleteHookDecision)({ _tag: 'continueWith', text: 'keep going', }) diff --git a/packages/fold-core/test/Model/EffectAnthropicToolResultEncodingRepro.vi.test.ts b/packages/fold-core/test/Model/EffectAnthropicToolResultEncodingRepro.vi.test.ts index 695faf7..068d6b6 100644 --- a/packages/fold-core/test/Model/EffectAnthropicToolResultEncodingRepro.vi.test.ts +++ b/packages/fold-core/test/Model/EffectAnthropicToolResultEncodingRepro.vi.test.ts @@ -112,7 +112,7 @@ it.effect('passes strings through and emits multipart client tool results in Eff ]), }).pipe(Effect.provide(modelLayer)) - const body = Schema.decodeUnknownSync(CapturedRequest)(requestBody) + const body = yield* Schema.decodeUnknownEffect(CapturedRequest)(requestBody) const toolResults = body.messages.flatMap((message) => message.content.filter( (block) => block['type'] === 'tool_result' && Predicate.isString(block['tool_use_id']), diff --git a/packages/fold-core/test/Model/ModelRequestSettings.vi.test.ts b/packages/fold-core/test/Model/ModelRequestSettings.vi.test.ts index cd91b4c..7346572 100644 --- a/packages/fold-core/test/Model/ModelRequestSettings.vi.test.ts +++ b/packages/fold-core/test/Model/ModelRequestSettings.vi.test.ts @@ -155,7 +155,7 @@ it.effect('binds an agent-specific prompt cache key on OpenAI requests', () => promptCacheKey: 'session-child-affinity', }) - expect(Reflect.get(configs.openai ?? {}, 'prompt_cache_key')).toBe('session-child-affinity') + expect(configs.openai).toMatchObject({ prompt_cache_key: 'session-child-affinity' }) }), ) diff --git a/packages/fold-core/test/Projection/Projection.vi.test.ts b/packages/fold-core/test/Projection/Projection.vi.test.ts index ad2d81c..1c1cb71 100644 --- a/packages/fold-core/test/Projection/Projection.vi.test.ts +++ b/packages/fold-core/test/Projection/Projection.vi.test.ts @@ -335,9 +335,12 @@ it.effect('compaction cutoff covers every reordered tool result in its discarded const entries = yield* readEntries const scripted = yield* makeScriptedLanguageModel([textTurn('tool batch summary')]) - const plan = yield* makeCompactionService({ enabled: true, thresholdTokens: 1, keepRecentTokens: 1 }) - .plan({ agentId: rootAgentId, entries, model, trigger: 'threshold' }) - .pipe(Effect.provide(scripted.layer)) + const compaction = yield* makeCompactionService({ + enabled: true, + thresholdTokens: 1, + keepRecentTokens: 1, + }).pipe(Effect.provide(scripted.layer)) + const plan = yield* compaction.plan({ agentId: rootAgentId, entries, model, trigger: 'threshold' }) if (plan === null) throw new Error('expected a compaction plan') // Projection puts the second result last, but its smaller physical sequence must not become diff --git a/packages/fold-core/test/Session/SessionEvents.vi.test.ts b/packages/fold-core/test/Session/SessionEvents.vi.test.ts index f80b158..6fa351f 100644 --- a/packages/fold-core/test/Session/SessionEvents.vi.test.ts +++ b/packages/fold-core/test/Session/SessionEvents.vi.test.ts @@ -20,7 +20,7 @@ const durableTags = [ it.effect('surfaces durable rows and one ephemeral tool-progress delta on Session.events', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const scripted = yield* makeScriptedLanguageModel([ toolCallTurn([{ id: 'provider-call-1', name: 'echo', params: { text: 'hi' } }]), textTurn('Tool said hi'), diff --git a/packages/fold-core/test/Session/SessionRuntime.vi.test.ts b/packages/fold-core/test/Session/SessionRuntime.vi.test.ts index 7c7e3b2..93ac3b3 100644 --- a/packages/fold-core/test/Session/SessionRuntime.vi.test.ts +++ b/packages/fold-core/test/Session/SessionRuntime.vi.test.ts @@ -15,7 +15,7 @@ import { sessionBaseLayer, startSessionInput } from './SessionTestHelpers' it.effect('starts a session and completes a text-only send with the full log shape', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const scripted = yield* makeScriptedLanguageModel([textTurn('Hello!')]) const layer = sessionBaseLayer(scripted.layer, layerEchoTool(recorder)) @@ -62,7 +62,7 @@ it.effect('starts a session and completes a text-only send with the full log sha it.effect('records a null cwd when the host has none', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const scripted = yield* makeScriptedLanguageModel([textTurn('Hello!')]) const layer = sessionBaseLayer(scripted.layer, layerEchoTool(recorder)) @@ -82,7 +82,7 @@ it.effect('records a null cwd when the host has none', () => it.effect('fails send before start with SessionNotStartedError', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const scripted = yield* makeScriptedLanguageModel([textTurn('Hello!')]) const layer = sessionBaseLayer(scripted.layer, layerEchoTool(recorder)) @@ -98,7 +98,7 @@ it.effect('fails send before start with SessionNotStartedError', () => it.effect('fails a second start with SessionAlreadyStartedError and appends no second session_started', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const scripted = yield* makeScriptedLanguageModel([textTurn('Hello!')]) const layer = sessionBaseLayer(scripted.layer, layerEchoTool(recorder)) diff --git a/packages/fold-core/test/Session/SessionTestHelpers.ts b/packages/fold-core/test/Session/SessionTestHelpers.ts index b54db5c..c5611db 100644 --- a/packages/fold-core/test/Session/SessionTestHelpers.ts +++ b/packages/fold-core/test/Session/SessionTestHelpers.ts @@ -10,17 +10,15 @@ import { liveModelRequestSettingsLayer, liveSessionLayer, liveToolRuntimeLayer, - makeHookRunner, - makeSessionControls, - makeToolsetResolver, - SessionControls, - Subagents, + layerHookRunner, + layerSessionControls, + layerToolsetResolver, toolEventSinkLayerFromAgentEvents, toolsetLayerFromToolkit, type HookConfig, type StartSessionInput, } from '../../src/index' -import { noSubagentsStub, testModel } from '../AgentRuntime/AgentRuntimeTestHelpers' +import { testModel } from '../AgentRuntime/AgentRuntimeTestHelpers' import { layerDeterministicRuntime } from '../TestLayers/DeterministicRuntime' import { TestToolkit } from '../TestLayers/TestTools' @@ -31,7 +29,7 @@ export { testModel } export const startSessionInput = (overrides?: Partial): StartSessionInput => ({ cwd: '/test', model: testModel, - systemPrompt: 'You are a test agent.', + systemPrompt: ['You are a test agent.'], ...overrides, }) @@ -61,13 +59,12 @@ export const sessionBaseLayer = ( idsLayer, agentEventsLayer, toolsetLayer, - makeToolsetResolver().pipe(Layer.provide(toolsetLayer)), + layerToolsetResolver().pipe(Layer.provide(toolsetLayer)), layerDefaultSystemPrompt, liveModelRequestSettingsLayer, - makeHookRunner(hooks).pipe(Layer.provide(hookDeps)), + layerHookRunner(hooks).pipe(Layer.provide(hookDeps)), toolEventSinkLayerFromAgentEvents.pipe(Layer.provide(agentEventsLayer)), - Layer.succeed(Subagents, noSubagentsStub), - Layer.effect(SessionControls, makeSessionControls()), + layerSessionControls(), NodeFileSystem.layer, ) diff --git a/packages/fold-core/test/Skills/SkillTool.vi.test.ts b/packages/fold-core/test/Skills/SkillTool.vi.test.ts index d10807a..6ae1af7 100644 --- a/packages/fold-core/test/Skills/SkillTool.vi.test.ts +++ b/packages/fold-core/test/Skills/SkillTool.vi.test.ts @@ -1,4 +1,4 @@ -import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' +import * as NodeServices from '@effect/platform-node/NodeServices' import { describe, expect, it } from '@effect/vitest' import { Effect, Layer, Ref, Schema } from 'effect' @@ -10,14 +10,15 @@ import { makeSkillTool, skillSourceFromData, StopController, - Subagents, ToolCallId, ToolEvents, + ToolResultFailure, + ToolResultSuccess, ToolResultText, ToolState, + type SessionToolContribution, type SkillMeta, type SkillSourceService, - type ToolHandlerServices, } from '../../src/index' /** Ambient per-call services the runtime normally provides; the skill tool uses none of them. */ @@ -28,13 +29,7 @@ const ambientServices = Layer.mergeAll( Layer.succeed(CurrentAgent, { agentId: AgentId.make('agent_aaaaaaaaaaaaaaaaaaaaaaaa'), parentAgentId: null }), Layer.succeed(CurrentToolCall, { toolCallId: ToolCallId.make('tool_call_aaaaaaaaaaaaaaaaaaaaaaaa') }), Layer.succeed(InterruptNote, { set: () => Effect.void }), - Layer.succeed(Subagents, { - dispatch: () => Effect.die(new Error('Subagents not available in this test')), - fork: () => Effect.die(new Error('Subagents not available in this test')), - resume: () => Effect.die(new Error('Subagents not available in this test')), - continueSubagent: () => Effect.die(new Error('Subagents not available in this test')), - }), - NodeFileSystem.layer, + NodeServices.layer, ) const skillContentOf = (result: unknown): string => { @@ -42,8 +37,16 @@ const skillContentOf = (result: unknown): string => { throw new Error('expected a skill tool result with string content') } -const runHandler = (effect: Effect.Effect) => - effect.pipe(Effect.provide(ambientServices)) +/** Run a realized handler, decoding its erased success and failure back to the tool result schemas. */ +const runHandler = (handler: SessionToolContribution['handler'], params: unknown) => + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- the erased handler is decoded here + handler(params).pipe( + Effect.catch((error) => + Schema.decodeUnknownEffect(ToolResultFailure)(error).pipe(Effect.orDie, Effect.flatMap(Effect.fail)), + ), + Effect.flatMap((result) => Schema.decodeUnknownEffect(ToolResultSuccess)(result).pipe(Effect.orDie)), + Effect.provide(ambientServices), + ) const demoSkills = [ { name: 'commit-helper', description: 'Craft commit messages', content: 'Write conventional commits.' }, @@ -60,7 +63,7 @@ describe('makeSkillTool', () => { expect(tool.name).toBe('skill') expect(realized.tool.description).toContain('Available skills: commit-helper, reviewer.') - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.provide(NodeServices.layer)), ) it.effect('loads a skill and wraps its content', () => @@ -69,13 +72,13 @@ describe('makeSkillTool', () => { const snapshot = yield* source.list const realized = yield* makeSkillTool({ source, snapshot }).init - const result = yield* runHandler(realized.handler({ name: 'commit-helper' })) + const result = yield* runHandler(realized.handler, { name: 'commit-helper' }) expect(result).toEqual({ _tag: 'text', text: '\nWrite conventional commits.\n', }) - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.provide(NodeServices.layer)), ) it.effect('returns an instructive failure with the roster for unknown skills', () => @@ -84,14 +87,14 @@ describe('makeSkillTool', () => { const snapshot = yield* source.list const realized = yield* makeSkillTool({ source, snapshot }).init - const result = yield* runHandler(realized.handler({ name: 'missing' })).pipe(Effect.flip) + const result = yield* runHandler(realized.handler, { name: 'missing' }).pipe(Effect.flip) expect(result).toEqual({ _tag: 'failure', text: 'Skill "missing" not found. Available skills: commit-helper, reviewer', details: { availableSkills: ['commit-helper', 'reviewer'] }, }) - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.provide(NodeServices.layer)), ) it.effect('refresh reports skills added after the session-start snapshot', () => @@ -115,13 +118,13 @@ describe('makeSkillTool', () => { { name: 'late-arrival', description: 'Added mid-session' }, ]) - const result = yield* runHandler(realized.handler({ name: 'commit-helper', refresh: true })) + const result = yield* runHandler(realized.handler, { name: 'commit-helper', refresh: true }) const content = skillContentOf(result) expect(content).toContain('') expect(content).toContain('') expect(content).toContain('Skills added since session start:\n- late-arrival: Added mid-session') - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.provide(NodeServices.layer)), ) it.effect('refresh reports an unchanged roster', () => @@ -130,11 +133,11 @@ describe('makeSkillTool', () => { const snapshot = yield* source.list const realized = yield* makeSkillTool({ source, snapshot }).init - const result = yield* runHandler(realized.handler({ name: 'reviewer', refresh: true })) + const result = yield* runHandler(realized.handler, { name: 'reviewer', refresh: true }) const content = skillContentOf(result) expect(content).toContain('The skill list has not changed since this session started.') - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.provide(NodeServices.layer)), ) it.effect('an empty snapshot steers the model toward refresh', () => @@ -143,6 +146,6 @@ describe('makeSkillTool', () => { const realized = yield* makeSkillTool({ source, snapshot: [] }).init expect(realized.tool.description).toContain('No skills were available when this session started') - }).pipe(Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.provide(NodeServices.layer)), ) }) diff --git a/packages/fold-core/test/Subagents/DriveHarness.ts b/packages/fold-core/test/Subagents/DriveHarness.ts index c59a96d..c072e0a 100644 --- a/packages/fold-core/test/Subagents/DriveHarness.ts +++ b/packages/fold-core/test/Subagents/DriveHarness.ts @@ -1,6 +1,6 @@ /** * Shared engine-test harness for the Subagents slice: a real facade session whose root model calls a - * test-only `drive` tool once per send, and whose handler yields the ambient Subagents service with an + * test-only `drive` tool once per send, and whose handler calls the subagent operations with an * instruction chosen at runtime - so resume ids (only known after a dispatch) can be fed back in while * the facade, provisioning, per-call ambient services, and child loops all stay real. Also provides a * hang-once scripted model for interrupt scenarios: its first request signals a Deferred and never @@ -11,12 +11,14 @@ import { Predicate, Deferred, Effect, Ref, Schema, Stream } from 'effect' import { AiError, LanguageModel } from 'effect/unstable/ai' import { + dispatchSubagent, + resumeSubagent, + forkSubagent, customModel, defineAgent, defineTool, renderSubagentResult, startSession, - Subagents, subagentTool, type ActiveModel, type AgentId, @@ -29,19 +31,17 @@ import { import { gptActiveModel, scriptedModel } from '../Api/ApiTestHelpers' import { textTurn, toolCallTurn, type ScriptedTurn } from '../TestLayers/ScriptedLanguageModel' -type Mutable = { -readonly [Key in keyof T]: T[Key] } - /** One engine operation the drive tool should perform on its next invocation. */ export type DriveInstruction = | { readonly op: 'dispatch'; readonly agent: string; readonly prompt: string; readonly skill?: string } | { readonly op: 'resume'; readonly agentId: AgentId; readonly prompt: string } | { readonly op: 'fork'; readonly prompt: string } -/** A test-only tool whose handler drives the ambient Subagents engine from a mutable instruction slot. */ +/** A test-only tool whose handler drives the subagent operations from a mutable instruction slot. */ export const makeDriveTool = (instructions: Ref.Ref>, roster: ReadonlyArray) => defineTool({ name: 'drive', - description: 'Test driver over the Subagents engine.', + description: 'Test driver over the subagent operations.', parameters: Schema.Struct({}), success: Schema.Struct({ content: Schema.String }), failure: Schema.Struct({ message: Schema.String }), @@ -54,33 +54,34 @@ export const makeDriveTool = (instructions: Ref.Ref ({ message: `${instruction.op} failed: ${error._tag}`, }) if (instruction.op === 'dispatch') { - const result = yield* subagents - .dispatch({ - agent: instruction.agent, - prompt: instruction.prompt, - skill: instruction.skill ?? null, - allowedAgents: roster, - }) - .pipe(Effect.mapError(failWith)) + const result = yield* dispatchSubagent({ + agent: instruction.agent, + prompt: instruction.prompt, + skill: instruction.skill ?? null, + allowedAgents: roster, + }).pipe(Effect.mapError(failWith)) return { content: renderSubagentResult(result) } } if (instruction.op === 'resume') { - const result = yield* subagents - .resume({ agentId: instruction.agentId, prompt: instruction.prompt, skill: null }) - .pipe(Effect.mapError(failWith)) + const result = yield* resumeSubagent({ + agentId: instruction.agentId, + prompt: instruction.prompt, + skill: null, + }).pipe(Effect.mapError(failWith)) return { content: renderSubagentResult(result) } } - const result = yield* subagents - .fork({ prompt: instruction.prompt, skill: null, forkAgentDefinitionId: null }) - .pipe(Effect.mapError(failWith)) + const result = yield* forkSubagent({ + prompt: instruction.prompt, + skill: null, + forkAgentDefinitionId: null, + }).pipe(Effect.mapError(failWith)) return { content: renderSubagentResult(result) } }), }) @@ -112,11 +113,9 @@ export const makeDriveSession = (input: { systemPrompt: 'root', tools: [makeDriveTool(instructions, roster), subagentTool(input.definitions)], }) - const startOptions: Mutable[0]> = { agent } - if (input.profiles !== undefined) { - startOptions.profiles = input.profiles - } - const session = yield* startSession(startOptions) + const session = yield* startSession( + input.profiles === undefined ? { agent } : { agent, profiles: input.profiles }, + ) /** Queue one instruction; the caller decides how to run the send (await, fork, ...). */ const queue = (instruction: DriveInstruction) => Ref.set(instructions, [instruction]) diff --git a/packages/fold-core/test/Subagents/SubagentDispatch.vi.test.ts b/packages/fold-core/test/Subagents/SubagentDispatch.vi.test.ts index b47b179..e1af346 100644 --- a/packages/fold-core/test/Subagents/SubagentDispatch.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentDispatch.vi.test.ts @@ -15,7 +15,6 @@ import { startSession, subagentTool, type AgentStartedLogEntry, - type LogEntry, type ToolResultLogEntry, } from '../../src/index' import { claudeActiveModel, gptActiveModel, scriptedModel } from '../Api/ApiTestHelpers' @@ -92,10 +91,7 @@ it.effect('dispatches a fresh subagent on the shared log and renders its result' expect(subagentStarted.parentAgentId).toBe(rootStarted.agentId) expect(subagentStarted.toolCallId).not.toBeNull() - const subagentRows = entries.filter( - (entry): entry is LogEntry & { readonly agentId: string } => - 'agentId' in entry && entry.agentId === subagentStarted.agentId, - ) + const subagentRows = entries.filter((entry) => entry.agentId === subagentStarted.agentId) for (const row of subagentRows) { expect(row.parentAgentId).toBe(rootStarted.agentId) expect(row.toolCallId).toBe(subagentStarted.toolCallId) diff --git a/packages/fold-core/test/Subagents/SubagentInterrupt.vi.test.ts b/packages/fold-core/test/Subagents/SubagentInterrupt.vi.test.ts index a2a6ab7..30ff063 100644 --- a/packages/fold-core/test/Subagents/SubagentInterrupt.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentInterrupt.vi.test.ts @@ -47,9 +47,7 @@ it.effect('an interrupted subagent leaves honest durable markers and is resumabl if (started === undefined) throw new Error('expected the dispatched subagent to have started') // Write-through: everything the subagent did before the interrupt is already durable. - const subagentTags = entries - .filter((entry) => 'agentId' in entry && entry.agentId === started.agentId) - .map((entry) => entry._tag) + const subagentTags = entries.filter((entry) => entry.agentId === started.agentId).map((entry) => entry._tag) expect(subagentTags).toEqual([ 'agent_started', 'system-message', diff --git a/packages/fold-core/test/Subagents/SubagentResume.vi.test.ts b/packages/fold-core/test/Subagents/SubagentResume.vi.test.ts index 365d079..5d97443 100644 --- a/packages/fold-core/test/Subagents/SubagentResume.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentResume.vi.test.ts @@ -3,7 +3,7 @@ import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' * Engine tests for subagent resume (D21): a previously dispatched subagent - completed, errored, or * dead from a defect - is resumable by agent_id with its full prior context, new rows grouping under * the RESUMING tool call. The root drives the engine through a test-only `drive` tool whose handler - * yields the ambient Subagents service directly, so resume ids (only known after the first dispatch) + * calls the subagent operations directly, so resume ids (only known after the first dispatch) * can be chosen at runtime while everything else - facade, provisioning, per-call ambient services, * the child loops - is real. */ @@ -11,13 +11,14 @@ import { expect, it } from '@effect/vitest' import { Predicate, Effect, Ref, Schema } from 'effect' import { + dispatchSubagent, + resumeSubagent, defineAgent, defineSubagent, defineTool, renderSubagentResult, shortAgentId, startSession, - Subagents, subagentTool, type AgentId, type AgentStartedLogEntry, @@ -33,11 +34,11 @@ type DriveInstruction = | { readonly op: 'dispatch'; readonly agent: string; readonly prompt: string } | { readonly op: 'resume'; readonly agentId: AgentId; readonly prompt: string } -/** A test-only tool whose handler drives the ambient Subagents engine from a mutable instruction slot. */ +/** A test-only tool whose handler drives the subagent operations from a mutable instruction slot. */ const makeDriveTool = (instructions: Ref.Ref>, roster: ReadonlyArray) => defineTool({ name: 'drive', - description: 'Test driver over the Subagents engine.', + description: 'Test driver over the subagent operations.', parameters: Schema.Struct({}), success: Schema.Struct({ content: Schema.String }), failure: Schema.Struct({ message: Schema.String }), @@ -50,20 +51,19 @@ const makeDriveTool = (instructions: Ref.Ref>, r } yield* Ref.set(instructions, remaining.slice(1)) - const subagents = yield* Subagents const result = instruction.op === 'dispatch' - ? yield* subagents - .dispatch({ - agent: instruction.agent, - prompt: instruction.prompt, - skill: null, - allowedAgents: roster, - }) - .pipe(Effect.mapError((error) => ({ message: `dispatch failed: ${error._tag}` }))) - : yield* subagents - .resume({ agentId: instruction.agentId, prompt: instruction.prompt, skill: null }) - .pipe(Effect.mapError((error) => ({ message: `resume failed: ${error._tag}` }))) + ? yield* dispatchSubagent({ + agent: instruction.agent, + prompt: instruction.prompt, + skill: null, + allowedAgents: roster, + }).pipe(Effect.mapError((error) => ({ message: `dispatch failed: ${error._tag}` }))) + : yield* resumeSubagent({ + agentId: instruction.agentId, + prompt: instruction.prompt, + skill: null, + }).pipe(Effect.mapError((error) => ({ message: `resume failed: ${error._tag}` }))) return { content: renderSubagentResult(result) } }), diff --git a/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts b/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts index 6e89e19..8b7faa5 100644 --- a/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentRoster.vi.test.ts @@ -1,4 +1,4 @@ -import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' +import * as NodeServices from '@effect/platform-node/NodeServices' /** * Engine tests for per-agent rosters (D21, round-five shape): the roster is the subagentTool value's * argument - each value advertises exactly its own roster and its closure is the dispatch authority - @@ -10,13 +10,13 @@ import { expect, it } from '@effect/vitest' import { Predicate, Cause, Effect, Exit, Schema } from 'effect' import { + forkSubagent, defineAgent, defineForkAgent, defineSubagent, defineTool, renderSubagentResult, startSession, - Subagents, subagentTool, withSubagentCapabilities, type ForkAgentDefinition, @@ -39,7 +39,7 @@ it.effect('each subagentTool value advertises exactly its own roster', () => expect(wide.tool.description).toContain('- beta: second specialist') expect(narrow.tool.description).not.toContain('alpha') expect(narrow.tool.description).toContain('- beta: second specialist') - }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ) it.effect('nested rosters give depth; out-of-roster dispatch fails instructively; envelopes chain', () => @@ -118,7 +118,7 @@ it.effect('nested rosters give depth; out-of-roster dispatch fails instructively const renderedFailure = JSON.stringify(selfDispatchResult.message.content[0]) expect(renderedFailure).toContain('Agent type \\"general-purpose\\" is not available to you') expect(renderedFailure).toContain('researcher') - }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ) it.effect('duplicate type names across distinct definitions defect at session start', () => @@ -133,7 +133,7 @@ it.effect('duplicate type names across distinct definitions defect at session st if (!Exit.isFailure(exit)) throw new Error('expected session start to defect') expect(String(Cause.squash(exit.cause))).toContain('duplicate subagent type name') - }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ) it.effect('the same definition shared by two rosters is one registry entry', () => @@ -162,7 +162,7 @@ it.effect('the same definition shared by two rosters is one registry entry', () const finished = yield* session.send('go') expect(finished.outcome).toBe('completed') - }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ) const hostAgentTool = (forkAgent: ForkAgentDefinition) => @@ -174,10 +174,12 @@ const hostAgentTool = (forkAgent: ForkAgentDefinition) => success: Schema.Struct({ content: Schema.String }), handler: ({ prompt }) => Effect.gen(function* () { - const subagents = yield* Subagents - const result = yield* subagents - .fork({ prompt, skill: null, forkAgentDefinitionId: forkAgent.id, history: 'all' }) - .pipe(Effect.orDie) + const result = yield* forkSubagent({ + prompt, + skill: null, + forkAgentDefinitionId: forkAgent.id, + history: 'all', + }).pipe(Effect.orDie) return { content: renderSubagentResult(result) } }), }), @@ -212,7 +214,7 @@ it.effect('host agent tools configure two fork generations structurally', () => expect(started[1]?.fork?.definitionId).toBe('leaf-fork') expect(started[1]?.tools).not.toContain('agent') expect(started[1]?.parentAgentId).toBe(started[0]?.agentId) - }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ) it.effect('duplicate fork agent definition ids defect at session start', () => @@ -230,5 +232,5 @@ it.effect('duplicate fork agent definition ids defect at session start', () => if (!Exit.isFailure(exit)) throw new Error('expected session start to defect') expect(String(Cause.squash(exit.cause))).toContain('duplicate fork agent definition id') - }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ) diff --git a/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts b/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts index 61da766..5e5a270 100644 --- a/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts @@ -2,7 +2,7 @@ import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** * Engine tests for the REAL subagent tool wire (D21): the model dispatches and resumes subagents by * calling the `subagent` tool with its flat wire parameters - no test-only drive tool in the loop - so - * these prove the whole path the SDK ships: wire params -> parseSubagentCommand -> Subagents engine -> + * these prove the whole path the SDK ships: wire params -> parseSubagentCommand -> subagent operations -> * rendered durable result. The resume turn's agent_id is only known after the dispatch, so the root's * script is extended between sends (`pushTurns`). Malformed commands (no selector, garbage agent_id, * unknown agent_id) come back as instructive tool failures the model can correct from. diff --git a/packages/fold-core/test/SystemPrompt/SystemPrompt.vi.test.ts b/packages/fold-core/test/SystemPrompt/SystemPrompt.vi.test.ts index 599ed39..b02f472 100644 --- a/packages/fold-core/test/SystemPrompt/SystemPrompt.vi.test.ts +++ b/packages/fold-core/test/SystemPrompt/SystemPrompt.vi.test.ts @@ -1,7 +1,7 @@ import { expect, it } from '@effect/vitest' import { Effect } from 'effect' -import { layerDefaultSystemPrompt, makeSystemPrompt, SystemPrompt, type ActiveModel } from '../../src/index' +import { layerDefaultSystemPrompt, layerSystemPrompt, SystemPrompt, type ActiveModel } from '../../src/index' const anthropicModel: ActiveModel = { providerId: 'anthropic', @@ -21,7 +21,7 @@ const gptModel: ActiveModel = { reasoning: { _tag: 'disabled' }, } -const familyPrompts = makeSystemPrompt({ +const familyPrompts = layerSystemPrompt({ basePrompts: { claude: 'CLAUDE BASE PROMPT', codex: 'CODEX BASE PROMPT' }, }) diff --git a/packages/fold-core/test/TestLayers/HookRunnerTestHarness.ts b/packages/fold-core/test/TestLayers/HookRunnerTestHarness.ts index e6008e9..60295b5 100644 --- a/packages/fold-core/test/TestLayers/HookRunnerTestHarness.ts +++ b/packages/fold-core/test/TestLayers/HookRunnerTestHarness.ts @@ -3,7 +3,7 @@ import { Effect, Layer } from 'effect' import { type HookRunner, layerInMemoryEventLog, - makeHookRunner, + layerHookRunner, StopController, type HookConfig, type StopControllerService, @@ -23,7 +23,7 @@ export const runWithHookRunner = ( effect.pipe( Effect.provideService(StopController, stopController), Effect.provide( - makeHookRunner(config).pipe( + layerHookRunner(config).pipe( Layer.provide( Layer.mergeAll( layerInMemoryEventLog, diff --git a/packages/fold-core/test/TestLayers/ScriptedLanguageModel.ts b/packages/fold-core/test/TestLayers/ScriptedLanguageModel.ts index c4f660c..4b2c095 100644 --- a/packages/fold-core/test/TestLayers/ScriptedLanguageModel.ts +++ b/packages/fold-core/test/TestLayers/ScriptedLanguageModel.ts @@ -16,7 +16,7 @@ import { AiError, LanguageModel, type Prompt, type Response } from 'effect/unsta /** Optional shaping for a scripted turn's finish part. */ export type ScriptedFinishOptions = { - readonly reason?: typeof Response.FinishReason.Type + readonly reason?: Response.FinishReason readonly inputTokens?: number readonly outputTokens?: number } diff --git a/packages/fold-core/test/TestLayers/TestStopController.ts b/packages/fold-core/test/TestLayers/TestStopController.ts index 9483a09..885d3dd 100644 --- a/packages/fold-core/test/TestLayers/TestStopController.ts +++ b/packages/fold-core/test/TestLayers/TestStopController.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, Ref } from 'effect' +import { Array as Arr, Effect, Layer, Ref } from 'effect' import { StopController, type StopControllerService } from '../../src/index' @@ -29,7 +29,7 @@ export const makeRecordingStopController: Effect.Effect /** Record one stop request reason. */ requestStop: (reason) => Ref.update(requests, (reasons) => [...reasons, reason]), /** Report whether any stop request has been recorded. */ - isStopRequested: Ref.get(requests).pipe(Effect.map((reasons) => reasons.length > 0)), + isStopRequested: Ref.get(requests).pipe(Effect.map((reasons) => Arr.isReadonlyArrayNonEmpty(reasons))), }, } }) diff --git a/packages/fold-core/test/TestLayers/TestTools.ts b/packages/fold-core/test/TestLayers/TestTools.ts index af2e6d5..a58caab 100644 --- a/packages/fold-core/test/TestLayers/TestTools.ts +++ b/packages/fold-core/test/TestLayers/TestTools.ts @@ -25,11 +25,10 @@ export type EchoRecorder = { readonly calls: Ref.Ref> } -export const makeEchoRecorder = (): Effect.Effect => - Effect.gen(function* () { - const calls = yield* Ref.make>([]) - return { calls } - }) +export const makeEchoRecorder: Effect.Effect = Effect.gen(function* () { + const calls = yield* Ref.make>([]) + return { calls } +}) export const layerEchoTool = (recorder: EchoRecorder) => TestToolkit.toLayer( diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeDefect.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeDefect.vi.test.ts index 7a46f5f..3eee7e9 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeDefect.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeDefect.vi.test.ts @@ -1,7 +1,7 @@ import { expect, it } from '@effect/vitest' import { Predicate, Effect, Ref } from 'effect' -import { makeHookRunner, messagesForAgent, StopController, ToolRuntime } from '../../src/index' +import { layerHookRunner, messagesForAgent, StopController, ToolRuntime } from '../../src/index' import { layerEchoTool, makeEchoRecorder, TestToolkit } from '../TestLayers/TestTools' import { agentId, collectEntries, makeAssistantToolCall, toolRuntimeBaseLayer } from './ToolRuntimeTestHelpers' @@ -19,7 +19,7 @@ const projectedToolResultPart = (projected: ReturnType) it.effect('projects tool handler defects as model-visible tool failures', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const toolLayer = TestToolkit.toLayer( TestToolkit.of({ echo: ({ text }) => @@ -29,7 +29,7 @@ it.effect('projects tool handler defects as model-visible tool failures', () => }), }), ) - const layer = toolRuntimeBaseLayer(makeHookRunner({}), toolLayer) + const layer = toolRuntimeBaseLayer(layerHookRunner({}), toolLayer) const result = yield* Effect.gen(function* () { const runtime = yield* ToolRuntime @@ -63,7 +63,7 @@ it.effect('truncates long defect messages before projecting them to the model', echo: () => Effect.die(new Error(longMessage)), }), ) - const layer = toolRuntimeBaseLayer(makeHookRunner({}), toolLayer) + const layer = toolRuntimeBaseLayer(layerHookRunner({}), toolLayer) const projected = yield* Effect.gen(function* () { const runtime = yield* ToolRuntime @@ -100,7 +100,7 @@ it.effect('projects tool defects after cooperative stop requests as model-visibl }), }), ) - const layer = toolRuntimeBaseLayer(makeHookRunner({}), toolLayer) + const layer = toolRuntimeBaseLayer(layerHookRunner({}), toolLayer) const result = yield* Effect.gen(function* () { const runtime = yield* ToolRuntime @@ -126,8 +126,8 @@ it.effect('projects tool defects after cooperative stop requests as model-visibl it.effect('projects preToolUse hook defects as model-visible tool failures and skips the handler', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() - const hookLayer = makeHookRunner({ + const recorder = yield* makeEchoRecorder + const hookLayer = layerHookRunner({ preToolUse: [ { name: 'guard', @@ -164,8 +164,8 @@ it.effect('projects preToolUse hook defects as model-visible tool failures and s it.effect('projects postToolUse hook defects as model-visible tool failures', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() - const hookLayer = makeHookRunner({ + const recorder = yield* makeEchoRecorder + const hookLayer = layerHookRunner({ postToolUse: [ { name: 'redactor', @@ -202,8 +202,8 @@ it.effect('projects postToolUse hook defects as model-visible tool failures', () it.effect('projects hook defects after StopController requests as hook failures', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() - const hookLayer = makeHookRunner({ + const recorder = yield* makeEchoRecorder + const hookLayer = layerHookRunner({ preToolUse: [ { name: 'stopper', diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeEvents.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeEvents.vi.test.ts index 9fa1e1f..0cc9e19 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeEvents.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeEvents.vi.test.ts @@ -13,7 +13,7 @@ import { it.effect('tool handler can emit UI progress events separate from final result', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const eventsRef = yield* Ref.make>([]) const layer = toolRuntimeBaseLayer( hookRunnerNoop, diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeHookState.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeHookState.vi.test.ts index 36f9b50..37ebdfa 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeHookState.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeHookState.vi.test.ts @@ -1,7 +1,7 @@ import { expect, it } from '@effect/vitest' import { Predicate, Effect, Schema } from 'effect' -import { defineToolState, makeHookRunner, toolStateForAgent, ToolRuntime } from '../../src/index' +import { defineToolState, layerHookRunner, toolStateForAgent, ToolRuntime } from '../../src/index' import { layerStatefulEchoTool, makeEchoRecorder } from '../TestLayers/TestTools' import { agentId, @@ -14,16 +14,16 @@ import { const AuditState = defineToolState({ namespace: 'audit', keys: { - seen: Schema.Number, + seen: Schema.Finite, }, }) it.effect('a preToolUse hook writes durable state in its declared namespace, separate from the tool namespace', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder // The hook name differs from its declared ToolState namespace ('audit'), proving the persisted // namespace comes from the AuditState definition rather than the hook's name. - const hookLayer = makeHookRunner({ + const hookLayer = layerHookRunner({ preToolUse: [ { name: 'audit-hook', diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeInterrupt.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeInterrupt.vi.test.ts index ded1717..c37f91c 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeInterrupt.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeInterrupt.vi.test.ts @@ -7,11 +7,9 @@ import { layerInMemoryEventLog, liveToolRuntimeLayer, messagesForAgent, - Subagents, ToolRuntime, toolsetLayerFromToolkit, } from '../../src/index' -import { noSubagentsStub } from '../AgentRuntime/AgentRuntimeTestHelpers' import { layerDeterministicRuntime } from '../TestLayers/DeterministicRuntime' import { hookRunnerNoop } from '../TestLayers/NoOpHookRunner' import { agentId, collectEntries, layerNoopToolEvents, toolCallId } from './ToolRuntimeTestHelpers' @@ -61,7 +59,6 @@ it.effect('writes a synthetic interrupted tool-result when a running tool fiber toolsetLayerFromToolkit(BlockingToolkit).pipe(Layer.provide(handlerLayer)), hookRunnerNoop, layerNoopToolEvents, - Layer.succeed(Subagents, noSubagentsStub), NodeFileSystem.layer, ), ), diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeLiveHooks.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeLiveHooks.vi.test.ts index c50fcd2..c7e2cdc 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeLiveHooks.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeLiveHooks.vi.test.ts @@ -1,14 +1,14 @@ import { expect, it } from '@effect/vitest' import { Predicate, Effect, Ref } from 'effect' -import { makeHookRunner, messagesForAgent, ToolRuntime } from '../../src/index' +import { layerHookRunner, messagesForAgent, ToolRuntime } from '../../src/index' import { layerEchoTool, makeEchoRecorder } from '../TestLayers/TestTools' import { agentId, collectEntries, makeAssistantToolCall, toolRuntimeBaseLayer } from './ToolRuntimeTestHelpers' it.effect('live preToolUse hook can replace the result and skip the handler', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() - const hookLayer = makeHookRunner({ + const recorder = yield* makeEchoRecorder + const hookLayer = layerHookRunner({ preToolUse: [ { name: 'block-echo', @@ -53,8 +53,8 @@ it.effect('live preToolUse hook can replace the result and skip the handler', () it.effect('live preToolUse hook can update execution params without changing prompt projection', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() - const hookLayer = makeHookRunner({ + const recorder = yield* makeEchoRecorder + const hookLayer = layerHookRunner({ preToolUse: [ { name: 'mutate-echo', @@ -106,9 +106,9 @@ it.effect('live preToolUse hook can update execution params without changing pro it.effect('postToolUse receives original, executed, and handler values while hooks compose in order', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const observed = yield* Ref.make>([]) - const hookLayer = makeHookRunner({ + const hookLayer = layerHookRunner({ preToolUse: [ { name: 'mutate-echo', diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeNoOpHooks.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeNoOpHooks.vi.test.ts index c951e3b..1d284da 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeNoOpHooks.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeNoOpHooks.vi.test.ts @@ -8,7 +8,7 @@ import { agentId, collectEntries, makeAssistantToolCall, toolRuntimeBaseLayer } it.effect('settles a real tool call with NoOpHookRunner', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const layer = toolRuntimeBaseLayer(hookRunnerNoop, layerEchoTool(recorder)) const result = yield* Effect.gen(function* () { diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeState.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeState.vi.test.ts index e4a74a9..dd55d98 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeState.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeState.vi.test.ts @@ -8,7 +8,7 @@ import { agentId, collectEntries, makeAssistantToolCall, toolRuntimeBaseLayer } it.effect('tool handler can write durable ToolState', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const layer = toolRuntimeBaseLayer(hookRunnerNoop, layerStatefulEchoTool(recorder)) const result = yield* Effect.gen(function* () { diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeStateSnapshot.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeStateSnapshot.vi.test.ts index c25e145..25900a5 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeStateSnapshot.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeStateSnapshot.vi.test.ts @@ -10,15 +10,13 @@ import { type Ids, layerInMemoryEventLog, liveToolRuntimeLayer, - makeHookRunner, - Subagents, + layerHookRunner, ToolCallId, ToolRuntime, ToolState, toolsetLayerFromToolkit, toolStateForAgent, } from '../../src/index' -import { noSubagentsStub } from '../AgentRuntime/AgentRuntimeTestHelpers' import { layerDeterministicRuntime } from '../TestLayers/DeterministicRuntime' import { hookRunnerNoop } from '../TestLayers/NoOpHookRunner' import { agentId, collectEntries, layerNoopToolEvents } from './ToolRuntimeTestHelpers' @@ -62,7 +60,6 @@ const probeRuntimeLayer = ( toolsetLayerFromToolkit(ProbeToolkit).pipe(Layer.provide(handlerLayer)), hookLayer.pipe(Layer.provide(hookDeps)), layerNoopToolEvents, - Layer.succeed(Subagents, noSubagentsStub), NodeFileSystem.layer, ), ), @@ -164,7 +161,7 @@ describe('ToolRuntime handler state snapshots', () => { // The hook shares state with the tool because both use the same ProbeState definition, not // because of any name match - the hook name deliberately differs from the declared namespace. - const hookLayer = makeHookRunner({ + const hookLayer = layerHookRunner({ preToolUse: [ { name: 'probe-seed', diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeStopRequest.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeStopRequest.vi.test.ts index 44b09fb..8d8a716 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeStopRequest.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeStopRequest.vi.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from '@effect/vitest' import { Effect, Ref } from 'effect' -import { makeHookRunner, StopController, ToolRuntime } from '../../src/index' +import { layerHookRunner, StopController, ToolRuntime } from '../../src/index' import { hookRunnerNoop } from '../TestLayers/NoOpHookRunner' import { layerEchoTool, layerStoppingEchoTool, makeEchoRecorder } from '../TestLayers/TestTools' import { agentId, makeAssistantToolCall, toolRuntimeBaseLayer } from './ToolRuntimeTestHelpers' @@ -9,7 +9,7 @@ import { agentId, makeAssistantToolCall, toolRuntimeBaseLayer } from './ToolRunt describe('ToolRuntime cooperative stop requests', () => { it.effect('a tool handler can request a stop; the result is still persisted and settle reports it', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() + const recorder = yield* makeEchoRecorder const layer = toolRuntimeBaseLayer(hookRunnerNoop, layerStoppingEchoTool(recorder)) const result = yield* Effect.gen(function* () { @@ -39,8 +39,8 @@ describe('ToolRuntime cooperative stop requests', () => { it.effect('a preToolUse hook can request a stop while continuing; the tool still executes', () => Effect.gen(function* () { - const recorder = yield* makeEchoRecorder() - const hookLayer = makeHookRunner({ + const recorder = yield* makeEchoRecorder + const hookLayer = layerHookRunner({ preToolUse: [ { name: 'stop-after-this-batch', diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeTestHelpers.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeTestHelpers.ts index 995a58f..5840556 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeTestHelpers.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeTestHelpers.ts @@ -11,14 +11,12 @@ import { layerInMemoryEventLog, liveToolRuntimeLayer, noopToolEventSink, - Subagents, ToolCallId, ToolEventSink, toolsetLayerFromToolkit, type LogEntry, type ToolRuntimeEvent, } from '../../src/index' -import { noSubagentsStub } from '../AgentRuntime/AgentRuntimeTestHelpers' import { layerDeterministicRuntime } from '../TestLayers/DeterministicRuntime' import { TestToolkit } from '../TestLayers/TestTools' @@ -74,7 +72,6 @@ export const toolRuntimeBaseLayer = ( toolsetLayerFromToolkit(TestToolkit).pipe(Layer.provide(toolHandlerLayer)), hookLayer.pipe(Layer.provide(hookDeps)), eventLayer, - Layer.succeed(Subagents, noSubagentsStub), NodeFileSystem.layer, ), ), diff --git a/packages/fold-core/test/ToolRuntime/ToolsetResolver.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolsetResolver.vi.test.ts index d58d016..257fbb2 100644 --- a/packages/fold-core/test/ToolRuntime/ToolsetResolver.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolsetResolver.vi.test.ts @@ -1,7 +1,7 @@ import { expect, it } from '@effect/vitest' import { Effect, Layer } from 'effect' -import { makeToolsetResolver, Toolset, ToolsetResolver, type ActiveModel } from '../../src/index' +import { layerToolsetResolver, Toolset, ToolsetResolver, type ActiveModel } from '../../src/index' /** Toolset stub exposing only installed names; the resolver never touches handlers. */ const stubToolset = (names: ReadonlyArray): Layer.Layer => @@ -59,7 +59,7 @@ const resolveFor = (model: ActiveModel, layer: Layer.Layer) => it.effect('codex-family models edit through apply_patch: write and edit are hidden', () => Effect.gen(function* () { - const layer = makeToolsetResolver().pipe(Layer.provide(stubToolset(installed))) + const layer = layerToolsetResolver().pipe(Layer.provide(stubToolset(installed))) const resolved = yield* resolveFor(codexModel, layer) expect(resolved.names).toEqual(['echo', 'read', 'apply_patch', 'bash']) @@ -68,7 +68,7 @@ it.effect('codex-family models edit through apply_patch: write and edit are hidd it.effect('claude-family models edit through write/edit: apply_patch is hidden', () => Effect.gen(function* () { - const layer = makeToolsetResolver().pipe(Layer.provide(stubToolset(installed))) + const layer = layerToolsetResolver().pipe(Layer.provide(stubToolset(installed))) const resolved = yield* resolveFor(claudeModel, layer) expect(resolved.names).toEqual(['echo', 'read', 'write', 'edit', 'bash']) @@ -77,7 +77,7 @@ it.effect('claude-family models edit through write/edit: apply_patch is hidden', it.effect('gpt-family models edit through apply_patch: write and edit are hidden', () => Effect.gen(function* () { - const layer = makeToolsetResolver().pipe(Layer.provide(stubToolset(installed))) + const layer = layerToolsetResolver().pipe(Layer.provide(stubToolset(installed))) const resolved = yield* resolveFor(gptModel, layer) expect(resolved.names).toEqual(['echo', 'read', 'apply_patch', 'bash']) @@ -86,7 +86,7 @@ it.effect('gpt-family models edit through apply_patch: write and edit are hidden it.effect('unknown families default to write/edit editing', () => Effect.gen(function* () { - const layer = makeToolsetResolver().pipe(Layer.provide(stubToolset(installed))) + const layer = layerToolsetResolver().pipe(Layer.provide(stubToolset(installed))) const resolved = yield* resolveFor(unknownModel, layer) expect(resolved.names).toEqual(['echo', 'read', 'write', 'edit', 'bash']) @@ -95,7 +95,7 @@ it.effect('unknown families default to write/edit editing', () => it.effect('policy overrides merge over the default family exclusions', () => Effect.gen(function* () { - const layer = makeToolsetResolver({ excludedToolsByFamily: { claude: ['bash', 'apply_patch'] } }).pipe( + const layer = layerToolsetResolver({ excludedToolsByFamily: { claude: ['bash', 'apply_patch'] } }).pipe( Layer.provide(stubToolset(installed)), ) const resolved = yield* resolveFor(claudeModel, layer) diff --git a/packages/fold-core/test/ToolState/ToolStateSnapshot.vi.test.ts b/packages/fold-core/test/ToolState/ToolStateSnapshot.vi.test.ts index 1e0b5a4..c56fc30 100644 --- a/packages/fold-core/test/ToolState/ToolStateSnapshot.vi.test.ts +++ b/packages/fold-core/test/ToolState/ToolStateSnapshot.vi.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from '@effect/vitest' -import { Predicate, Effect, Layer, Stream } from 'effect' +import { Predicate, Effect, Layer, type Schema, Stream } from 'effect' import { AgentId, @@ -17,7 +17,7 @@ const toolCallId = ToolCallId.make('tool_call_aaaaaaaaaaaaaaaaaaaaaaaa') const layer = Layer.mergeAll(layerInMemoryEventLog, layerDeterministicRuntime({ startMillis: 1_000, stepMillis: 0 })) -const appendSharedValue = (value: unknown) => +const appendSharedValue = (value: Schema.Json) => Effect.gen(function* () { const eventLog = yield* EventLog diff --git a/packages/fold-core/test/Tools/EditEngine.vi.test.ts b/packages/fold-core/test/Tools/EditEngine.vi.test.ts index 70d580d..31edea1 100644 --- a/packages/fold-core/test/Tools/EditEngine.vi.test.ts +++ b/packages/fold-core/test/Tools/EditEngine.vi.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from '@effect/vitest' -import { Effect, Result } from 'effect' +import { Effect, Result, Schema } from 'effect' -import { applyEdits, EditEngineError, normalizeEditInput } from '../../src/index' +import { applyEdits, EditEngineError, editToolContract, normalizeEditInput } from '../../src/index' + +const decodeEditParameters = Schema.decodeUnknownEffect(editToolContract.parameters) const run = (effect: Effect.Effect) => Effect.result(effect) @@ -206,9 +208,10 @@ describe('normalizeEditInput', () => { }), ) - it.effect('parses a JSON-string edits array (models sometimes stringify it)', () => + it.effect('decodes a JSON-string edits array (models sometimes stringify it)', () => Effect.gen(function* () { - const edits = yield* normalizeEditInput({ edits: '[{"oldText":"a","newText":"b"}]' }) + const params = yield* decodeEditParameters({ path: 'file.ts', edits: '[{"oldText":"a","newText":"b"}]' }) + const edits = yield* normalizeEditInput(params) expect(edits).toEqual([{ oldText: 'a', newText: 'b' }]) }), @@ -235,9 +238,11 @@ describe('normalizeEditInput', () => { expect(message).toBe('Edit tool input is invalid. edits must contain at least one replacement.') }) - it('fails on a malformed JSON-string edits value', async () => { - const message = await expectFailure(normalizeEditInput({ edits: 'not json' })) + it.effect('rejects a malformed JSON-string edits value at parameter decoding', () => + Effect.gen(function* () { + const error = yield* decodeEditParameters({ path: 'file.ts', edits: 'not json' }).pipe(Effect.flip) - expect(message).toBe('Edit tool input is invalid. edits must be an array of {oldText, newText}.') - }) + expect(error._tag).toBe('SchemaError') + }), + ) }) diff --git a/packages/fold-core/test/index.vi.test.ts b/packages/fold-core/test/index.vi.test.ts index 6398898..36689d0 100644 --- a/packages/fold-core/test/index.vi.test.ts +++ b/packages/fold-core/test/index.vi.test.ts @@ -6,7 +6,7 @@ import { LogSeq, ToolCallId } from '../src/index' it.effect('defines core schemas', () => Effect.gen(function* () { - const seq = yield* Schema.decodeUnknownEffect(LogSeq)(0) + const seq = yield* Schema.decodeEffect(LogSeq)(0) const toolCallId = ToolCallId.create() expect(seq).toBe(0) diff --git a/packages/fold-opencode/src/AuthStore.ts b/packages/fold-opencode/src/AuthStore.ts index 78c9fbb..9da7085 100644 --- a/packages/fold-opencode/src/AuthStore.ts +++ b/packages/fold-opencode/src/AuthStore.ts @@ -1,8 +1,13 @@ -/** File-backed OpenCode credentials stored under the `opencode` key in `~/.fold/auth.json`. */ +/** + * File-backed OpenCode credentials stored under the `opencode` key in `~/.fold/auth.json`. The document + * is provider-keyed JSON shared with other providers; only our entry decodes as a token. A missing file + * is an empty document. `load` degrades to "no credentials" (with a logged warning) on an unreadable or + * corrupt document or a bad entry; `save`/`clear` fail on an unreadable or corrupt document rather than + * clobber it. + */ import { homedir } from 'node:os' import { dirname, join } from 'node:path' -import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Context, Effect, FileSystem, Layer, Option, Schema } from 'effect' export const TOKEN_EXPIRY_BUFFER_MS = 30_000 @@ -13,7 +18,7 @@ export class OpenCodeTokenData extends Schema.Class('fold/Ope type: Schema.Literal('oauth'), access: Schema.String, refresh: Schema.String, - expires: Schema.Number, + expires: Schema.Finite, metadata: Schema.optional( Schema.Struct({ server: Schema.String, @@ -29,69 +34,132 @@ export class OpenCodeTokenData extends Schema.Class('fold/Ope } } +/** Auth store failure: the document could not be read, is not provider-keyed JSON, or could not be written. */ export class OpenCodeAuthStoreError extends Schema.TaggedError()('OpenCodeAuthStoreError', { + reason: Schema.Literals(['ReadFailed', 'InvalidDocument', 'WriteFailed']), message: Schema.String, cause: Schema.optional(Schema.Defect()), }) {} -export type OpenCodeAuthStore = { +/** The credential store OpenCodeAuth persists through. */ +export type OpenCodeAuthStoreService = { readonly path: string readonly load: Effect.Effect> readonly save: (token: OpenCodeTokenData) => Effect.Effect readonly clear: Effect.Effect } -export type MakeOpenCodeAuthStoreOptions = { +/** The credential store OpenCodeAuth persists through; {@link layerOpenCodeAuthStore} provides the file-backed one. */ +export class OpenCodeAuthStore extends Context.Service()( + 'fold/OpenCodeAuthStore', +) {} + +/** Options for {@link layerOpenCodeAuthStore}. */ +export type OpenCodeAuthStoreOptions = { + /** Path of the auth document. Defaults to `~/.fold/auth.json`. */ readonly path?: string + /** Key of this provider's entry in the document. Defaults to `opencode`. */ readonly providerId?: string - readonly fileSystem?: FileSystem.FileSystem -} -let nodeFs: FileSystem.FileSystem | null = null -const defaultFs = (): FileSystem.FileSystem => { - if (nodeFs === null) - nodeFs = Effect.runSync( - Effect.scoped( - Layer.build(NodeFileSystem.layer).pipe(Effect.map((c) => Context.get(c, FileSystem.FileSystem))), - ), - ) - return nodeFs } -const Document = Schema.Record(Schema.String, Schema.Unknown) -const decodeDocument = Schema.decodeUnknownOption(Schema.fromJsonString(Document)) -const decodeToken = Schema.decodeUnknownOption(OpenCodeTokenData) +/** The auth document: JSON entries keyed by provider id. Entries other than ours are preserved verbatim. */ +export const OpenCodeAuthDocument = Schema.Record(Schema.String, Schema.Json) +export type OpenCodeAuthDocument = typeof OpenCodeAuthDocument.Type -/** Construct a provider-keyed credential store; unrelated entries are preserved. */ -export const makeOpenCodeAuthStore = (options?: MakeOpenCodeAuthStoreOptions): OpenCodeAuthStore => { - const fs = options?.fileSystem ?? defaultFs() - const path = options?.path ?? defaultOpenCodeAuthStorePath() - const providerId = options?.providerId ?? 'opencode' - const read: Effect.Effect> = fs.readFileString(path).pipe( - Effect.map((text): Record => Option.getOrElse(decodeDocument(text), () => ({}))), - Effect.catch(() => Effect.succeed>({})), - ) - const write = (document: Record) => - Effect.gen(function* () { - yield* fs.makeDirectory(dirname(path), { recursive: true }) - yield* fs.writeFileString(path, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 }) - yield* fs.chmod(path, 0o600) - }).pipe(Effect.mapError((cause) => new OpenCodeAuthStoreError({ message: `Failed to write ${path}`, cause }))) - return { - path, - load: read.pipe( - Effect.map((document) => decodeToken(document[providerId])), - Effect.withSpan('fold.opencode_auth_store.load'), - ), - save: (token) => - read.pipe( - Effect.flatMap((document) => write({ ...document, [providerId]: token })), - Effect.as(token), - Effect.withSpan('fold.opencode_auth_store.save'), - ), - clear: read.pipe( - Effect.flatMap((document) => { +const emptyDocument: OpenCodeAuthDocument = {} +const decodeDocument = Schema.decodeEffect(Schema.fromJsonString(OpenCodeAuthDocument)) +const encodeDocument = Schema.encodeEffect(Schema.fromJsonString(OpenCodeAuthDocument, { space: 2 })) + +/** Our entry's JSON codec: decodes a document value into a token and encodes a token back to JSON. */ +const OpenCodeTokenEntry = Schema.toCodecJson(OpenCodeTokenData) +const decodeTokenEntry = Schema.decodeOption(OpenCodeTokenEntry) +const encodeTokenEntry = Schema.encodeEffect(OpenCodeTokenEntry) + +/** A file-backed, provider-keyed credential store; unrelated entries are preserved. */ +export const layerOpenCodeAuthStore = ( + options?: OpenCodeAuthStoreOptions, +): Layer.Layer => + Layer.effect( + OpenCodeAuthStore, + Effect.map(FileSystem.FileSystem, (fs): OpenCodeAuthStoreService => { + const path = options?.path ?? defaultOpenCodeAuthStorePath() + const providerId = options?.providerId ?? 'opencode' + + // A missing document is simply "no credentials stored yet"; any other read failure is real. + const read: Effect.Effect = fs.readFileString(path).pipe( + Effect.asSome, + Effect.catchReason('PlatformError', 'NotFound', () => Effect.succeed(Option.none())), + Effect.mapError( + (cause) => + new OpenCodeAuthStoreError({ reason: 'ReadFailed', message: `Failed to read ${path}`, cause }), + ), + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(emptyDocument), + onSome: (text) => + decodeDocument(text).pipe( + Effect.mapError( + (cause) => + new OpenCodeAuthStoreError({ + reason: 'InvalidDocument', + message: `${path} is not a JSON object of provider entries`, + cause, + }), + ), + ), + }), + ), + ) + const write = (document: OpenCodeAuthDocument) => + Effect.gen(function* () { + yield* fs.makeDirectory(dirname(path), { recursive: true }) + const text = yield* encodeDocument(document) + yield* fs.writeFileString(path, `${text}\n`, { mode: 0o600 }) + yield* fs.chmod(path, 0o600) + }).pipe( + Effect.mapError( + (cause) => + new OpenCodeAuthStoreError({ + reason: 'WriteFailed', + message: `Failed to write ${path}`, + cause, + }), + ), + ) + const load = Effect.gen(function* () { + const document = yield* read + const entry = document[providerId] + if (entry === undefined) return Option.none() + const token = decodeTokenEntry(entry) + if (Option.isNone(token)) yield* Effect.logWarning(`Ignoring invalid "${providerId}" entry in ${path}`) + return token + }).pipe( + Effect.catchTag('OpenCodeAuthStoreError', (error) => + Effect.logWarning(`${error.message}; treating it as holding no credentials`, error.cause).pipe( + Effect.as(Option.none()), + ), + ), + Effect.withSpan('fold.opencode_auth_store.load'), + ) + const save = Effect.fn('fold.opencode_auth_store.save')(function* (token: OpenCodeTokenData) { + const document = yield* read + const entry = yield* encodeTokenEntry(token).pipe( + Effect.mapError( + (cause) => + new OpenCodeAuthStoreError({ + reason: 'WriteFailed', + message: `Failed to encode the "${providerId}" entry for ${path}`, + cause, + }), + ), + ) + yield* write({ ...document, [providerId]: entry }) + return token + }) + const clear = Effect.gen(function* () { + const document = yield* read + if (document[providerId] === undefined) return const { [providerId]: _removed, ...rest } = document - return write(rest) - }), - Effect.withSpan('fold.opencode_auth_store.clear'), - ), - } -} + yield* write(rest) + }).pipe(Effect.withSpan('fold.opencode_auth_store.clear')) + return { path, load, save, clear } + }), + ) diff --git a/packages/fold-opencode/src/OpenCodeAuth.ts b/packages/fold-opencode/src/OpenCodeAuth.ts index 1348a62..9d22832 100644 --- a/packages/fold-opencode/src/OpenCodeAuth.ts +++ b/packages/fold-opencode/src/OpenCodeAuth.ts @@ -2,11 +2,10 @@ * OpenCode Console OAuth client. Device endpoints and polling semantics are adapted from OpenCode's * MIT-licensed client implementation; see ../NOTICE and ../LICENSE.opencode. */ -import { Clock, Context, Duration, Effect, Option, Schema, Semaphore } from 'effect' +import { Clock, Context, Duration, Effect, Layer, Match, Option, Schema, Semaphore } from 'effect' import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from 'effect/unstable/http' -import type { OpenCodeAuthStore } from './AuthStore' -import { makeOpenCodeAuthStore, OpenCodeTokenData } from './AuthStore' +import { OpenCodeAuthStore, OpenCodeTokenData } from './AuthStore' export const OPENCODE_CONSOLE_URL = 'https://console.opencode.ai' export const OPENCODE_CLIENT_ID = 'opencode-cli' @@ -15,12 +14,16 @@ const Device = Schema.Struct({ device_code: Schema.String, user_code: Schema.String, verification_uri_complete: Schema.String, - expires_in: Schema.Number, - interval: Schema.Number, + expires_in: Schema.Finite, + interval: Schema.Finite, }) -const Token = Schema.Struct({ access_token: Schema.String, refresh_token: Schema.String, expires_in: Schema.Number }) -const Pending = Schema.Struct({ error: Schema.String }) -const DeviceToken = Schema.Union([Token, Pending]) +const Token = Schema.Struct({ access_token: Schema.String, refresh_token: Schema.String, expires_in: Schema.Finite }) +const DeviceToken = Schema.Union([ + Schema.Struct({ _tag: Schema.tagDefaultOmit('Granted'), ...Token.fields }), + Schema.Struct({ _tag: Schema.tagDefaultOmit('Pending'), error: Schema.Literal('authorization_pending') }), + Schema.Struct({ _tag: Schema.tagDefaultOmit('SlowDown'), error: Schema.Literal('slow_down') }), + Schema.Struct({ _tag: Schema.tagDefaultOmit('Failed'), error: Schema.String }), +]) const User = Schema.Struct({ id: Schema.String, email: Schema.String }) const Org = Schema.Struct({ id: Schema.String, name: Schema.String }) type OpenCodeTokenMetadata = { @@ -43,8 +46,8 @@ export type OpenCodeAuthService = { readonly logout: Effect.Effect } export class OpenCodeAuth extends Context.Service()('fold/OpenCodeAuth') {} -export type MakeOpenCodeAuthOptions = { - readonly store?: OpenCodeAuthStore +/** Options for {@link layerOpenCodeAuth}. */ +export type OpenCodeAuthOptions = { readonly server?: string readonly onDeviceCode?: (prompt: OpenCodeDevicePrompt) => Effect.Effect } @@ -59,8 +62,7 @@ const post = ( HttpClientRequest.post(url).pipe( HttpClientRequest.acceptJson, HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.String))(body), - Effect.flatMap(client.execute), - Effect.flatMap((r) => (statusOk ? HttpClientResponse.filterStatusOk(r) : Effect.succeed(r))), + Effect.flatMap((statusOk ? HttpClient.filterStatusOk(client) : client).execute), Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), ) const get = (client: HttpClient.HttpClient, url: string, access: string, schema: S) => @@ -120,28 +122,32 @@ const poll = ( false, ), ), - Effect.flatMap((result) => { - if ('access_token' in result) - return credential(client, server, result).pipe( - Effect.mapError( - (cause) => + Effect.flatMap( + Match.type().pipe( + Match.tagsExhaustive({ + Granted: (token) => + credential(client, server, token).pipe( + Effect.mapError( + (cause) => + new OpenCodeAuthError({ + reason: 'AuthorizationFailed', + message: 'Failed to load OpenCode account metadata', + cause, + }), + ), + ), + Pending: () => poll(client, server, code, interval), + SlowDown: () => poll(client, server, code, Duration.sum(interval, Duration.seconds(5))), + Failed: ({ error }) => + Effect.fail( new OpenCodeAuthError({ reason: 'AuthorizationFailed', - message: 'Failed to load OpenCode account metadata', - cause, + message: `Device authorization failed: ${error}`, }), - ), - ) - if (result.error === 'authorization_pending') return poll(client, server, code, interval) - if (result.error === 'slow_down') - return poll(client, server, code, Duration.sum(interval, Duration.seconds(5))) - return Effect.fail( - new OpenCodeAuthError({ - reason: 'AuthorizationFailed', - message: `Device authorization failed: ${result.error}`, + ), }), - ) - }), + ), + ), Effect.mapError((cause) => Schema.is(OpenCodeAuthError)(cause) ? cause @@ -153,115 +159,136 @@ const poll = ( ), ) -/** Build an auth service over the ambient HttpClient. */ -export const makeOpenCodeAuth = Effect.fnUntraced(function* (options?: MakeOpenCodeAuthOptions) { - const client = yield* HttpClient.HttpClient - const store = options?.store ?? makeOpenCodeAuthStore() - const server = options?.server ?? OPENCODE_CONSOLE_URL - const lock = Semaphore.makeUnsafe(1) - let current = yield* store.load - const save = (token: OpenCodeTokenData) => - store.save(token).pipe( - Effect.mapError((cause) => new OpenCodeAuthError({ reason: 'StoreFailed', message: cause.message, cause })), - Effect.tap((saved) => - Effect.sync(() => { - current = Option.some(saved) - }), - ), - ) - const refresh = (token: OpenCodeTokenData) => - post( - client, - `${token.metadata?.server ?? server}/auth/device/token`, - { grant_type: 'refresh_token', refresh_token: token.refresh, client_id: OPENCODE_CLIENT_ID }, - Token, - ).pipe( - Effect.flatMap((next) => credential(client, token.metadata?.server ?? server, next)), - Effect.mapError( - (cause) => - new OpenCodeAuthError({ - reason: 'RefreshFailed', - message: 'Failed to refresh OpenCode credentials', - cause, - }), - ), - Effect.flatMap(save), - ) - const getToken = Effect.gen(function* () { - const now = yield* Clock.currentTimeMillis - if (Option.isNone(current)) - return yield* new OpenCodeAuthError({ - reason: 'NotAuthenticated', - message: `No OpenCode credentials found in ${store.path}; run the device login first.`, - }) - return current.value.isExpired(now) ? yield* refresh(current.value) : current.value - }) - return { - get: lock.withPermit(getToken).pipe(Effect.withSpan('fold.opencode_auth.get')), - authenticateDevice: lock - .withPermit( - Effect.gen(function* () { - const device = yield* post( - client, - `${server}/auth/device/code`, - { client_id: OPENCODE_CLIENT_ID }, - Device, - ) - yield* (options?.onDeviceCode ?? ((p) => Effect.log(`Open ${p.url} and enter code ${p.userCode}`)))( - { url: `${server}${device.verification_uri_complete}`, userCode: device.user_code }, - ) - return yield* poll(client, server, device.device_code, Duration.seconds(device.interval)).pipe( - Effect.flatMap(save), - ) - }).pipe( - Effect.mapError((cause) => - Schema.is(OpenCodeAuthError)(cause) - ? cause - : new OpenCodeAuthError({ - reason: 'AuthorizationFailed', - message: 'Unable to start OpenCode device authorization', - cause, - }), - ), - ), - ) - .pipe(Effect.withSpan('fold.opencode_auth.authenticate_device')), - logout: lock - .withPermit( - store.clear.pipe( +/** OpenCode auth over the provided credential store and HttpClient. */ +export const layerOpenCodeAuth = ( + options?: OpenCodeAuthOptions, +): Layer.Layer => + Layer.effect( + OpenCodeAuth, + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient + const store = yield* OpenCodeAuthStore + const server = options?.server ?? OPENCODE_CONSOLE_URL + const lock = Semaphore.makeUnsafe(1) + let current = yield* store.load + const save = (token: OpenCodeTokenData) => + store.save(token).pipe( Effect.mapError( (cause) => new OpenCodeAuthError({ reason: 'StoreFailed', message: cause.message, cause }), ), - Effect.tap(() => + Effect.tap((saved) => Effect.sync(() => { - current = Option.none() + current = Option.some(saved) }), ), - ), - ) - .pipe(Effect.withSpan('fold.opencode_auth.logout')), - } satisfies OpenCodeAuthService -}) + ) + const refresh = (token: OpenCodeTokenData) => + post( + client, + `${token.metadata?.server ?? server}/auth/device/token`, + { grant_type: 'refresh_token', refresh_token: token.refresh, client_id: OPENCODE_CLIENT_ID }, + Token, + ).pipe( + Effect.flatMap((next) => credential(client, token.metadata?.server ?? server, next)), + Effect.mapError( + (cause) => + new OpenCodeAuthError({ + reason: 'RefreshFailed', + message: 'Failed to refresh OpenCode credentials', + cause, + }), + ), + Effect.flatMap(save), + ) + const getToken = Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis + if (Option.isNone(current)) + return yield* new OpenCodeAuthError({ + reason: 'NotAuthenticated', + message: `No OpenCode credentials found in ${store.path}; run the device login first.`, + }) + return current.value.isExpired(now) ? yield* refresh(current.value) : current.value + }) + return { + get: lock.withPermit(getToken).pipe(Effect.withSpan('fold.opencode_auth.get')), + authenticateDevice: lock + .withPermit( + Effect.gen(function* () { + const device = yield* post( + client, + `${server}/auth/device/code`, + { client_id: OPENCODE_CLIENT_ID }, + Device, + ) + yield* ( + options?.onDeviceCode ?? + ((p) => Effect.log(`Open ${p.url} and enter code ${p.userCode}`)) + )({ url: `${server}${device.verification_uri_complete}`, userCode: device.user_code }) + return yield* poll( + client, + server, + device.device_code, + Duration.seconds(device.interval), + ).pipe(Effect.flatMap(save)) + }).pipe( + Effect.mapError((cause) => + Schema.is(OpenCodeAuthError)(cause) + ? cause + : new OpenCodeAuthError({ + reason: 'AuthorizationFailed', + message: 'Unable to start OpenCode device authorization', + cause, + }), + ), + ), + ) + .pipe(Effect.withSpan('fold.opencode_auth.authenticate_device')), + logout: lock + .withPermit( + store.clear.pipe( + Effect.mapError( + (cause) => + new OpenCodeAuthError({ reason: 'StoreFailed', message: cause.message, cause }), + ), + Effect.tap(() => + Effect.sync(() => { + current = Option.none() + }), + ), + ), + ) + .pipe(Effect.withSpan('fold.opencode_auth.logout')), + } satisfies OpenCodeAuthService + }), + ) /** Authenticate inference requests with the current user token and organization. */ -export const withOpenCodeAuth = (client: HttpClient.HttpClient, auth: OpenCodeAuthService): HttpClient.HttpClient => - client.pipe( - HttpClient.mapRequestEffect((request) => - auth.get.pipe( - Effect.map((token) => { - const headers: Record = {} - const orgId = token.metadata?.orgID - if (orgId !== undefined) headers['x-org-id'] = orgId - return request.pipe( - HttpClientRequest.bearerToken(token.access), - HttpClientRequest.setHeaders(headers), - ) - }), - Effect.mapError( - (cause) => - new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ request, cause, description: cause.message }), - }), +export const openCodeAuthenticatedClient = ( + client: HttpClient.HttpClient, +): Effect.Effect => + Effect.map(OpenCodeAuth, (auth) => + client.pipe( + HttpClient.mapRequestEffect((request) => + auth.get.pipe( + Effect.map((token) => { + const headers: Record = {} + const orgId = token.metadata?.orgID + if (orgId !== undefined) headers['x-org-id'] = orgId + return request.pipe( + HttpClientRequest.bearerToken(token.access), + HttpClientRequest.setHeaders(headers), + ) + }), + Effect.mapError( + (cause) => + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + cause, + description: cause.message, + }), + }), + ), ), ), ), diff --git a/packages/fold-opencode/src/OpenCodeModel.ts b/packages/fold-opencode/src/OpenCodeModel.ts index 777ba2c..09a7c5a 100644 --- a/packages/fold-opencode/src/OpenCodeModel.ts +++ b/packages/fold-opencode/src/OpenCodeModel.ts @@ -10,12 +10,12 @@ import { import { customModel, resolveOpenAiReasoning } from '@humanlayer/fold-core' import type { FoldModel, ReasoningLevel } from '@humanlayer/fold-core' import { Match, Context, Effect, Layer, Option, Schema } from 'effect' -import type { Scope } from 'effect' -import type { LanguageModel } from 'effect/unstable/ai' -import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from 'effect/unstable/http' +import type { FileSystem, Scope } from 'effect' +import { LanguageModel } from 'effect/unstable/ai' +import { HttpClient, HttpClientRequest, HttpClientResponse } from 'effect/unstable/http' -import type { OpenCodeAuthStore } from './AuthStore' -import { makeOpenCodeAuth, OPENCODE_CONSOLE_URL, withOpenCodeAuth } from './OpenCodeAuth' +import { layerOpenCodeAuthStore, type OpenCodeAuthStoreOptions } from './AuthStore' +import { layerOpenCodeAuth, OPENCODE_CONSOLE_URL, OpenCodeAuth, openCodeAuthenticatedClient } from './OpenCodeAuth' /** Public OpenCode Zen gateway used when Console does not return an override. */ export const OPENCODE_ZEN_API_URL = 'https://opencode.ai/zen/v1' @@ -89,9 +89,22 @@ export type OpenCodeModelOptions = { /** Explicit inference base URL. This takes precedence over Console's remote config. */ readonly apiUrl?: string readonly consoleUrl?: string - readonly store?: OpenCodeAuthStore + /** The auth document holding this provider's credential, under the `providerId` entry. Defaults to `~/.fold/auth.json`. */ + readonly authStorePath?: string } +/** The credential store for this model: its provider's entry in its auth document. */ +const authStoreOptionsFor = (options: OpenCodeModelOptions): OpenCodeAuthStoreOptions => + options.authStorePath === undefined + ? { providerId: options.providerId ?? 'opencode' } + : { providerId: options.providerId ?? 'opencode', path: options.authStorePath } + +/** This model's OpenCodeAuth over its provider's credential store, on the host's HttpClient and FileSystem. */ +const authLayerFor = (options: OpenCodeModelOptions) => + layerOpenCodeAuth(options.consoleUrl === undefined ? {} : { server: options.consoleUrl }).pipe( + Layer.provide(layerOpenCodeAuthStore(authStoreOptionsFor(options))), + ) + const fetchRemoteProviders = (authenticated: HttpClient.HttpClient, server: string) => authenticated.execute(HttpClientRequest.get(`${server}/api/config`).pipe(HttpClientRequest.acceptJson)).pipe( Effect.flatMap((response) => @@ -112,55 +125,62 @@ const fetchRemoteProviders = (authenticated: HttpClient.HttpClient, server: stri /** Construct the Effect LanguageModel backed by stored OpenCode OAuth credentials. */ export const makeOpenCodeLanguageModel = ( options: OpenCodeModelOptions = {}, -): Effect.Effect => - Effect.gen(function* () { - const httpContext = yield* Layer.build(FetchHttpClient.layer) - const http = Context.get(httpContext, HttpClient.HttpClient) - const authOptions: { store?: OpenCodeAuthStore; server?: string } = {} - if (options.store !== undefined) authOptions.store = options.store - if (options.consoleUrl !== undefined) authOptions.server = options.consoleUrl - const auth = yield* makeOpenCodeAuth(authOptions).pipe(Effect.provideService(HttpClient.HttpClient, http)) - const authenticated = withOpenCodeAuth(http, auth) - const requestedModel = options.model ?? DEFAULT_OPENCODE_MODEL_ID - const credential = yield* Effect.option(auth.get) - const credentialServer = Option.isSome(credential) ? credential.value.metadata?.server : undefined - const providers = yield* fetchRemoteProviders( - authenticated, - options.consoleUrl ?? credentialServer ?? OPENCODE_CONSOLE_URL, - ) - const resolved = resolveOpenCodeModelConfig(providers, requestedModel, options.apiUrl) - const reasoning = resolveOpenAiReasoning(options.reasoning ?? 'off') - const config = Match.valueTags(reasoning, { - disabled: () => ({}), - effort: ({ effort }) => ({ reasoning: { effort } }), - }) +): Effect.Effect => + Layer.build( + Layer.effect( + LanguageModel.LanguageModel, + Effect.gen(function* () { + const auth = yield* OpenCodeAuth + const authenticated = yield* openCodeAuthenticatedClient(yield* HttpClient.HttpClient) + const requestedModel = options.model ?? DEFAULT_OPENCODE_MODEL_ID + const credential = yield* Effect.option(auth.get) + const credentialServer = Option.isSome(credential) ? credential.value.metadata?.server : undefined + const providers = yield* fetchRemoteProviders( + authenticated, + options.consoleUrl ?? credentialServer ?? OPENCODE_CONSOLE_URL, + ) + const resolved = resolveOpenCodeModelConfig(providers, requestedModel, options.apiUrl) + const reasoning = resolveOpenAiReasoning(options.reasoning ?? 'off') + const config = Match.valueTags(reasoning, { + disabled: () => ({}), + effort: ({ effort }) => ({ reasoning: { effort } }), + }) - if (resolved.protocol === 'chat-completions') { - const clientContext = yield* Layer.build(ChatClient.layer({ apiUrl: resolved.apiUrl })).pipe( - Effect.provideService(HttpClient.HttpClient, authenticated), - ) - return yield* ChatLanguageModel.make({ - model: resolved.model, - config, - }).pipe(Effect.provideService(ChatClient.OpenAiClient, Context.get(clientContext, ChatClient.OpenAiClient))) - } + if (resolved.protocol === 'chat-completions') { + const clientContext = yield* Layer.build(ChatClient.layer({ apiUrl: resolved.apiUrl })).pipe( + Effect.provideService(HttpClient.HttpClient, authenticated), + ) + return yield* ChatLanguageModel.make({ + model: resolved.model, + config, + }).pipe( + Effect.provideService( + ChatClient.OpenAiClient, + Context.get(clientContext, ChatClient.OpenAiClient), + ), + ) + } - const clientContext = yield* Layer.build(ResponsesClient.layer({ apiUrl: resolved.apiUrl })).pipe( - Effect.provideService(HttpClient.HttpClient, authenticated), - ) - return yield* ResponsesLanguageModel.make({ - model: resolved.model, - config, - }).pipe( - Effect.provideService( - ResponsesClient.OpenAiClient, - Context.get(clientContext, ResponsesClient.OpenAiClient), - ), - ) - }) + const clientContext = yield* Layer.build(ResponsesClient.layer({ apiUrl: resolved.apiUrl })).pipe( + Effect.provideService(HttpClient.HttpClient, authenticated), + ) + return yield* ResponsesLanguageModel.make({ + model: resolved.model, + config, + }).pipe( + Effect.provideService( + ResponsesClient.OpenAiClient, + Context.get(clientContext, ResponsesClient.OpenAiClient), + ), + ) + }), + ).pipe(Layer.provide(authLayerFor(options))), + ).pipe(Effect.map((context) => Context.get(context, LanguageModel.LanguageModel))) /** Create a Fold model descriptor directly usable by fold-agent's public session APIs. */ -export const openCodeModel = (options: OpenCodeModelOptions = {}): FoldModel => { +export const openCodeModel = ( + options: OpenCodeModelOptions = {}, +): FoldModel => { const reasoning = options.reasoning ?? 'off' return customModel({ activeModel: { @@ -176,5 +196,6 @@ export const openCodeModel = (options: OpenCodeModelOptions = {}): FoldModel => } /** Convenience descriptor for OpenCode Zen's OpenAI-compatible Grok Build model. */ -export const grokBuildModel = (options: Omit = {}): FoldModel => - openCodeModel({ ...options, model: GROK_BUILD_MODEL_ID }) +export const grokBuildModel = ( + options: Omit = {}, +): FoldModel => openCodeModel({ ...options, model: GROK_BUILD_MODEL_ID }) diff --git a/packages/fold-opencode/test/AuthStore.vi.test.ts b/packages/fold-opencode/test/AuthStore.vi.test.ts new file mode 100644 index 0000000..8c10329 --- /dev/null +++ b/packages/fold-opencode/test/AuthStore.vi.test.ts @@ -0,0 +1,65 @@ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' +import { describe, expect, it } from '@effect/vitest' +import { Effect, Layer, Option, Schema } from 'effect' + +import { layerOpenCodeAuthStore, OpenCodeAuthDocument, OpenCodeAuthStore, OpenCodeTokenData } from '../src/AuthStore' + +const tempStorePath = (): string => join(mkdtempSync(join(tmpdir(), 'fold-opencode-store-')), 'auth.json') + +const decodeDocument = Schema.decodeSync(Schema.fromJsonString(OpenCodeAuthDocument)) + +const token = new OpenCodeTokenData({ + type: 'oauth', + access: 'access', + refresh: 'refresh', + expires: 42, + metadata: { server: 'https://opencode.ai', accountID: 'acc', email: 'a@b.c' }, +}) + +describe('OpenCodeAuthStore', () => { + it.effect('round-trips a token and preserves other providers entries', () => + Effect.gen(function* () { + const path = tempStorePath() + writeFileSync(path, JSON.stringify({ codex: { type: 'oauth', nested: [1, null] } })) + yield* Effect.gen(function* () { + const store = yield* OpenCodeAuthStore + + yield* store.save(token) + const loaded = yield* store.load + expect(Option.getOrUndefined(loaded)?.metadata?.accountID).toBe('acc') + + const document = decodeDocument(readFileSync(path, 'utf8')) + expect(document['codex']).toEqual({ type: 'oauth', nested: [1, null] }) + expect(document['opencode']).toEqual({ + type: 'oauth', + access: 'access', + refresh: 'refresh', + expires: 42, + metadata: { server: 'https://opencode.ai', accountID: 'acc', email: 'a@b.c' }, + }) + + yield* store.clear + expect(decodeDocument(readFileSync(path, 'utf8'))['opencode']).toBeUndefined() + }).pipe(Effect.provide(layerOpenCodeAuthStore({ path }).pipe(Layer.provide(NodeFileSystem.layer)))) + }), + ) + + it.effect('loads nothing from a corrupt document and refuses to overwrite it', () => + Effect.gen(function* () { + const path = tempStorePath() + writeFileSync(path, '{ not json') + yield* Effect.gen(function* () { + const store = yield* OpenCodeAuthStore + + expect(Option.isNone(yield* store.load)).toBe(true) + const error = yield* Effect.flip(store.save(token)) + expect(error.reason).toBe('InvalidDocument') + expect(readFileSync(path, 'utf8')).toBe('{ not json') + }).pipe(Effect.provide(layerOpenCodeAuthStore({ path }).pipe(Layer.provide(NodeFileSystem.layer)))) + }), + ) +}) diff --git a/packages/fold-opencode/test/OpenCodeAuth.vi.test.ts b/packages/fold-opencode/test/OpenCodeAuth.vi.test.ts index 115cfc9..1509f93 100644 --- a/packages/fold-opencode/test/OpenCodeAuth.vi.test.ts +++ b/packages/fold-opencode/test/OpenCodeAuth.vi.test.ts @@ -1,10 +1,10 @@ import { describe, it } from '@effect/vitest' -import { Effect, Layer, Option } from 'effect' +import { Effect, Layer } from 'effect' import { HttpClient, HttpClientResponse } from 'effect/unstable/http' import { expect } from 'vitest' -import type { OpenCodeAuthStore } from '../src/AuthStore' -import { makeOpenCodeAuth } from '../src/OpenCodeAuth' +import { OpenCodeAuthStore, type OpenCodeAuthStoreService } from '../src/AuthStore' +import { layerOpenCodeAuth, OpenCodeAuth } from '../src/OpenCodeAuth' const response = (request: Parameters[0], body: unknown) => HttpClientResponse.fromWeb( @@ -16,9 +16,9 @@ describe('OpenCode device OAuth', () => { const saved: Array = [] const paths: Array = [] let polls = 0 - const store: OpenCodeAuthStore = { + const store: OpenCodeAuthStoreService = { path: '/tmp/auth.json', - load: Effect.succeed(Option.none()), + load: Effect.succeedNone, save: (token) => Effect.sync(() => { saved.push(token.access) @@ -55,15 +55,7 @@ describe('OpenCode device OAuth', () => { }), ) return Effect.gen(function* () { - const auth = yield* makeOpenCodeAuth({ - store, - server: 'https://console.test', - onDeviceCode: ({ url, userCode }) => - Effect.sync(() => { - expect(url).toBe('https://console.test/activate') - expect(userCode).toBe('ABCD') - }), - }) + const auth = yield* OpenCodeAuth const token = yield* auth.authenticateDevice expect(token.metadata?.orgID).toBe('org-a') expect(saved).toEqual(['access']) @@ -74,6 +66,18 @@ describe('OpenCode device OAuth', () => { '/api/user', '/api/orgs', ]) - }).pipe(Effect.provide(Layer.succeed(HttpClient.HttpClient, client))) + }).pipe( + Effect.provide( + layerOpenCodeAuth({ + server: 'https://console.test', + onDeviceCode: ({ url, userCode }) => + Effect.sync(() => { + expect(url).toBe('https://console.test/activate') + expect(userCode).toBe('ABCD') + }), + }).pipe(Layer.provide(Layer.succeed(OpenCodeAuthStore, store))), + ), + Effect.provideService(HttpClient.HttpClient, client), + ) }) }) diff --git a/packages/fold-tui-theme/src/github/stats.ts b/packages/fold-tui-theme/src/github/stats.ts index 3b49742..000380d 100644 --- a/packages/fold-tui-theme/src/github/stats.ts +++ b/packages/fold-tui-theme/src/github/stats.ts @@ -62,7 +62,7 @@ export function updatesByDay(items: ReadonlyArray, days: number): Array< startOfToday.setHours(0, 0, 0, 0) const todayMs = startOfToday.getTime() - const buckets = new Array(days).fill(0) + const buckets = Array.from({ length: days }, () => 0) for (const item of items) { const updated = new Date(item.updatedAt).getTime() if (Number.isNaN(updated)) continue diff --git a/packages/fold-xai/src/AuthStore.ts b/packages/fold-xai/src/AuthStore.ts index fdc3ef2..e1f643c 100644 --- a/packages/fold-xai/src/AuthStore.ts +++ b/packages/fold-xai/src/AuthStore.ts @@ -1,16 +1,16 @@ /** * File-backed Xai credential store: one provider-keyed JSON document (default `~/.fold/auth.json`) * holding OAuth tokens only (D23). Field names are agentlayer-compatible (`access`/`refresh`/`expires`/ - * `accountId`), so existing entries copy across verbatim. Reads degrade to "no credentials" on missing - * or malformed data - the document may hold other providers' entries, so a bad xai entry is skipped, - * never clobbered; writes merge over the existing document and force `0600` permissions. The FileSystem - * is a default-or-override seam like fold-agent tools: tests pass an implementation, everyone else gets - * the Node platform filesystem. + * `accountId`), so existing entries copy across verbatim. The document may hold other providers' + * entries, so it decodes as provider-keyed JSON and only our entry decodes as a token. A missing file is + * an empty document. `load` degrades to "no credentials" (with a logged warning) on an unreadable or + * corrupt document or a bad xai entry; `save`/`clear` fail on an unreadable or corrupt document rather + * than clobber it. Writes merge over the existing document and force `0600` permissions. */ import { homedir } from 'node:os' import { dirname, join } from 'node:path' -import { Effect, FileSystem, Option, Schema } from 'effect' +import { Context, Effect, FileSystem, Layer, Option, Schema } from 'effect' /** Milliseconds before nominal expiry a token is already treated as expired (clanka parity). */ export const TOKEN_EXPIRY_BUFFER_MS = 30_000 @@ -23,7 +23,7 @@ export class XaiTokenData extends Schema.Class('fold/XaiTokenData' type: Schema.Literal('oauth'), access: Schema.String, refresh: Schema.String, - expires: Schema.Number, + expires: Schema.Finite, accountId: Schema.optional(Schema.String), }) { /** True when the token is expired - or within the safety buffer of expiring - at `nowMs`. */ @@ -32,15 +32,15 @@ export class XaiTokenData extends Schema.Class('fold/XaiTokenData' } } -/** Auth store persistence failure (reads never fail - they degrade to absent credentials). */ +/** Auth store failure: the document could not be read, is not provider-keyed JSON, or could not be written. */ export class XaiAuthStoreError extends Schema.TaggedError()('XaiAuthStoreError', { - reason: Schema.Literals(['WriteFailed']), + reason: Schema.Literals(['ReadFailed', 'InvalidDocument', 'WriteFailed']), message: Schema.String, cause: Schema.optional(Schema.Defect()), }) {} /** The credential store one XaiAuth instance persists through. */ -export type XaiAuthStore = { +export type XaiAuthStoreService = { /** Absolute path of the backing JSON document (used in error messages and guidance). */ readonly path: string readonly load: Effect.Effect> @@ -48,96 +48,135 @@ export type XaiAuthStore = { readonly clear: Effect.Effect } -/** Options for {@link makeXaiAuthStore}. */ -export type MakeXaiAuthStoreOptions = { +/** The credential store XaiAuth persists through; {@link layerXaiAuthStore} provides the file-backed one. */ +export class XaiAuthStore extends Context.Service()('fold/XaiAuthStore') {} + +/** Options for {@link layerXaiAuthStore}. */ +export type XaiAuthStoreOptions = { /** Path of the auth document. Defaults to `~/.fold/auth.json`. */ readonly path?: string /** Key of this provider's entry in the document. Defaults to `xai`. */ readonly providerId?: string } -/** The auth document is provider-keyed; entries other than ours are opaque and preserved verbatim. */ -const AuthDocument = Schema.Record(Schema.String, Schema.Unknown) +/** The auth document: JSON entries keyed by provider id. Entries other than ours are preserved verbatim. */ +export const XaiAuthDocument = Schema.Record(Schema.String, Schema.Json) +export type XaiAuthDocument = typeof XaiAuthDocument.Type -const decodeDocument = Schema.decodeUnknownOption(Schema.fromJsonString(AuthDocument)) +const emptyDocument: XaiAuthDocument = {} -const decodeToken = Schema.decodeUnknownOption(XaiTokenData) +const decodeDocument = Schema.decodeEffect(Schema.fromJsonString(XaiAuthDocument)) -const encodeToken = (token: XaiTokenData): Record => { - const encoded: Record = { - type: token.type, - access: token.access, - refresh: token.refresh, - expires: token.expires, - } - if (token.accountId !== undefined) encoded['accountId'] = token.accountId - return encoded -} +const encodeDocument = Schema.encodeEffect(Schema.fromJsonString(XaiAuthDocument, { space: 2 })) -/** Build a file-backed Xai credential store. */ -export const makeXaiAuthStore = ( - options?: MakeXaiAuthStoreOptions, -): Effect.Effect => - Effect.map(FileSystem.FileSystem, (fs) => { - const path = options?.path ?? defaultAuthStorePath() - const providerId = options?.providerId ?? 'xai' - - const readDocument: Effect.Effect> = fs.readFileString(path).pipe( - Effect.flatMap((content) => { - const document = decodeDocument(content) - return Option.isSome(document) - ? Effect.succeed(document.value) - : Effect.logWarning(`Auth store ${path} is not a JSON object; treating it as empty`).pipe( - Effect.as>({}), - ) - }), - // A missing (or unreadable) document is simply "no credentials stored yet". - Effect.catch(() => Effect.succeed>({})), - ) - - const writeDocument = (document: Record): Effect.Effect => - Effect.gen(function* () { - yield* fs.makeDirectory(dirname(path), { recursive: true }) - yield* fs.writeFileString(path, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 }) - // writeFileString's mode only applies on creation; force 0600 on pre-existing documents too. - yield* fs.chmod(path, 0o600) - }).pipe( +/** Our entry's JSON codec: decodes a document value into a token and encodes a token back to JSON. */ +const XaiTokenEntry = Schema.toCodecJson(XaiTokenData) + +const decodeTokenEntry = Schema.decodeOption(XaiTokenEntry) + +const encodeTokenEntry = Schema.encodeEffect(XaiTokenEntry) + +/** A file-backed xAI credential store. */ +export const layerXaiAuthStore = ( + options?: XaiAuthStoreOptions, +): Layer.Layer => + Layer.effect( + XaiAuthStore, + Effect.map(FileSystem.FileSystem, (fs): XaiAuthStoreService => { + const path = options?.path ?? defaultAuthStorePath() + const providerId = options?.providerId ?? 'xai' + + // A missing document is simply "no credentials stored yet"; any other read failure is real. + const readDocument: Effect.Effect = fs.readFileString(path).pipe( + Effect.asSome, + Effect.catchReason('PlatformError', 'NotFound', () => Effect.succeed(Option.none())), Effect.mapError( (cause) => new XaiAuthStoreError({ - reason: 'WriteFailed', - message: `Failed to write the auth store at ${path}`, + reason: 'ReadFailed', + message: `Failed to read the auth store at ${path}`, cause, }), ), + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(emptyDocument), + onSome: (text) => + decodeDocument(text).pipe( + Effect.mapError( + (cause) => + new XaiAuthStoreError({ + reason: 'InvalidDocument', + message: `Auth store ${path} is not a JSON object of provider entries`, + cause, + }), + ), + ), + }), + ), ) - const load = Effect.gen(function* () { - const document = yield* readDocument - const entry = document[providerId] - if (entry === undefined) return Option.none() + const writeDocument = (document: XaiAuthDocument): Effect.Effect => + Effect.gen(function* () { + yield* fs.makeDirectory(dirname(path), { recursive: true }) + const text = yield* encodeDocument(document) + yield* fs.writeFileString(path, `${text}\n`, { mode: 0o600 }) + // writeFileString's mode only applies on creation; force 0600 on pre-existing documents too. + yield* fs.chmod(path, 0o600) + }).pipe( + Effect.mapError( + (cause) => + new XaiAuthStoreError({ + reason: 'WriteFailed', + message: `Failed to write the auth store at ${path}`, + cause, + }), + ), + ) + + const load = Effect.gen(function* () { + const document = yield* readDocument + const entry = document[providerId] + if (entry === undefined) return Option.none() - const token = decodeToken(entry) - if (Option.isNone(token)) { - yield* Effect.logWarning(`Ignoring invalid "${providerId}" entry in ${path}`) - } + const token = decodeTokenEntry(entry) + if (Option.isNone(token)) { + yield* Effect.logWarning(`Ignoring invalid "${providerId}" entry in ${path}`) + } - return token - }).pipe(Effect.withSpan('fold.xaiAuthStore.load')) + return token + }).pipe( + Effect.catchTag('XaiAuthStoreError', (error) => + Effect.logWarning(`${error.message}; treating it as holding no credentials`, error.cause).pipe( + Effect.as(Option.none()), + ), + ), + Effect.withSpan('fold.xaiAuthStore.load'), + ) - const save = (token: XaiTokenData) => - Effect.gen(function* () { + const save = Effect.fn('fold.xaiAuthStore.save')(function* (token: XaiTokenData) { const document = yield* readDocument - yield* writeDocument({ ...document, [providerId]: encodeToken(token) }) + const entry = yield* encodeTokenEntry(token).pipe( + Effect.mapError( + (cause) => + new XaiAuthStoreError({ + reason: 'WriteFailed', + message: `Failed to encode the "${providerId}" entry for ${path}`, + cause, + }), + ), + ) + yield* writeDocument({ ...document, [providerId]: entry }) return token - }).pipe(Effect.withSpan('fold.xaiAuthStore.save')) + }) - const clear = Effect.gen(function* () { - const document = yield* readDocument - if (document[providerId] === undefined) return - const { [providerId]: _removed, ...rest } = document - yield* writeDocument(rest) - }).pipe(Effect.withSpan('fold.xaiAuthStore.clear')) - - return { path, load, save, clear } - }) + const clear = Effect.gen(function* () { + const document = yield* readDocument + if (document[providerId] === undefined) return + const { [providerId]: _removed, ...rest } = document + yield* writeDocument(rest) + }).pipe(Effect.withSpan('fold.xaiAuthStore.clear')) + + return { path, load, save, clear } + }), + ) diff --git a/packages/fold-xai/src/OAuthFlows.ts b/packages/fold-xai/src/OAuthFlows.ts index 58d0245..b0c2f1b 100644 --- a/packages/fold-xai/src/OAuthFlows.ts +++ b/packages/fold-xai/src/OAuthFlows.ts @@ -37,7 +37,7 @@ export class XaiAuthError extends Schema.TaggedError()('XaiAuthErr const TokenResponse = Schema.Struct({ access_token: Schema.String, refresh_token: Schema.optional(Schema.String), - expires_in: Schema.optional(Schema.Number), + expires_in: Schema.optional(Schema.Finite), }) const DeviceResponse = Schema.Struct({ @@ -45,8 +45,8 @@ const DeviceResponse = Schema.Struct({ user_code: Schema.String, verification_uri: Schema.String, verification_uri_complete: Schema.optional(Schema.String), - expires_in: Schema.optional(Schema.Number), - interval: Schema.optional(Schema.Number), + expires_in: Schema.optional(Schema.Finite), + interval: Schema.optional(Schema.Finite), }) const DeviceError = Schema.Struct({ @@ -54,11 +54,8 @@ const DeviceError = Schema.Struct({ error_description: Schema.optional(Schema.String), }) -const failure = (reason: XaiAuthError['reason'], message: string, cause?: unknown): XaiAuthError => { - const options: { reason: XaiAuthError['reason']; message: string; cause?: unknown } = { reason, message } - if (cause !== undefined) options.cause = cause - return new XaiAuthError(options) -} +const failure = (reason: XaiAuthError['reason'], message: string, cause?: unknown): XaiAuthError => + new XaiAuthError({ reason, message, cause }) const tokenData = (payload: typeof TokenResponse.Type, fallbackRefresh?: string) => Effect.map( @@ -73,7 +70,7 @@ const tokenData = (payload: typeof TokenResponse.Type, fallbackRefresh?: string) ) /** Scope and harden an HttpClient for xAI's OAuth issuer. */ -export const makeXaiIssuerClient = (client: HttpClient.HttpClient): HttpClient.HttpClient => +export const xaiIssuerClient = (client: HttpClient.HttpClient): HttpClient.HttpClient => client.pipe( HttpClient.mapRequest(HttpClientRequest.prependUrl(XAI_ISSUER)), HttpClient.filterStatusOk, @@ -242,6 +239,8 @@ export const runXaiBrowserFlow = Effect.fn('fold.xaiAuth.browserFlow')(function* const nonce = base64Url(yield* crypto.randomBytes(32).pipe(Effect.orDie)) const code = yield* Effect.scoped( Effect.gen(function* () { + const effectContext = yield* Effect.context() + const callback = yield* Deferred.make() yield* Effect.acquireRelease( Effect.tryPromise({ @@ -250,7 +249,9 @@ export const runXaiBrowserFlow = Effect.fn('fold.xaiAuth.browserFlow')(function* const server = createServer((request, response) => { const url = new URL(request.url ?? '/', XAI_BROWSER_REDIRECT_URI) const fail = (message: string, status = 400) => { - Effect.runSync(Deferred.fail(callback, failure('BrowserFlowFailed', message))) + Effect.runSyncWith(effectContext)( + Deferred.fail(callback, failure('BrowserFlowFailed', message)), + ) response.writeHead(status, { 'Content-Type': 'text/plain' }) response.end(message) } @@ -262,7 +263,7 @@ export const runXaiBrowserFlow = Effect.fn('fold.xaiAuth.browserFlow')(function* return fail('Invalid state - potential CSRF attack') const received = url.searchParams.get('code') if (received === null) return fail('Missing authorization code') - Effect.runSync(Deferred.succeed(callback, received)) + Effect.runSyncWith(effectContext)(Deferred.succeed(callback, received)) response.writeHead(200, { 'Content-Type': 'text/plain' }) response.end('xAI authorization successful. Return to fold.') }) diff --git a/packages/fold-xai/src/XaiAuth.ts b/packages/fold-xai/src/XaiAuth.ts index 7a83e19..afb4511 100644 --- a/packages/fold-xai/src/XaiAuth.ts +++ b/packages/fold-xai/src/XaiAuth.ts @@ -1,17 +1,11 @@ /** Persistent, single-flight xAI OAuth credential service and authenticated HTTP decorator. */ import * as NodeCrypto from '@effect/platform-node/NodeCrypto' -import { Clock, Context, Effect, Option, Semaphore } from 'effect' +import { Clock, Context, Effect, Layer, Option, Semaphore } from 'effect' import { HttpClient, HttpClientError, HttpClientRequest } from 'effect/unstable/http' -import { makeXaiAuthStore, type XaiAuthStore, type XaiTokenData } from './AuthStore' +import { XaiAuthStore, type XaiAuthStoreError, type XaiTokenData } from './AuthStore' import type { XaiBrowserFlowOptions, XaiDevicePrompt } from './OAuthFlows' -import { - makeXaiIssuerClient, - refreshXaiAccessToken, - runXaiBrowserFlow, - runXaiDeviceFlow, - XaiAuthError, -} from './OAuthFlows' +import { xaiIssuerClient, refreshXaiAccessToken, runXaiBrowserFlow, runXaiDeviceFlow, XaiAuthError } from './OAuthFlows' export type XaiAuthService = { readonly get: Effect.Effect @@ -22,8 +16,8 @@ export type XaiAuthService = { export class XaiAuth extends Context.Service()('fold/XaiAuth') {} -export type MakeXaiAuthOptions = { - readonly store?: XaiAuthStore +/** Options for {@link layerXaiAuth}. */ +export type XaiAuthOptions = { readonly onDeviceCode?: (prompt: XaiDevicePrompt) => Effect.Effect readonly onBrowserUrl?: (url: string) => Effect.Effect readonly browser?: Pick @@ -33,92 +27,104 @@ const devicePrompt = (prompt: XaiDevicePrompt) => Effect.log(`Open ${prompt.verificationUri} and enter code: ${prompt.userCode}`) const browserPrompt = (url: string) => Effect.log(`Open this URL to authenticate xAI:\n${url}`) -/** Construct xAI auth over the ambient HttpClient. Interactive flows are explicit methods. */ -export const makeXaiAuth = Effect.fnUntraced(function* (options?: MakeXaiAuthOptions) { - const store = options?.store ?? (yield* makeXaiAuthStore()) - const client = makeXaiIssuerClient(yield* HttpClient.HttpClient) - const semaphore = Semaphore.makeUnsafe(1) - let current = yield* store.load - const storeError = (cause: unknown) => - new XaiAuthError({ - reason: 'StoreFailed', - message: `Failed to persist xAI credentials to ${store.path}`, - cause, - }) - const save = (token: XaiTokenData) => - store.save(token).pipe( - Effect.mapError(storeError), - Effect.tap(() => - Effect.sync(() => { - current = Option.some(token) - }), - ), - ) - const get = Effect.uninterruptibleMask( - Effect.fnUntraced(function* (restore) { - const now = yield* Clock.currentTimeMillis - if (Option.isSome(current) && !current.value.isExpired(now)) return current.value - if (Option.isNone(current)) - return yield* new XaiAuthError({ - reason: 'NotAuthenticated', - message: `No xAI OAuth credentials found in ${store.path}`, +/** xAI auth over the provided credential store and HttpClient. Interactive flows are explicit methods. */ +export const layerXaiAuth = ( + options?: XaiAuthOptions, +): Layer.Layer => + Layer.effect( + XaiAuth, + Effect.gen(function* () { + const store = yield* XaiAuthStore + const client = xaiIssuerClient(yield* HttpClient.HttpClient) + const semaphore = Semaphore.makeUnsafe(1) + let current = yield* store.load + const storeError = (cause: XaiAuthStoreError) => + new XaiAuthError({ + reason: 'StoreFailed', + message: `Failed to persist xAI credentials to ${store.path}`, + cause, }) - return yield* restore(refreshXaiAccessToken(client, current.value.refresh)).pipe(Effect.flatMap(save)) - }), - ) - const run = (flow: Effect.Effect) => - Effect.uninterruptibleMask((restore) => restore(flow).pipe(Effect.flatMap(save))) - return { - get: semaphore.withPermit(get).pipe(Effect.withSpan('fold.xaiAuth.get')), - authenticateDevice: semaphore - .withPermit(run(runXaiDeviceFlow({ client, onCode: options?.onDeviceCode ?? devicePrompt }))) - .pipe(Effect.withSpan('fold.xaiAuth.authenticateDevice')), - authenticateBrowser: semaphore - .withPermit( - run( - runXaiBrowserFlow({ - client, - onUrl: options?.onBrowserUrl ?? browserPrompt, - ...options?.browser, - }).pipe(Effect.provide(NodeCrypto.layer)), - ), - ) - .pipe(Effect.withSpan('fold.xaiAuth.authenticateBrowser')), - logout: semaphore - .withPermit( - store.clear.pipe( + const save = (token: XaiTokenData) => + store.save(token).pipe( Effect.mapError(storeError), Effect.tap(() => Effect.sync(() => { - current = Option.none() + current = Option.some(token) }), ), - ), + ) + const get = Effect.uninterruptibleMask( + Effect.fnUntraced(function* (restore) { + const now = yield* Clock.currentTimeMillis + if (Option.isSome(current) && !current.value.isExpired(now)) return current.value + if (Option.isNone(current)) + return yield* new XaiAuthError({ + reason: 'NotAuthenticated', + message: `No xAI OAuth credentials found in ${store.path}`, + }) + return yield* restore(refreshXaiAccessToken(client, current.value.refresh)).pipe( + Effect.flatMap(save), + ) + }), ) - .pipe(Effect.withSpan('fold.xaiAuth.logout')), - } satisfies XaiAuthService -}) + const run = (flow: Effect.Effect) => + Effect.uninterruptibleMask((restore) => restore(flow).pipe(Effect.flatMap(save))) + return { + get: semaphore.withPermit(get).pipe(Effect.withSpan('fold.xaiAuth.get')), + authenticateDevice: semaphore + .withPermit(run(runXaiDeviceFlow({ client, onCode: options?.onDeviceCode ?? devicePrompt }))) + .pipe(Effect.withSpan('fold.xaiAuth.authenticateDevice')), + authenticateBrowser: semaphore + .withPermit( + run( + runXaiBrowserFlow({ + client, + onUrl: options?.onBrowserUrl ?? browserPrompt, + ...options?.browser, + }).pipe(Effect.provide(NodeCrypto.layer)), + ), + ) + .pipe(Effect.withSpan('fold.xaiAuth.authenticateBrowser')), + logout: semaphore + .withPermit( + store.clear.pipe( + Effect.mapError(storeError), + Effect.tap(() => + Effect.sync(() => { + current = Option.none() + }), + ), + ), + ) + .pipe(Effect.withSpan('fold.xaiAuth.logout')), + } satisfies XaiAuthService + }), + ) /** Inject the current OAuth bearer token into every request without mutating caller headers. */ -export const withXaiAuth = (client: HttpClient.HttpClient, auth: XaiAuthService): HttpClient.HttpClient => - client.pipe( - HttpClient.mapRequestEffect((request) => - auth.get.pipe( - Effect.map((token) => - request.pipe( - HttpClientRequest.bearerToken(token.access), - HttpClientRequest.setHeader('User-Agent', 'fold/xai-oauth'), +export const xaiAuthenticatedClient = ( + client: HttpClient.HttpClient, +): Effect.Effect => + Effect.map(XaiAuth, (auth) => + client.pipe( + HttpClient.mapRequestEffect((request) => + auth.get.pipe( + Effect.map((token) => + request.pipe( + HttpClientRequest.bearerToken(token.access), + HttpClientRequest.setHeader('User-Agent', 'fold/xai-oauth'), + ), ), - ), - Effect.mapError( - (cause) => - new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ - request, - cause, - description: `xAI authentication failed: ${cause.message}`, + Effect.mapError( + (cause) => + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + cause, + description: `xAI authentication failed: ${cause.message}`, + }), }), - }), + ), ), ), ), diff --git a/packages/fold-xai/src/XaiModel.ts b/packages/fold-xai/src/XaiModel.ts index 3f34b1c..3c50452 100644 --- a/packages/fold-xai/src/XaiModel.ts +++ b/packages/fold-xai/src/XaiModel.ts @@ -1,4 +1,3 @@ -import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' /** FoldModel factory for xAI's OpenAI-compatible inference API authenticated with OAuth. */ import { OpenAiClient, OpenAiLanguageModel } from '@humanlayer/effect-ai-openai-compat' import type { @@ -8,13 +7,13 @@ import type { } from '@humanlayer/effect-ai-openai-compat/OpenAiClient' 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 type { Scope } from 'effect' -import type { LanguageModel } from 'effect/unstable/ai' -import { FetchHttpClient, HttpClient } from 'effect/unstable/http' +import { Context, Effect, Layer, Match, Option, Predicate, Schema, Stream } from 'effect' +import type { FileSystem, Scope } from 'effect' +import { LanguageModel } from 'effect/unstable/ai' +import { HttpClient } from 'effect/unstable/http' -import type { XaiAuthStore } from './AuthStore' -import { makeXaiAuth, withXaiAuth } from './XaiAuth' +import { layerXaiAuthStore, type XaiAuthStoreOptions } from './AuthStore' +import { layerXaiAuth, xaiAuthenticatedClient } from './XaiAuth' import { DEFAULT_XAI_MODEL_ID } from './XaiModelCatalog' export const XAI_API_URL = 'https://api.x.ai/v1' @@ -48,7 +47,10 @@ const normalizeXaiResponse = - typeof response === 'string' || '_tag' in response ? response : normalizeXaiResponse(response) + Match.value(response).pipe( + Match.when({ usage: Match.defined }, (chunk) => normalizeXaiResponse(chunk)), + Match.orElse((event) => event), + ) /** Normalize xAI's token semantics before the stock OpenAI-compatible model derives usage details. */ export const decorateXaiClient = (inner: OpenAiClient.Service): OpenAiClient.Service => ({ @@ -66,30 +68,42 @@ export type XaiModelOptions = { readonly reasoning?: ReasoningLevel readonly providerId?: string readonly apiUrl?: string - readonly store?: XaiAuthStore + /** The auth document holding this provider's credential, under the `providerId` entry. Defaults to `~/.fold/auth.json`. */ + readonly authStorePath?: string } +/** The credential store for this model: its provider's entry in its auth document. */ +const authStoreOptionsFor = (options: XaiModelOptions): XaiAuthStoreOptions => + options.authStorePath === undefined + ? { providerId: options.providerId ?? 'xai' } + : { providerId: options.providerId ?? 'xai', path: options.authStorePath } + +/** This model's XaiAuth over its provider's credential store, on the host's HttpClient and FileSystem. */ +const authLayerFor = (options: XaiModelOptions) => + layerXaiAuth().pipe(Layer.provide(layerXaiAuthStore(authStoreOptionsFor(options)))) + /** Build xAI's stock OpenAI-compatible LanguageModel over the OAuth transport. */ export const makeXaiLanguageModel = ( options: XaiModelOptions, -): Effect.Effect => - Effect.gen(function* () { - const httpContext = yield* Layer.build(FetchHttpClient.layer) - const base = Context.get(httpContext, HttpClient.HttpClient) - const authOptions: { store?: XaiAuthStore } = {} - if (options.store !== undefined) authOptions.store = options.store - const auth = yield* makeXaiAuth(authOptions).pipe(Effect.provideService(HttpClient.HttpClient, base)) - const clientContext = yield* Layer.build(OpenAiClient.layer({ apiUrl: options.apiUrl ?? XAI_API_URL })).pipe( - Effect.provideService(HttpClient.HttpClient, withXaiAuth(base, auth)), - ) - const client = decorateXaiClient(Context.get(clientContext, OpenAiClient.OpenAiClient)) - return yield* OpenAiLanguageModel.make({ model: options.model ?? DEFAULT_XAI_MODEL_ID }).pipe( - Effect.provideService(OpenAiClient.OpenAiClient, client), - ) - }).pipe(Effect.provide(NodeFileSystem.layer)) +): Effect.Effect => + Layer.build( + Layer.effect( + LanguageModel.LanguageModel, + Effect.gen(function* () { + const authenticated = yield* xaiAuthenticatedClient(yield* HttpClient.HttpClient) + const clientContext = yield* Layer.build( + OpenAiClient.layer({ apiUrl: options.apiUrl ?? XAI_API_URL }), + ).pipe(Effect.provideService(HttpClient.HttpClient, authenticated)) + const client = decorateXaiClient(Context.get(clientContext, OpenAiClient.OpenAiClient)) + return yield* OpenAiLanguageModel.make({ model: options.model ?? DEFAULT_XAI_MODEL_ID }).pipe( + Effect.provideService(OpenAiClient.OpenAiClient, client), + ) + }), + ).pipe(Layer.provide(authLayerFor(options))), + ).pipe(Effect.map((context) => Context.get(context, LanguageModel.LanguageModel))) /** Describe an xAI OAuth-backed model compatible with Fold sessions and switching. */ -export const xaiModel = (options: XaiModelOptions = {}): FoldModel => { +export const xaiModel = (options: XaiModelOptions = {}): FoldModel => { const level = options.reasoning ?? 'off' return customModel({ activeModel: { diff --git a/packages/fold-xai/test/Xai.vi.test.ts b/packages/fold-xai/test/Xai.vi.test.ts index 43874bc..b646249 100644 --- a/packages/fold-xai/test/Xai.vi.test.ts +++ b/packages/fold-xai/test/Xai.vi.test.ts @@ -7,13 +7,14 @@ import { Effect, Option, Schema } from 'effect' import { buildXaiAuthorizeUrl, DEFAULT_XAI_MODEL_ID, - makeXaiAuthStore, + layerXaiAuthStore, normalizeXaiChatCompletionUsage, XAI_FRONTIER_MODELS, XAI_BROWSER_REDIRECT_URI, XAI_CLIENT_ID, xaiModel, XaiFrontierModelId, + XaiAuthStore, XaiTokenData, } from '../src/index' @@ -31,13 +32,15 @@ describe('xAI OAuth', () => { it.effect('persists xAI tokens under its provider key and clears without losing peers', () => Effect.gen(function* () { const path = `${process.cwd()}/.tmp-xai-auth-${crypto.randomUUID()}.json` - const store = yield* makeXaiAuthStore({ path }) - const token = new XaiTokenData({ type: 'oauth', access: 'access', refresh: 'refresh', expires: 42 }) - yield* store.save(token) - const loaded = yield* store.load - expect(Option.getOrUndefined(loaded)?.access).toBe('access') - yield* store.clear - expect(Option.isNone(yield* store.load)).toBe(true) + yield* Effect.gen(function* () { + const store = yield* XaiAuthStore + const token = new XaiTokenData({ type: 'oauth', access: 'access', refresh: 'refresh', expires: 42 }) + yield* store.save(token) + const loaded = yield* store.load + expect(Option.getOrUndefined(loaded)?.access).toBe('access') + yield* store.clear + expect(Option.isNone(yield* store.load)).toBe(true) + }).pipe(Effect.provide(layerXaiAuthStore({ path }))) }).pipe( Effect.ensuring( Effect.promise(async () => { diff --git a/scripts/build/binaries.ts b/scripts/build/binaries.ts index 579a21e..3845706 100644 --- a/scripts/build/binaries.ts +++ b/scripts/build/binaries.ts @@ -2,8 +2,10 @@ import { existsSync, realpathSync } from 'node:fs' import { mkdir, rm } from 'node:fs/promises' import { join, relative } from 'node:path' +import { Schema } from 'effect' + import { createSolidTransformPlugin } from '../../packages/fold-cli/node_modules/@opentui/solid/scripts/solid-plugin' -import { json, root, targetName, targets } from '../release/manifest' +import { encodeJson, readJson, root, RootManifest, targetName, targets } from '../release/manifest' const args = new Set(process.argv.slice(2)) const versionArg = process.argv.find((_, index, all) => all[index - 1] === '--version') ?? '0.0.0' @@ -13,7 +15,7 @@ const selected = args.has('--host') (os === 'windows' ? 'win32' : os) === process.platform && cpu === process.arch && variant === '', ) : targets -const { workspaces } = await json<{ workspaces: { catalog: Record } }>(join(root, 'package.json')) +const { workspaces } = await readJson(join(root, 'package.json'), RootManifest) const catalog = workspaces.catalog if (!args.has('--skip-install')) { @@ -47,10 +49,11 @@ for (const target of selected) { const bunTarget: Bun.CompileTarget = `bun-${os === 'windows' ? 'windows' : os}-${cpu}${variant.includes('baseline') ? '-baseline' : ''}${variant.includes('musl') ? '-musl' : ''}` const bunfs = os === 'windows' ? 'B:/~BUN/root/' : '/$bunfs/root/' const define: Record = { - FOLD_VERSION: JSON.stringify(versionArg), - OTUI_TREE_SITTER_WORKER_PATH: JSON.stringify(bunfs + workerRelative), + FOLD_VERSION: encodeJson(Schema.String, versionArg), + OTUI_TREE_SITTER_WORKER_PATH: encodeJson(Schema.String, bunfs + workerRelative), } - if (os === 'linux') define['process.env.OPENTUI_LIBC'] = JSON.stringify(variant.includes('musl') ? 'musl' : 'glibc') + if (os === 'linux') + define['process.env.OPENTUI_LIBC'] = encodeJson(Schema.String, variant.includes('musl') ? 'musl' : 'glibc') const result = await Bun.build({ entrypoints: [join(root, 'packages/fold-cli/src/cli.ts'), parserWorker], plugins: [createSolidTransformPlugin()], diff --git a/scripts/build/packages.ts b/scripts/build/packages.ts index 2ab7c06..3d2bbaa 100644 --- a/scripts/build/packages.ts +++ b/scripts/build/packages.ts @@ -1,7 +1,30 @@ import { mkdir, rm } from 'node:fs/promises' import { join } from 'node:path' -import { libraries, root, json } from '../release/manifest' +import { Schema } from 'effect' + +import { encodeJson, jsonDocument, libraries, readJson, root, StringRecord } from '../release/manifest' + +const LibraryManifest = Schema.Struct({ + name: Schema.String, + exports: Schema.Record( + Schema.String, + Schema.NullOr( + Schema.Union([ + Schema.String, + Schema.Struct({ import: Schema.optionalKey(Schema.String), source: Schema.optionalKey(Schema.String) }), + ]), + ), + ), + bin: Schema.optionalKey(StringRecord), +}) + +const ReleaseTsconfig = Schema.Struct({ + extends: Schema.String, + compilerOptions: Schema.Record(Schema.String, Schema.Union([Schema.Boolean, Schema.String])), + include: Schema.Array(Schema.String), + exclude: Schema.Array(Schema.String), +}) const version = process.argv.find((_, index, args) => args[index - 1] === '--version') ?? '0.0.0' const providersOnly = process.argv.includes('--providers') @@ -13,11 +36,7 @@ const { default: solidTransformPlugin } = await import( for (const name of libraries) { if (providersOnly && !name.startsWith('effect-ai-')) continue const dir = join(root, 'packages', name) - const manifest = await json<{ - name: string - exports: Record - bin?: Record - }>(join(dir, 'package.json')) + const manifest = await readJson(join(dir, 'package.json'), LibraryManifest) const entries = new Set() for (const value of Object.values(manifest.exports)) { const entry = typeof value === 'string' ? value : (value?.source ?? value?.import) @@ -40,7 +59,7 @@ for (const name of libraries) { const effectAiProvider = name.startsWith('effect-ai-') if (!effectAiProvider) { const define: Record = {} - if (name === 'fold-cli') define.FOLD_VERSION = JSON.stringify(version) + if (name === 'fold-cli') define.FOLD_VERSION = encodeJson(Schema.String, version) const result = await Bun.build({ entrypoints: [...entries].map((entry) => join(dir, entry)), outdir, @@ -71,16 +90,12 @@ for (const name of libraries) { } await Bun.write( buildConfig, - `${JSON.stringify( - { - extends: './tsconfig.json', - compilerOptions, - include: ['src'], - exclude: ['test', 'examples', 'scripts'], - }, - null, - 2, - )}\n`, + jsonDocument(ReleaseTsconfig, { + extends: './tsconfig.json', + compilerOptions, + include: ['src'], + exclude: ['test', 'examples', 'scripts'], + }), ) const declaration = Bun.spawn(['bunx', 'tsc', '-p', buildConfig], { cwd: root, diff --git a/scripts/release/manifest.ts b/scripts/release/manifest.ts index 476e29a..f5c9330 100644 --- a/scripts/release/manifest.ts +++ b/scripts/release/manifest.ts @@ -1,5 +1,7 @@ import { join } from 'node:path' +import { Schema } from 'effect' + export const root = join(import.meta.dirname, '../..') export const stage = join(root, '.release') export const libraries = [ @@ -35,6 +37,17 @@ export const targets = [ export const targetName = ([os, cpu, variant]: (typeof targets)[number]) => `@humanlayer/fold-${os}-${cpu}${variant ? `-${variant}` : ''}` -export async function json>(path: string): Promise { - return Bun.file(path).json() -} +export const StringRecord = Schema.Record(Schema.String, Schema.String) + +export const RootManifest = Schema.Struct({ workspaces: Schema.Struct({ catalog: StringRecord }) }) + +/** Read a JSON file and decode it with `schema`. */ +export const readJson = async (path: string, schema: Schema.Codec): Promise => + Schema.decodeSync(Schema.fromJsonString(schema))(await Bun.file(path).text()) + +/** Encode `value` with `schema` as JSON text. */ +export const encodeJson = (schema: Schema.Codec, value: T, space?: number): string => + Schema.encodeSync(Schema.fromJsonString(schema, { space }))(value) + +/** Encode `value` with `schema` as a pretty-printed JSON document with a trailing newline. */ +export const jsonDocument = (schema: Schema.Codec, value: T): string => `${encodeJson(schema, value, 2)}\n` diff --git a/scripts/release/prepare.ts b/scripts/release/prepare.ts index 58bbf04..7e6bddf 100644 --- a/scripts/release/prepare.ts +++ b/scripts/release/prepare.ts @@ -2,42 +2,63 @@ import { chmod, cp, mkdir, rm } from 'node:fs/promises' import { join } from 'node:path' import { parseArgs } from 'node:util' -import { internal, json, libraries, root, stage, targetName, targets } from './manifest' +import { Schema } from 'effect' + +import { + internal, + jsonDocument, + libraries, + readJson, + root, + RootManifest, + stage, + StringRecord, + targetName, + targets, +} from './manifest' const version = parseArgs({ options: { version: { type: 'string' } } }).values.version if (!version?.match(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/)) throw new Error('A valid --version is required') -type DependencyMap = Record -type ExportValue = string | { import?: string; source?: string } | null -type PackageManifest = { - [key: string]: unknown - name?: string - version?: string - private?: boolean - publishConfig?: Record - dependencies?: DependencyMap - peerDependencies?: DependencyMap - optionalDependencies?: DependencyMap - exports: Record - bin?: Record -} -type NativePackageManifest = { - name: string - version: string - description: string - license: string - repository: typeof repository - preferUnplugged: boolean - os: Array - cpu: Array - libc?: Array - files: Array - publishConfig: { access: string } +const DependencyMap = Schema.Record(Schema.String, Schema.mutableKey(Schema.optional(Schema.String))) +const ExportValue = Schema.NullOr(Schema.Union([Schema.String, StringRecord])) +type ExportValue = typeof ExportValue.Type +const manifestFields = { + name: Schema.optionalKey(Schema.String), + version: Schema.mutableKey(Schema.optionalKey(Schema.String)), + private: Schema.mutableKey(Schema.optionalKey(Schema.Boolean)), + publishConfig: Schema.mutableKey(Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown))), + dependencies: Schema.optionalKey(DependencyMap), + peerDependencies: Schema.optionalKey(DependencyMap), + optionalDependencies: Schema.optionalKey(DependencyMap), + bin: Schema.mutableKey(Schema.optionalKey(StringRecord)), } +const otherManifestFields = [Schema.Record(Schema.String, Schema.mutableKey(Schema.optional(Schema.Unknown)))] as const +const PackageManifest = Schema.StructWithRest( + Schema.Struct({ ...manifestFields, exports: Schema.Record(Schema.String, Schema.mutableKey(ExportValue)) }), + otherManifestFields, +) +const PlatformManifest = Schema.StructWithRest(Schema.Struct(manifestFields), otherManifestFields) +type PackageManifest = typeof PackageManifest.Type +const Repository = Schema.Struct({ type: Schema.String, url: Schema.String }) +const NativePackageManifest = Schema.Struct({ + name: Schema.String, + version: Schema.String, + description: Schema.String, + license: Schema.String, + repository: Repository, + preferUnplugged: Schema.Boolean, + os: Schema.Array(Schema.String), + cpu: Schema.Array(Schema.String), + libc: Schema.mutableKey(Schema.optionalKey(Schema.Array(Schema.String))), + files: Schema.Array(Schema.String), + publishConfig: Schema.Struct({ access: Schema.String }), +}) +type NativePackageManifest = typeof NativePackageManifest.Type -const rootManifest = await json<{ workspaces: { catalog: Record } }>(join(root, 'package.json')) +const rootManifest = await readJson(join(root, 'package.json'), RootManifest) const catalog = rootManifest.workspaces.catalog -const repository = { type: 'git', url: 'git+https://github.com/humanlayer/fold.git' } +const repository: typeof Repository.Type = { type: 'git', url: 'git+https://github.com/humanlayer/fold.git' } await rm(stage, { recursive: true, force: true }) function dependencies(manifest: PackageManifest) { @@ -62,7 +83,7 @@ function dependencies(manifest: PackageManifest) { for (const packageDir of libraries) { const source = join(root, 'packages', packageDir) const dest = join(stage, 'packages', packageDir) - const manifest = structuredClone(await json(join(source, 'package.json'))) + const manifest = await readJson(join(source, 'package.json'), PackageManifest) manifest.version = version manifest.private = false manifest.publishConfig = { ...manifest.publishConfig, access: 'public' } @@ -74,7 +95,8 @@ for (const packageDir of libraries) { dependencies(manifest) const rewrite = (value: string) => value.replace(/^\.\/src\//, './dist/').replace(/\.(tsx?|jsx?)$/, '.js') const dts = (value: string) => rewrite(value).replace(/\.js$/, '.d.ts') - const sourcePath = (value: ExportValue) => (typeof value === 'string' ? value : (value?.source ?? value?.import)) + const sourcePath = (value: ExportValue | undefined) => + typeof value === 'string' ? value : (value?.source ?? value?.import) const isSourceModule = (value: string) => /^\.\/src\/.*\.(?:[cm]?[jt]sx?)$/.test(value) const mainSource = sourcePath(manifest.exports['.']) if (mainSource === undefined || !isSourceModule(mainSource)) @@ -102,7 +124,7 @@ for (const packageDir of libraries) { ]) if (await Bun.file(join(source, file)).exists()) await cp(join(source, file), join(dest, file)) if (!(await Bun.file(join(dest, 'LICENSE')).exists())) await cp(join(root, 'LICENSE'), join(dest, 'LICENSE')) - await Bun.write(join(dest, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) + await Bun.write(join(dest, 'package.json'), jsonDocument(PackageManifest, manifest)) } const optionalDependencies = Object.fromEntries(targets.map((target) => [targetName(target), version])) @@ -126,10 +148,10 @@ for (const target of targets) { publishConfig: { access: 'public' }, } if (variant.includes('musl')) manifest.libc = ['musl'] - await Bun.write(join(dest, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) + await Bun.write(join(dest, 'package.json'), jsonDocument(NativePackageManifest, manifest)) await cp(join(root, 'LICENSE'), join(dest, 'LICENSE')) } -const platform = await json(join(root, 'packages/fold/package.json')) +const platform = await readJson(join(root, 'packages/fold/package.json'), PlatformManifest) Object.assign(platform, { version, private: false, @@ -150,4 +172,4 @@ await Bun.write( "#!/usr/bin/env node\nthrow new Error('foldcode native binary was not installed')\n", ) await chmod(join(platformDest, 'bin/foldcode.exe'), 0o755) -await Bun.write(join(platformDest, 'package.json'), `${JSON.stringify(platform, null, 2)}\n`) +await Bun.write(join(platformDest, 'package.json'), jsonDocument(PlatformManifest, platform)) diff --git a/scripts/release/validate-manifest.ts b/scripts/release/validate-manifest.ts index 5b3f888..c065e2b 100644 --- a/scripts/release/validate-manifest.ts +++ b/scripts/release/validate-manifest.ts @@ -3,9 +3,46 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { json, libraries, root, stage, targetName, targets } from './manifest' +import { Schema } from 'effect' -type ExportValue = string | { types?: string; import?: string; default?: string } | null +import { encodeJson, libraries, readJson, root, stage, StringRecord, targetName, targets } from './manifest' + +const ExportValue = Schema.NullOr( + Schema.Union([ + Schema.String, + Schema.Struct({ + types: Schema.optionalKey(Schema.String), + import: Schema.optionalKey(Schema.String), + default: Schema.optionalKey(Schema.String), + }), + ]), +) +type ExportValue = typeof ExportValue.Type +const ExportMap = Schema.Record(Schema.String, ExportValue) + +const PublishedManifest = Schema.Struct({ + name: Schema.String, + version: Schema.String, + private: Schema.optionalKey(Schema.Boolean), + publishConfig: Schema.optionalKey(Schema.Struct({ access: Schema.optionalKey(Schema.String) })), + dependencies: Schema.optionalKey(StringRecord), + peerDependencies: Schema.optionalKey(StringRecord), + optionalDependencies: Schema.optionalKey(StringRecord), + exports: Schema.optionalKey(ExportMap), + bin: Schema.optionalKey(StringRecord), +}) + +const ProviderManifest = Schema.Struct({ peerDependencies: Schema.Struct({ effect: Schema.String }) }) + +const ConsumerManifest = Schema.Struct({ + name: Schema.String, + private: Schema.Boolean, + type: Schema.String, + packageManager: Schema.String, +}) + +const PackedPackage = Schema.Struct({ name: Schema.String, filename: Schema.String }) +const PackOutput = Schema.Union([Schema.Array(PackedPackage), Schema.Record(Schema.String, PackedPackage)]) const providerPackages = new Set([ '@humanlayer/effect-ai-openai', @@ -45,7 +82,7 @@ const expandGlob = async (directory: string, pattern: string) => { } const assertNodeImport = async (packageName: string, entrypoint: string) => { - const expression = `await import(${JSON.stringify(pathToFileURL(entrypoint).href)})` + const expression = `await import(${encodeJson(Schema.String, pathToFileURL(entrypoint).href)})` const process = Bun.spawn(['node', '--input-type=module', '--eval', expression], { stdout: 'inherit', stderr: 'inherit', @@ -66,24 +103,11 @@ const run = async (command: Array, cwd: string, captureOutput = false) = } type PackedArchive = { name: string; path: string } -type PackedPackage = { name: string; filename: string } - -const isPackedPackage = (value: unknown): value is PackedPackage => - typeof value === 'object' && - value !== null && - 'name' in value && - typeof value.name === 'string' && - 'filename' in value && - typeof value.filename === 'string' const pack = async (directory: string, outputDirectory: string): Promise => { const output = await run(['npm', 'pack', '--json', '--pack-destination', outputDirectory], directory, true) - const packed: unknown = JSON.parse(output) - const artifact = Array.isArray(packed) - ? packed.find(isPackedPackage) - : typeof packed === 'object' && packed !== null - ? Object.values(packed).find(isPackedPackage) - : undefined + const packed = Schema.decodeSync(Schema.fromJsonString(PackOutput))(output) + const [artifact] = Array.isArray(packed) ? packed : Object.values(packed) if (artifact === undefined) throw new Error(`npm pack did not produce an archive for ${directory}`) return { name: artifact.name, path: join(outputDirectory, artifact.filename) } } @@ -102,12 +126,12 @@ const validateExternalProviderConsumer = async ( type: 'module', packageManager: 'pnpm@11.25.0', } - await Bun.write(join(directory, 'package.json'), `${JSON.stringify(manifest)}\n`) + await Bun.write(join(directory, 'package.json'), `${encodeJson(ConsumerManifest, manifest)}\n`) if (manager === 'pnpm') await Bun.write( join(directory, 'pnpm-workspace.yaml'), `overrides:\n${Object.entries(overrides) - .map(([name, range]) => ` ${JSON.stringify(name)}: ${JSON.stringify(range)}`) + .map(([name, range]) => ` ${encodeJson(Schema.String, name)}: ${encodeJson(Schema.String, range)}`) .join('\n')}\n`, ) await Bun.write( @@ -143,9 +167,7 @@ const validateExternalProviderConsumers = async () => { const archives = await Promise.all( externalConsumerPackages.map((name) => pack(join(stage, 'packages', name), archivesDirectory)), ) - const manifest = await json<{ peerDependencies: { effect: string } }>( - join(stage, 'packages/effect-ai-openai/package.json'), - ) + const manifest = await readJson(join(stage, 'packages/effect-ai-openai/package.json'), ProviderManifest) await validateExternalProviderConsumer('npm', archives, manifest.peerDependencies.effect) await validateExternalProviderConsumer('pnpm', archives, manifest.peerDependencies.effect) } finally { @@ -219,17 +241,7 @@ const manifests = [ join(stage, 'packages/fold/package.json'), ] for (const path of manifests) { - const manifest = await json<{ - name: string - version: string - private?: boolean - publishConfig?: { access?: string } - dependencies?: Record - peerDependencies?: Record - optionalDependencies?: Record - exports?: Record - bin?: Record - }>(path) + const manifest = await readJson(path, PublishedManifest) if (expectedVersion !== undefined && manifest.version !== expectedVersion) throw new Error(`${manifest.name} is ${manifest.version}, expected ${expectedVersion}`) if (manifest.private) throw new Error(`${manifest.name} is private`) @@ -262,7 +274,7 @@ for (const path of manifests) { ) throw new Error(`${manifest.name} must not publish ${provider} as a peer or optional dependency`) } - if (manifest.exports && JSON.stringify(manifest.exports).includes('/src/')) + if (manifest.exports && encodeJson(ExportMap, manifest.exports).includes('/src/')) throw new Error(`${manifest.name} exposes source files`) if (providerPackages.has(manifest.name)) { if (manifest.exports === undefined) throw new Error(`${manifest.name} is missing an export map`) diff --git a/tools/oxlint/README.md b/tools/oxlint/README.md index f9f461f..f99e6fd 100644 --- a/tools/oxlint/README.md +++ b/tools/oxlint/README.md @@ -1,9 +1,41 @@ -# Vendored OXLint rules +# Vendored lint tooling -This directory contains the selected rules that Fold owns and maintains: +Fold owns and maintains the tooling under `tools/`. It is formatted with the repository's oxfmt settings but is +excluded from lint. -- `anti-slop` rules are adapted from . -- `automation` rules are adapted from . +| Path | Source | Run by | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | +| `tools/oxlint/anti-slop` | [K-Mistele/anti-slop](https://github.com/K-Mistele/anti-slop) `src/` at `3abfdfb` (fork of dmmulroy/anti-slop), plus the local `no-conditional-empty-{object,array}-spread` rules and `Schema.tag`/`Schema.tagDefaultOmit` fields allowed by `no-manual-tagged-construction` | `bun run lint` | +| `tools/oxlint/automation` | [typeonce-dev/ai-automation](https://github.com/typeonce-dev/ai-automation/tree/0bca096fe6fe9878cd15303a623dd2cd85915ddd/rules/oxlint/src) via humanlayer/channels `e499a74` | `bun run lint` | +| `tools/oxlint/import-extensions.mjs` | humanlayer/channels `e499a74` | `bun run lint` | +| `tools/typed` | typeonce-dev/ai-automation `0bca096` via humanlayer/channels `e499a74` | `bun run lint` | -Only rules enabled in `.oxlintrc.jsonc` are vendored. Keep `@oxlint/plugins` aligned with the repository's -`oxlint` version when upgrading either package. +The `automation` rules that Fold used before this import (`no-ambient-nondeterminism`, `no-disable-validation`, +`no-manual-tag-comparison`, `no-manual-tagged-construction`, `no-service-option`, +`no-shadowed-standard-array-static`, `no-silent-error-swallow`, `prefer-effect-match`, +`prefer-option-from-nullable`, `prefer-tagged-error-handling`) are Fold adaptations; the rest are upstream +source. `no-manual-tag-comparison`, `no-manual-tagged-construction`, and `prefer-effect-match` are off: the stricter +`anti-slop-effect` rules of the same names replace them. `tools/typed/src/engine.ts` sets `followSymbolicLinks: false` so workspace symlink cycles in +`node_modules` do not break source exclusion. + +Keep `oxlint`, `@oxlint/plugins`, `oxlint-tsgolint`, and `@effect/tsgo` on versions `@effect/tsgo` lists as +supported; `prepare` patches oxlint with the Effect rules used by the `recommended` preset. + +## Rules not yet enabled + +These rules are vendored but off because Fold code still violates them. Counts exclude TUI code, which keeps +its exemption in `.oxlintrc.jsonc`. + +| Rule | Production | Tests | +| -------------------------------------------- | ---------- | ----- | +| `automation/private-function-prefix` | 406 | 161 | +| `automation/no-multiple-function-params` | 283 | 59 | +| `automation/no-single-use-private-functions` | 292 | 41 | +| `automation/no-optional-function-parameters` | 104 | 22 | +| `automation/no-effect-asvoid` | 15 | 1 | + +`anti-slop/no-comments`, `automation/no-comments`, and `automation/no-reexport-only-modules` (it only flags package +entrypoints) are intentionally off. Tests are exempt from `automation/no-global-json`, the type-assertion rules, +`no-try-catch`, `no-typeof-object`, `no-runtime-typeof`, `no-unknown-parameters`, and `no-unsafe-dictionary-type`. +`scripts/` and `packages/fold/postinstall.mjs` are not linted. +The React, XState, Tailwind, and API-layer `automation` rules do not apply to Fold. diff --git a/tools/oxlint/anti-slop/effect/index.ts b/tools/oxlint/anti-slop/effect/index.ts new file mode 100644 index 0000000..baa9e84 --- /dev/null +++ b/tools/oxlint/anti-slop/effect/index.ts @@ -0,0 +1,21 @@ +import { eslintCompatPlugin } from '@oxlint/plugins' + +import { noManualEffectErrorTagRule } from './rules/no-manual-effect-error-tag.ts' +import { noManualTagComparisonRule } from './rules/no-manual-tag-comparison.ts' +import { noManualTaggedConstructionRule } from './rules/no-manual-tagged-construction.ts' +import { noServiceConstructorImportsRule } from './rules/no-service-constructor-imports.ts' +import { preferEffectMatchRule } from './rules/prefer-effect-match.ts' + +/** Opt-in Oxlint rules for Effect service and Layer architecture. */ +const antiSlopEffectPlugin = eslintCompatPlugin({ + meta: { name: 'anti-slop-effect' }, + rules: { + 'no-manual-effect-error-tag': noManualEffectErrorTagRule, + 'no-manual-tag-comparison': noManualTagComparisonRule, + 'no-manual-tagged-construction': noManualTaggedConstructionRule, + 'no-service-constructor-imports': noServiceConstructorImportsRule, + 'prefer-effect-match': preferEffectMatchRule, + }, +}) + +export default antiSlopEffectPlugin diff --git a/tools/oxlint/anti-slop/effect/rules/no-manual-effect-error-tag.ts b/tools/oxlint/anti-slop/effect/rules/no-manual-effect-error-tag.ts new file mode 100644 index 0000000..48285ac --- /dev/null +++ b/tools/oxlint/anti-slop/effect/rules/no-manual-effect-error-tag.ts @@ -0,0 +1,44 @@ +import { defineRule } from '@oxlint/plugins' + +import { + isInsideBroadEffectHandler, + isReasonTagMember, + isTagMember, + tagMemberFromComparison, +} from '../shared/tagged-values.ts' + +export const noManualEffectErrorTagRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Use Effect tagged error handlers instead of manually branching on `_tag` in a catch handler.', + }, + messages: { + tag: 'Use Effect.catchTag or Effect.catchTags instead of manually discriminating a tagged error in a broad Effect catch handler.', + reason: 'Use Effect.catchReason or Effect.catchReasons instead of manually discriminating a tagged `reason` in a broad Effect catch handler.', + }, + }, + createOnce(context) { + return { + BinaryExpression(node) { + const tagMember = tagMemberFromComparison(node) + if (tagMember === undefined || !isInsideBroadEffectHandler(node)) { + return + } + context.report({ + node, + messageId: isReasonTagMember(tagMember) ? 'reason' : 'tag', + }) + }, + SwitchStatement(node) { + if (!isTagMember(node.discriminant) || !isInsideBroadEffectHandler(node)) { + return + } + context.report({ + node, + messageId: isReasonTagMember(node.discriminant) ? 'reason' : 'tag', + }) + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/effect/rules/no-manual-tag-comparison.ts b/tools/oxlint/anti-slop/effect/rules/no-manual-tag-comparison.ts new file mode 100644 index 0000000..66bff8c --- /dev/null +++ b/tools/oxlint/anti-slop/effect/rules/no-manual-tag-comparison.ts @@ -0,0 +1,34 @@ +import { defineRule } from '@oxlint/plugins' + +import { isInsideBroadEffectHandler, isTagMember, tagMemberFromComparison } from '../shared/tagged-values.ts' + +export const noManualTagComparisonRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Use Effect Match or Predicate helpers instead of manually branching on `_tag`.', + }, + messages: { + manualComparison: + 'Use Match.tag/Match.tags for tagged-value branching, or Predicate.isTagged for a simple reusable predicate.', + manualSwitch: + 'Use Match.value(value).pipe(Match.tag/Match.tags/Match.tagsExhaustive) or the tagged enum `$match` helper instead of switching on `_tag`.', + }, + }, + createOnce(context) { + return { + BinaryExpression(node) { + if (tagMemberFromComparison(node) === undefined || isInsideBroadEffectHandler(node)) { + return + } + context.report({ node, messageId: 'manualComparison' }) + }, + SwitchStatement(node) { + if (!isTagMember(node.discriminant) || isInsideBroadEffectHandler(node)) { + return + } + context.report({ node, messageId: 'manualSwitch' }) + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/effect/rules/no-manual-tagged-construction.ts b/tools/oxlint/anti-slop/effect/rules/no-manual-tagged-construction.ts new file mode 100644 index 0000000..d6cdd4b --- /dev/null +++ b/tools/oxlint/anti-slop/effect/rules/no-manual-tagged-construction.ts @@ -0,0 +1,62 @@ +import { defineRule, type ESTree } from '@oxlint/plugins' + +import { propertyName } from '../shared/tagged-values.ts' + +const isTagProperty = ( + property: ESTree.ObjectProperty | ESTree.PropertyDefinition | ESTree.TSPropertySignature, +): boolean => + (property.key.type === 'Identifier' && property.key.name === '_tag') || + (property.key.type === 'Literal' && property.key.value === '_tag') + +const schemaTagConstructors = new Set(['tag', 'tagDefaultOmit']) + +/** `_tag: Schema.tag("A")` and `_tag: Schema.tagDefaultOmit("A")` declare a schema field; they build no value. */ +const isSchemaTagField = (property: ESTree.ObjectProperty): boolean => + property.value.type === 'CallExpression' && + property.value.callee.type === 'MemberExpression' && + !property.value.callee.computed && + property.value.callee.object.type === 'Identifier' && + property.value.callee.object.name === 'Schema' && + property.value.callee.property.type === 'Identifier' && + schemaTagConstructors.has(property.value.callee.property.name) + +export const noManualTaggedConstructionRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Construct tagged values with their existing Effect constructor instead of writing `_tag` manually.', + }, + messages: { + manualConstruction: + 'Use the existing Schema tagged `.make` or tagged class/error constructor instead of writing a literal `_tag` object.', + manualDeclaration: + 'Declare tagged values with Schema.TaggedStruct, Schema.TaggedClass, Schema.TaggedError, or Schema.TaggedUnion instead of writing a literal `_tag` property.', + }, + }, + createOnce(context) { + return { + ObjectExpression(node) { + const tag = node.properties.find( + (property) => + property.type === 'Property' && + propertyName(property) === '_tag' && + !isSchemaTagField(property), + ) + if (tag !== undefined) { + context.report({ node: tag, messageId: 'manualConstruction' }) + } + }, + PropertyDefinition(node) { + if (isTagProperty(node)) { + context.report({ node, messageId: 'manualDeclaration' }) + } + }, + TSPropertySignature(node) { + if (isTagProperty(node)) { + context.report({ node, messageId: 'manualDeclaration' }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts b/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts new file mode 100644 index 0000000..6534bea --- /dev/null +++ b/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts @@ -0,0 +1,50 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +const SERVICE_CONSTRUCTOR_NAME = /^make[A-Z]/u +const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/u + +function isProjectLocalImport(source: string): boolean { + return source.startsWith('./') || source.startsWith('../') +} + +function getImportedName(specifier: ESTree.ImportSpecifier): string { + if (specifier.imported.type === 'Identifier') return specifier.imported.name + return specifier.imported.value +} + +/** Keep dependency-bearing Effect service constructors local to their owning capability modules. */ +export const noServiceConstructorImportsRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Disallow project-local make imports outside test and spec files.', + }, + messages: { + serviceConstructorImport: + 'Do not import Effect service constructor "{{name}}" into runtime code. Import the owning Layer, yield the contextual service, and allow its requirements to propagate to the composition root.', + }, + }, + create(context) { + const isTestFile = TEST_FILE.test(context.filename.replaceAll('\\', '/')) + + return { + ImportDeclaration(node) { + if (isTestFile || !isProjectLocalImport(node.source.value)) return + + for (const specifier of node.specifiers) { + if (specifier.type !== 'ImportSpecifier') continue + + const importedName = getImportedName(specifier) + if (!SERVICE_CONSTRUCTOR_NAME.test(importedName)) continue + + context.report({ + node: specifier, + messageId: 'serviceConstructorImport', + data: { name: importedName }, + }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/effect/rules/prefer-effect-match.ts b/tools/oxlint/anti-slop/effect/rules/prefer-effect-match.ts new file mode 100644 index 0000000..7c6977a --- /dev/null +++ b/tools/oxlint/anti-slop/effect/rules/prefer-effect-match.ts @@ -0,0 +1,48 @@ +import { defineRule, type ESTree } from '@oxlint/plugins' + +const equalityOperators = new Set(['==', '===', '!=', '!==']) + +export const preferEffectMatchRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Use Match from Effect for chained literal ternaries over the same value.', + }, + messages: { + preferMatch: 'Use Match from Effect instead of a chained literal ternary.', + }, + }, + createOnce(context) { + const isLiteral = (node: ESTree.Node): boolean => + node.type === 'Literal' || (node.type === 'TemplateLiteral' && node.expressions.length === 0) + + const comparedValue = (node: ESTree.Expression): string | undefined => { + if (node.type !== 'BinaryExpression' || !equalityOperators.has(node.operator)) { + return undefined + } + if (isLiteral(node.left)) return context.sourceCode.getText(node.right) + if (isLiteral(node.right)) return context.sourceCode.getText(node.left) + return undefined + } + + return { + ConditionalExpression(node) { + if (node.parent?.type === 'ConditionalExpression') return + const value = comparedValue(node.test) + if (value === undefined) return + + let alternate = node.alternate + let literalChecks = 1 + while (alternate.type === 'ConditionalExpression') { + if (comparedValue(alternate.test) !== value) return + literalChecks += 1 + alternate = alternate.alternate + } + + if (literalChecks > 1) { + context.report({ node, messageId: 'preferMatch' }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/effect/shared/tagged-values.ts b/tools/oxlint/anti-slop/effect/shared/tagged-values.ts new file mode 100644 index 0000000..4909bda --- /dev/null +++ b/tools/oxlint/anti-slop/effect/shared/tagged-values.ts @@ -0,0 +1,54 @@ +import type { ESTree } from '@oxlint/plugins' + +const equalityOperators = new Set(['==', '===', '!=', '!==']) +const broadEffectCatchMethods = new Set(['catch', 'catchAll', 'catchIf']) + +export const isStringLiteral = (node: ESTree.Node | null | undefined): node is ESTree.StringLiteral => + node?.type === 'Literal' && typeof node.value === 'string' + +export const isTagMember = (node: ESTree.Node | null | undefined): node is ESTree.MemberExpression => + node?.type === 'MemberExpression' && + ((!node.computed && node.property.type === 'Identifier' && node.property.name === '_tag') || + (node.computed && isStringLiteral(node.property) && node.property.value === '_tag')) + +export const tagMemberFromComparison = (node: ESTree.BinaryExpression): ESTree.MemberExpression | undefined => { + if (!equalityOperators.has(node.operator)) return undefined + if (isTagMember(node.left) && isStringLiteral(node.right)) return node.left + if (isTagMember(node.right) && isStringLiteral(node.left)) return node.right + return undefined +} + +const isBroadEffectCatchCall = (node: ESTree.Node | null | undefined): node is ESTree.CallExpression => + node?.type === 'CallExpression' && + node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'Effect' && + !node.callee.computed && + node.callee.property.type === 'Identifier' && + broadEffectCatchMethods.has(node.callee.property.name) + +export const isInsideBroadEffectHandler = (node: ESTree.Node): boolean => { + let current: ESTree.Node | null | undefined = node.parent + while (current !== null && current !== undefined) { + if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') { + return isBroadEffectCatchCall(current.parent) && current.parent.arguments.includes(current) + } + current = current.parent + } + return false +} + +export const isReasonTagMember = (node: ESTree.MemberExpression): boolean => + node.object.type === 'MemberExpression' && + ((!node.object.computed && node.object.property.type === 'Identifier' && node.object.property.name === 'reason') || + (node.object.computed && isStringLiteral(node.object.property) && node.object.property.value === 'reason')) + +export const propertyName = (property: ESTree.ObjectProperty): string | undefined => { + if (!property.computed && property.key.type === 'Identifier') { + return property.key.name + } + if (property.key.type === 'Literal' && typeof property.key.value === 'string') { + return property.key.value + } + return undefined +} diff --git a/tools/oxlint/anti-slop/index.ts b/tools/oxlint/anti-slop/index.ts index 3917980..2796f07 100644 --- a/tools/oxlint/anti-slop/index.ts +++ b/tools/oxlint/anti-slop/index.ts @@ -1,16 +1,51 @@ import { eslintCompatPlugin } from '@oxlint/plugins' +import { maxTernaryDepthRule } from './rules/max-ternary-depth.ts' +import { noChainedTypeAssertionsRule } from './rules/no-chained-type-assertions.ts' +import { noCommentsRule } from './rules/no-comments.ts' +import { noConditionalEmptyArraySpreadRule } from './rules/no-conditional-empty-array-spread.ts' import { noConditionalEmptyObjectSpreadRule } from './rules/no-conditional-empty-object-spread.ts' +import { noConditionalSpreadRule } from './rules/no-conditional-spread.ts' +import { noKnownValueWideningRule } from './rules/no-known-value-widening.ts' 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 { noReflectGetRule } from './rules/no-reflect-get.ts' +import { noReprovideAmbientServiceRule } from './rules/no-reprovide-ambient-service.ts' +import { noRuntimeTypeofRule } from './rules/no-runtime-typeof.ts' +import { noForbiddenTermInSymbolNamesRule } from './rules/no-shape-in-symbol-names.ts' +import { noUnknownParametersRule } from './rules/no-unknown-parameters.ts' +import { noUnknownReturnsRule } from './rules/no-unknown-returns.ts' +import { noUnknownTypeAliasesRule } from './rules/no-unknown-type-aliases.ts' +import { noUnsafeDictionaryTypeRule } from './rules/no-unsafe-dictionary-type.ts' +import { noWidenThenAssertRule } from './rules/no-widen-then-assert.ts' +import { requireSafetyCommentForTypeAssertionRule } from './rules/require-safety-comment-for-type-assertion.ts' -export default eslintCompatPlugin({ +/** Generic Oxlint rules that reject low-evidence and low-signal implementation patterns. */ +const antiSlopPlugin = eslintCompatPlugin({ meta: { name: 'anti-slop' }, rules: { + 'max-ternary-depth': maxTernaryDepthRule, + 'no-chained-type-assertions': noChainedTypeAssertionsRule, + 'no-comments': noCommentsRule, + 'no-conditional-empty-array-spread': noConditionalEmptyArraySpreadRule, 'no-conditional-empty-object-spread': noConditionalEmptyObjectSpreadRule, + 'no-conditional-spread': noConditionalSpreadRule, + 'no-known-value-widening': noKnownValueWideningRule, 'no-module-mocking': noModuleMockingRule, 'no-object-parameters': noObjectParametersRule, 'no-reflect-apply': noReflectApplyRule, + 'no-reflect-get': noReflectGetRule, + 'no-reprovide-ambient-service': noReprovideAmbientServiceRule, + 'no-runtime-typeof': noRuntimeTypeofRule, + 'no-unsafe-dictionary-type': noUnsafeDictionaryTypeRule, + 'no-shape-in-symbol-names': noForbiddenTermInSymbolNamesRule, + 'no-unknown-parameters': noUnknownParametersRule, + 'no-unknown-returns': noUnknownReturnsRule, + 'no-unknown-type-aliases': noUnknownTypeAliasesRule, + 'no-widen-then-assert': noWidenThenAssertRule, + 'require-safety-comment-for-type-assertion': requireSafetyCommentForTypeAssertionRule, }, }) + +export default antiSlopPlugin diff --git a/tools/oxlint/anti-slop/rules/max-ternary-depth.ts b/tools/oxlint/anti-slop/rules/max-ternary-depth.ts new file mode 100644 index 0000000..06029bb --- /dev/null +++ b/tools/oxlint/anti-slop/rules/max-ternary-depth.ts @@ -0,0 +1,80 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +function unwrapExpression(node: ESTree.Expression): ESTree.Expression { + let current = node + while ( + current.type === 'ParenthesizedExpression' || + current.type === 'TSAsExpression' || + current.type === 'TSSatisfiesExpression' || + current.type === 'TSTypeAssertion' || + current.type === 'TSNonNullExpression' + ) { + current = current.expression + } + return current +} + +function ternaryDepth(node: ESTree.Expression): number { + const expression = unwrapExpression(node) + if (expression.type !== 'ConditionalExpression') return 0 + return ( + 1 + + Math.max(ternaryDepth(expression.test), ternaryDepth(expression.consequent), ternaryDepth(expression.alternate)) + ) +} + +function isNestedTernary(node: ESTree.ConditionalExpression): boolean { + let parent = node.parent + while ( + parent.type === 'ParenthesizedExpression' || + parent.type === 'TSAsExpression' || + parent.type === 'TSSatisfiesExpression' || + parent.type === 'TSTypeAssertion' || + parent.type === 'TSNonNullExpression' + ) { + parent = parent.parent + } + return parent.type === 'ConditionalExpression' +} + +/** Limit ternary chains while preserving concise two-way selections. */ +export const maxTernaryDepthRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Limit ternary expression chains to a configurable depth.', + }, + messages: { + exceeded: + 'This ternary has a depth of {{depth}}; the configured maximum is {{maxDepth}}. Use a lookup table, switch, or named decision function.', + }, + schema: [ + { + type: 'object', + properties: { maxDepth: { type: 'integer', minimum: 1 } }, + additionalProperties: false, + }, + ], + defaultOptions: [{ maxDepth: 2 }], + }, + createOnce(context) { + return { + ConditionalExpression(node) { + if (isNestedTernary(node)) return + const option = context.options?.[0] + const maxDepth = + typeof option === 'object' && + option !== null && + !Array.isArray(option) && + typeof option.maxDepth === 'number' + ? option.maxDepth + : 2 + const depth = ternaryDepth(node) + if (depth > maxDepth) { + context.report({ node, messageId: 'exceeded', data: { depth, maxDepth } }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts new file mode 100644 index 0000000..e97ad90 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts @@ -0,0 +1,76 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +type TypeAssertionExpression = ESTree.TSAsExpression | ESTree.TSTypeAssertion + +function isTypeAssertionExpression(node: ESTree.Node): node is TypeAssertionExpression { + return node.type === 'TSAsExpression' || node.type === 'TSTypeAssertion' +} + +function unwrapParenthesizedExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression + while (current.type === 'ParenthesizedExpression') { + current = current.expression + } + return current +} + +function isConstAssertion(node: TypeAssertionExpression): boolean { + const { typeAnnotation } = node + return ( + typeAnnotation.type === 'TSTypeReference' && + typeAnnotation.typeName.type === 'Identifier' && + typeAnnotation.typeName.name === 'const' + ) +} + +function isOutermostAssertionInChain(node: TypeAssertionExpression): boolean { + let current: ESTree.Expression = node + let parent = node.parent + + while (parent.type === 'ParenthesizedExpression' && parent.expression === current) { + current = parent + parent = parent.parent + } + + return !isTypeAssertionExpression(parent) || parent.expression !== current +} + +function isForbiddenAssertionChain(node: TypeAssertionExpression): boolean { + let assertionCount = 0 + let hasNonConstAssertion = false + let current: ESTree.Expression = node + + while (isTypeAssertionExpression(current)) { + assertionCount += 1 + hasNonConstAssertion ||= !isConstAssertion(current) + current = unwrapParenthesizedExpression(current.expression) + } + + return assertionCount > 1 && hasNonConstAssertion +} + +/** Disallow nested TypeScript type assertions, while permitting chains made only of const assertions. */ +export const noChainedTypeAssertionsRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Disallow chained TypeScript as and angle-bracket assertions, including parenthesized chains.', + }, + messages: { + chained: + 'This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.', + }, + }, + createOnce(context) { + const checkTypeAssertion = (node: TypeAssertionExpression) => { + if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return + context.report({ node, messageId: 'chained' }) + } + + return { + TSAsExpression: checkTypeAssertion, + TSTypeAssertion: checkTypeAssertion, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-comments.ts b/tools/oxlint/anti-slop/rules/no-comments.ts new file mode 100644 index 0000000..2b04aa7 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-comments.ts @@ -0,0 +1,25 @@ +import { defineRule } from '@oxlint/plugins' + +/** Reject implementation comments except SAFETY justifications required by assertion policy. */ +export const noCommentsRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Reject implementation comments; code should communicate intent directly. SAFETY justifications remain allowed.', + }, + messages: { + comment: 'Remove this comment and make the code communicate its intent directly.', + }, + }, + createOnce(context) { + return { + Program() { + for (const comment of context.sourceCode.getAllComments()) { + if (/\bSAFETY\s*:/u.test(comment.value)) continue + context.report({ node: comment, messageId: 'comment' }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-conditional-empty-array-spread.ts b/tools/oxlint/anti-slop/rules/no-conditional-empty-array-spread.ts new file mode 100644 index 0000000..cd2e187 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-conditional-empty-array-spread.ts @@ -0,0 +1,44 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +function unwrapParentheses(node: ESTree.Expression): ESTree.Expression { + let current = node + while (current.type === 'ParenthesizedExpression') { + current = current.expression + } + return current +} + +function isEmptyArrayExpression(node: ESTree.Expression): boolean { + return node.type === 'ArrayExpression' && node.elements.length === 0 +} + +function isConditionalEmptyArraySpread(node: ESTree.Expression): boolean { + const conditional = unwrapParentheses(node) + return ( + conditional.type === 'ConditionalExpression' && + (isEmptyArrayExpression(conditional.consequent) || isEmptyArrayExpression(conditional.alternate)) + ) +} + +export const noConditionalEmptyArraySpreadRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Disallow array spreads that conditionally spread an empty array to omit items.', + }, + messages: { + avoid: 'This conditional spread hides item omission behind an empty array. Build the array in separate statements and add the item only when present.', + }, + }, + createOnce(context) { + return { + SpreadElement(node) { + if (node.parent.type !== 'ArrayExpression') return + if (isConditionalEmptyArraySpread(node.argument)) { + context.report({ node, messageId: 'avoid' }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-conditional-spread.ts b/tools/oxlint/anti-slop/rules/no-conditional-spread.ts new file mode 100644 index 0000000..9e4a2d5 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-conditional-spread.ts @@ -0,0 +1,42 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +function unwrapParentheses(node: ESTree.Expression): ESTree.Expression { + let current = node + while (current.type === 'ParenthesizedExpression') { + current = current.expression + } + return current +} + +function isConditionalSpread(node: ESTree.Expression): boolean { + const expression = unwrapParentheses(node) + return expression.type === 'ConditionalExpression' || expression.type === 'LogicalExpression' +} + +/** Ban spreading ternary and logical expressions in every spread position. */ +export const noConditionalSpreadRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Disallow spreading ternary and logical expressions. Build the value explicitly instead.', + }, + messages: { + avoid: 'Conditional spreads obscure which values or properties are present. Build the value in separate statements instead.', + }, + }, + createOnce(context) { + return { + SpreadElement(node) { + if (isConditionalSpread(node.argument)) { + context.report({ node, messageId: 'avoid' }) + } + }, + JSXSpreadAttribute(node) { + if (isConditionalSpread(node.argument)) { + context.report({ node, messageId: 'avoid' }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-known-value-widening.ts b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts new file mode 100644 index 0000000..58d441e --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts @@ -0,0 +1,217 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree, Scope, SourceCode, Variable } from '@oxlint/plugins' + +import { + classifyWideningTarget, + createTypeEnvironment, + isKnownEvidenceExpression, + type TypeEnvironment, + type WideningTarget, +} from '../shared/dictionary-types.ts' + +type FunctionExpression = ESTree.ArrowFunctionExpression | ESTree.Function + +function unwrapExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression + while ( + current.type === 'ParenthesizedExpression' || + current.type === 'TSAsExpression' || + current.type === 'TSSatisfiesExpression' || + current.type === 'TSTypeAssertion' || + current.type === 'TSNonNullExpression' + ) { + current = current.expression + } + return current +} + +function resolveVariable(sourceCode: SourceCode, identifier: ESTree.IdentifierReference): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier) + while (scope !== null) { + const variable = scope.set.get(identifier.name) + if (variable !== undefined) return variable + scope = scope.upper + } + return null +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + if (variable.defs.length !== 1) return null + const [definition] = variable.defs + return definition?.type === 'Variable' && definition.node.type === 'VariableDeclarator' ? definition.node : null +} + +function isStableConstVariable(variable: Variable, declarator: ESTree.VariableDeclarator): boolean { + return ( + declarator.parent.type === 'VariableDeclaration' && + declarator.parent.kind === 'const' && + variable.references.every((reference) => reference.init || !reference.isWrite()) + ) +} + +function hasKnownEvidence( + sourceCode: SourceCode, + expression: ESTree.Expression, + visitedVariables = new Set(), +): boolean { + if (isKnownEvidenceExpression(expression)) return true + const unwrapped = unwrapExpression(expression) + if (unwrapped.type !== 'Identifier') return false + const variable = resolveVariable(sourceCode, unwrapped) + if (variable === null || visitedVariables.has(variable)) return false + const declarator = variableDeclarator(variable) + if (declarator === null || declarator.init === null || !isStableConstVariable(variable, declarator)) { + return false + } + visitedVariables.add(variable) + return hasKnownEvidence(sourceCode, declarator.init, visitedVariables) +} + +function annotationTarget( + annotation: ESTree.TSTypeAnnotation | null | undefined, + environment: TypeEnvironment, +): WideningTarget | null { + return annotation === null || annotation === undefined + ? null + : classifyWideningTarget(annotation.typeAnnotation, environment) +} + +function enclosingFunction(node: ESTree.Node): FunctionExpression | null { + let current: ESTree.Node | null = node.parent + while (current !== null && current.type !== 'Program') { + if ( + current.type === 'ArrowFunctionExpression' || + current.type === 'FunctionDeclaration' || + current.type === 'FunctionExpression' + ) { + return current + } + current = current.parent + } + return null +} + +function sourceKeyName(sourceCode: SourceCode, key: ESTree.PropertyKey): string { + if (key.type === 'Identifier' || key.type === 'PrivateIdentifier') return key.name + if (key.type === 'Literal') return String(key.value) + return sourceCode.getText(key) +} + +function functionName(sourceCode: SourceCode, owner: FunctionExpression | null): string { + if (owner === null) return 'anonymous function' + if (owner.id !== null) return owner.id.name + const parent = owner.parent + if (parent.type === 'VariableDeclarator' && parent.id.type === 'Identifier') return parent.id.name + if (parent.type === 'MethodDefinition') return sourceKeyName(sourceCode, parent.key) + return 'anonymous function' +} + +function isEmptyObjectExpression(expression: ESTree.Expression): boolean { + const unwrapped = unwrapExpression(expression) + return unwrapped.type === 'ObjectExpression' && unwrapped.properties.length === 0 +} + +function isDictionaryAccumulatorTarget(destination: WideningTarget): boolean { + return destination.kind === 'open dictionary' || destination.kind === 'generic container' +} + +function hasParentAssertion(node: ESTree.Node): boolean { + return node.parent?.type === 'TSAsExpression' || node.parent?.type === 'TSTypeAssertion' +} + +/** Detect sound syntactic cases where a known value is explicitly widened and loses evidence. */ +export const noKnownValueWideningRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow syntactically established values from flowing into explicitly broad or anonymous target types that discard useful evidence.', + }, + messages: { + widening: + 'The explicit {{target}} type on {{subject}} discards known type evidence. Keep inference, validate with `satisfies`, or use a named owner contract.', + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null + + const reportFlow = (expression: ESTree.Expression, destination: WideningTarget | null, subject: string) => { + if (destination === null) return + if (isDictionaryAccumulatorTarget(destination) && isEmptyObjectExpression(expression)) { + return + } + if (!hasKnownEvidence(context.sourceCode, expression)) return + context.report({ + node: expression, + messageId: 'widening', + data: { subject, target: destination.kind }, + }) + } + + const targetFromAnnotation = (annotation: ESTree.TSTypeAnnotation | null | undefined) => + environment === null ? null : annotationTarget(annotation, environment) + + return { + Program(node) { + environment = createTypeEnvironment(node) + }, + VariableDeclarator(node) { + if (node.init === null || node.id.type !== 'Identifier') return + reportFlow(node.init, targetFromAnnotation(node.id.typeAnnotation), `binding \`${node.id.name}\``) + }, + PropertyDefinition(node) { + if (node.value === null) return + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ) + }, + AccessorProperty(node) { + if (node.value === null) return + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ) + }, + AssignmentExpression(node) { + if (node.operator !== '=' || node.left.type !== 'Identifier') return + const variable = resolveVariable(context.sourceCode, node.left) + if (variable === null) return + const declarator = variableDeclarator(variable) + if (declarator === null || declarator.id.type !== 'Identifier') return + reportFlow( + node.right, + targetFromAnnotation(declarator.id.typeAnnotation), + `binding \`${declarator.id.name}\``, + ) + }, + ReturnStatement(node) { + if (node.argument === null) return + const owner = enclosingFunction(node) + reportFlow( + node.argument, + targetFromAnnotation(owner?.returnType), + `return value of \`${functionName(context.sourceCode, owner)}\``, + ) + }, + ArrowFunctionExpression(node) { + if (node.body.type === 'BlockStatement') return + reportFlow( + node.body, + targetFromAnnotation(node.returnType), + `return value of \`${functionName(context.sourceCode, node)}\``, + ) + }, + TSAsExpression(node) { + if (environment === null || hasParentAssertion(node)) return + reportFlow(node.expression, classifyWideningTarget(node.typeAnnotation, environment), 'assertion') + }, + TSTypeAssertion(node) { + if (environment === null || hasParentAssertion(node)) return + reportFlow(node.expression, classifyWideningTarget(node.typeAnnotation, environment), 'assertion') + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-module-mocking.ts b/tools/oxlint/anti-slop/rules/no-module-mocking.ts index 1e2951f..c211955 100644 --- a/tools/oxlint/anti-slop/rules/no-module-mocking.ts +++ b/tools/oxlint/anti-slop/rules/no-module-mocking.ts @@ -56,6 +56,7 @@ function moduleMockCall(sourceCode: SourceCode, callee: ESTree.Expression): bool return method !== null && moduleMockMethods.has(method) } +/** Ban test framework module mocking in favor of real dependency seams. */ export const noModuleMockingRule = defineRule({ meta: { type: 'problem', diff --git a/tools/oxlint/anti-slop/rules/no-object-parameters.ts b/tools/oxlint/anti-slop/rules/no-object-parameters.ts index e774b4e..b2217cc 100644 --- a/tools/oxlint/anti-slop/rules/no-object-parameters.ts +++ b/tools/oxlint/anti-slop/rules/no-object-parameters.ts @@ -14,9 +14,15 @@ type ParameterOwner = | ESTree.TSMethodSignature function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { - if (parameter.type === 'TSParameterProperty') return parameterAnnotation(parameter.parameter) - if (parameter.type === 'RestElement') return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument) - if (parameter.type === 'AssignmentPattern') return parameter.typeAnnotation ?? parameter.left.typeAnnotation + if (parameter.type === 'TSParameterProperty') { + return parameterAnnotation(parameter.parameter) + } + if (parameter.type === 'RestElement') { + return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument) + } + if (parameter.type === 'AssignmentPattern') { + return parameter.typeAnnotation ?? parameter.left.typeAnnotation + } return parameter.typeAnnotation } @@ -26,11 +32,13 @@ function parameterName(parameter: Parameter, sourceCode: SourceCode): string { : sourceCode.getText(parameter).replace(/\s*:\s*object\s*$/u, '') } +/** Ban the broad object type on function inputs, including local aliases to object. */ export const noObjectParametersRule = defineRule({ meta: { type: 'problem', docs: { - description: 'Disallow object function parameters; inputs must use an owner-provided type.', + description: + 'Disallow object function parameters; inputs must use an owner-provided type and be parsed at their boundary.', }, messages: { objectParameter: diff --git a/tools/oxlint/anti-slop/rules/no-reflect-apply.ts b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts index 8bcba03..46ae36c 100644 --- a/tools/oxlint/anti-slop/rules/no-reflect-apply.ts +++ b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts @@ -2,6 +2,7 @@ import { defineRule } from '@oxlint/plugins' import { isGlobalReflectMethodCall } from '../shared/reflect-method.ts' +/** Ban Reflect.apply, which bypasses ordinary typed function calls. */ export const noReflectApplyRule = defineRule({ meta: { type: 'problem', diff --git a/tools/oxlint/anti-slop/rules/no-reflect-get.ts b/tools/oxlint/anti-slop/rules/no-reflect-get.ts new file mode 100644 index 0000000..ac357d3 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-get.ts @@ -0,0 +1,27 @@ +import { defineRule } from '@oxlint/plugins' + +import { isGlobalReflectMethodCall } from '../shared/reflect-method.ts' + +/** Ban Reflect.get, which bypasses ordinary property access and useful type evidence. */ +export const noReflectGetRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Disallow Reflect.get; use typed property access or parse dynamic input into a domain type.', + }, + messages: { + reflectGet: + 'Replace `Reflect.get` with typed property access. Parse dynamic input into a named domain type before reading it.', + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === 'Super' || node.callee.type === 'V8IntrinsicExpression') return + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, 'get')) { + context.report({ node, messageId: 'reflectGet' }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-reprovide-ambient-service.ts b/tools/oxlint/anti-slop/rules/no-reprovide-ambient-service.ts new file mode 100644 index 0000000..ee2a026 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reprovide-ambient-service.ts @@ -0,0 +1,123 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree, Scope, SourceCode, Variable } from '@oxlint/plugins' + +function resolveVariable(sourceCode: SourceCode, identifier: ESTree.IdentifierReference): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier) + while (scope !== null) { + const variable = scope.set.get(identifier.name) + if (variable !== undefined) return variable + scope = scope.upper + } + return null +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + if (variable.defs.length !== 1) return null + const [definition] = variable.defs + return definition?.type === 'Variable' && definition.node.type === 'VariableDeclarator' ? definition.node : null +} + +function unwrapExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression + while ( + current.type === 'ParenthesizedExpression' || + current.type === 'TSAsExpression' || + current.type === 'TSSatisfiesExpression' || + current.type === 'TSTypeAssertion' || + current.type === 'TSNonNullExpression' + ) { + current = current.expression + } + return current +} + +function importedName(specifier: ESTree.ImportSpecifier): string { + return specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value +} + +function isEffectProvideServiceCall(node: ESTree.CallExpression, effectNamespaces: ReadonlySet): boolean { + const callee = unwrapExpression(node.callee) + return ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.object.type === 'Identifier' && + effectNamespaces.has(callee.object.name) && + callee.property.type === 'Identifier' && + callee.property.name === 'provideService' + ) +} + +function directlyYieldedService(sourceCode: SourceCode, expression: ESTree.Expression): ESTree.Expression | null { + const value = unwrapExpression(expression) + if (value.type !== 'Identifier') return null + const variable = resolveVariable(sourceCode, value) + if (variable === null) return null + const declarator = variableDeclarator(variable) + if (declarator?.init === null || declarator?.init === undefined) return null + const initializer = unwrapExpression(declarator.init) + return initializer.type === 'YieldExpression' && initializer.delegate && initializer.argument !== null + ? unwrapExpression(initializer.argument) + : null +} + +/** Keep ambient Effect dependencies in R instead of capturing and re-providing them operation by operation. */ +export const noReprovideAmbientServiceRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow re-providing an Effect service that was yielded directly from the surrounding environment.', + }, + messages: { + reprovideAmbientService: + 'Do not capture "{{service}}" from the Effect environment and re-provide it with Effect.provideService. Leave "{{service}}" in the operation\'s R channel and provide its implementing Layer once at the application runtime composition root. If the runtime has an explicit service union, include "{{service}}" there; use Layer.provideMerge when the service must remain available after layer construction. A local provide is only appropriate for a genuinely dynamic implementation, such as a client configured from per-tenant credentials.', + }, + }, + createOnce(context) { + const effectNamespaces = new Set() + + return { + ImportDeclaration(node) { + if (node.source.value === 'effect') { + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportSpecifier' && importedName(specifier) === 'Effect') { + effectNamespaces.add(specifier.local.name) + } + } + return + } + + if (node.source.value !== 'effect/Effect') return + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportNamespaceSpecifier' || specifier.type === 'ImportDefaultSpecifier') { + effectNamespaces.add(specifier.local.name) + } + } + }, + CallExpression(node) { + if (!isEffectProvideServiceCall(node, effectNamespaces) || node.arguments.length < 2) return + const serviceArgument = node.arguments.at(-2) + const implementationArgument = node.arguments.at(-1) + if ( + serviceArgument === undefined || + implementationArgument === undefined || + serviceArgument.type === 'SpreadElement' || + implementationArgument.type === 'SpreadElement' + ) { + return + } + + const yieldedService = directlyYieldedService(context.sourceCode, implementationArgument) + if (yieldedService === null) return + const requestedService = unwrapExpression(serviceArgument) + if (context.sourceCode.getText(yieldedService) !== context.sourceCode.getText(requestedService)) return + + context.report({ + node, + messageId: 'reprovideAmbientService', + data: { service: context.sourceCode.getText(requestedService) }, + }) + }, + } + }, +}) 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..3fc6788 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts @@ -0,0 +1,63 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +type RuntimeFunction = ESTree.ArrowFunctionExpression | ESTree.Function + +function isRuntimeFunction(node: ESTree.Node): node is RuntimeFunction { + return ( + node.type === 'ArrowFunctionExpression' || + node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' + ) +} + +function 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 +} + +/** Disallow runtime typeof checks that narrow unparsed values instead of decoding them. */ +export const noRuntimeTypeofRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow runtime typeof checks; external values must be decoded into meaningful types at their I/O boundary.', + }, + messages: { + runtimeTypeof: + 'A `typeof` check narrows a representation without establishing its contract. Parse input at its I/O boundary, then branch on the domain value.', + }, + 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' }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts new file mode 100644 index 0000000..7895de0 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts @@ -0,0 +1,39 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +const FORBIDDEN_SYMBOL_NAME = 'shape' + +function containsForbiddenSymbolName(name: string): boolean { + return name.toLowerCase().includes(FORBIDDEN_SYMBOL_NAME) +} + +/** Ban the case-insensitive substring "shape" in every JavaScript and TypeScript symbol name. */ +export const noForbiddenTermInSymbolNamesRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow the case-insensitive substring "shape" in JavaScript, TypeScript, private, and JSX symbol names.', + }, + messages: { + forbiddenSymbolName: + 'Rename symbol "{{name}}" for its domain role; "shape" describes structure rather than ownership.', + }, + }, + createOnce(context) { + const reportForbiddenSymbolName = (node: ESTree.Node & { name: string }) => { + if (!containsForbiddenSymbolName(node.name)) return + context.report({ + node, + messageId: 'forbiddenSymbolName', + data: { name: node.name }, + }) + } + + return { + Identifier: reportForbiddenSymbolName, + PrivateIdentifier: reportForbiddenSymbolName, + JSXIdentifier: reportForbiddenSymbolName, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts new file mode 100644 index 0000000..9616911 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts @@ -0,0 +1,81 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +type Parameter = ESTree.ParamPattern +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature + +function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === 'TSParameterProperty') { + return parameterAnnotation(parameter.parameter) + } + if (parameter.type === 'RestElement') { + return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument) + } + if (parameter.type === 'AssignmentPattern') { + return parameter.typeAnnotation ?? parameter.left.typeAnnotation + } + return parameter.typeAnnotation +} + +function parameterName(parameter: Parameter, sourceText: string): string { + if (parameter.type === 'TSParameterProperty') { + return parameterName(parameter.parameter, sourceText) + } + if (parameter.type === 'AssignmentPattern') { + return parameterName(parameter.left, sourceText) + } + if (parameter.type === 'RestElement') { + return parameterName(parameter.argument, sourceText) + } + return parameter.type === 'Identifier' ? parameter.name : sourceText.replace(/\s*:\s*unknown\s*$/u, '') +} + +/** Disallow unknown inputs except explicitly named error-cause enrichment. */ +export const noUnknownParametersRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow explicitly unknown function parameters except `cause`; decode unknown input at its I/O boundary instead.', + }, + messages: { + unknownParameter: + 'Parameter `{{parameter}}` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.', + }, + }, + createOnce(context) { + const checkParameters = (node: ParameterOwner) => { + for (const parameter of node.params) { + const annotation = parameterAnnotation(parameter) + if (annotation?.typeAnnotation.type !== 'TSUnknownKeyword') continue + const name = parameterName(parameter, context.sourceCode.getText(parameter)) + if (name === 'cause') continue + context.report({ + node: annotation.typeAnnotation, + messageId: 'unknownParameter', + data: { parameter: name }, + }) + } + } + + return { + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-unknown-returns.ts b/tools/oxlint/anti-slop/rules/no-unknown-returns.ts new file mode 100644 index 0000000..2552677 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-returns.ts @@ -0,0 +1,105 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +import { lexicalTypeParameterNames } from '../shared/lexical-type-parameters.ts' + +type FunctionWithReturnType = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature + +function referencedAliasName(type: ESTree.TSType): string | null { + if (type.type === 'TSParenthesizedType') return referencedAliasName(type.typeAnnotation) + if (type.type !== 'TSTypeReference' || type.typeName.type !== 'Identifier') return null + return type.typeArguments === null || type.typeArguments === undefined || type.typeArguments.params.length === 0 + ? type.typeName.name + : null +} + +/** Ban function contracts that return unknown instead of a parsed domain type. */ +export const noUnknownReturnsRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Disallow functions whose explicit return contract is unknown or Promise.', + }, + messages: { + unknownReturn: + 'This function exposes `unknown` to its caller. Parse the value at its boundary and return a named domain type.', + }, + }, + createOnce(context) { + const aliases = new Map() + + const resolvesToUnknown = ( + type: ESTree.TSType, + shadowedAliases: ReadonlySet, + visited = new Set(), + ): boolean => { + if (type.type === 'TSUnknownKeyword') return true + if (type.type === 'TSParenthesizedType') { + return resolvesToUnknown(type.typeAnnotation, shadowedAliases, visited) + } + if (type.type === 'TSUnionType') { + return type.types.some((member) => resolvesToUnknown(member, shadowedAliases, visited)) + } + if ( + type.type === 'TSTypeReference' && + type.typeName.type === 'Identifier' && + (type.typeName.name === 'Promise' || type.typeName.name === 'PromiseLike') + ) { + const value = type.typeArguments?.params[0] + return value !== undefined && resolvesToUnknown(value, shadowedAliases, visited) + } + const name = referencedAliasName(type) + if (name === null || visited.has(name) || shadowedAliases.has(name)) return false + const alias = aliases.get(name) + if (alias === undefined || (alias.typeParameters !== null && alias.typeParameters !== undefined)) { + return false + } + const nextVisited = new Set(visited) + nextVisited.add(name) + return resolvesToUnknown(alias.typeAnnotation, shadowedAliases, nextVisited) + } + + const checkReturnType = (node: FunctionWithReturnType) => { + const annotation = node.returnType + if (annotation === null || annotation === undefined) return + if ( + !resolvesToUnknown( + annotation.typeAnnotation, + lexicalTypeParameterNames(node, context.sourceCode.visitorKeys), + ) + ) { + return + } + context.report({ node: annotation.typeAnnotation, messageId: 'unknownReturn' }) + } + + return { + Program(node) { + aliases.clear() + for (const statement of node.body) { + const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement + if (declaration?.type === 'TSTypeAliasDeclaration') { + aliases.set(declaration.id.name, declaration) + } + } + }, + ArrowFunctionExpression: checkReturnType, + FunctionDeclaration: checkReturnType, + FunctionExpression: checkReturnType, + TSCallSignatureDeclaration: checkReturnType, + TSConstructSignatureDeclaration: checkReturnType, + TSConstructorType: checkReturnType, + TSDeclareFunction: checkReturnType, + TSEmptyBodyFunctionExpression: checkReturnType, + TSFunctionType: checkReturnType, + TSMethodSignature: checkReturnType, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts new file mode 100644 index 0000000..32a7103 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts @@ -0,0 +1,62 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +function referencedAliasName(type: ESTree.TSType): string | null { + if (type.type === 'TSParenthesizedType') return referencedAliasName(type.typeAnnotation) + if (type.type !== 'TSTypeReference' || type.typeName.type !== 'Identifier') return null + return type.typeArguments === null || type.typeArguments === undefined || type.typeArguments.params.length === 0 + ? type.typeName.name + : null +} + +/** Ban named aliases that merely conceal TypeScript's unknown top type. */ +export const noUnknownTypeAliasesRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow type aliases whose resolved type is unknown; unknown must remain visible at an allowed boundary.', + }, + messages: { + unknownAlias: + 'Type alias `{{alias}}` hides `unknown`. Keep `unknown` explicit at the parsing boundary or on an allowed `cause` field; otherwise use the parsed owner type.', + }, + }, + createOnce(context) { + const aliases = new Map() + + const resolvesToUnknown = (type: ESTree.TSType, visited = new Set()): boolean => { + if (type.type === 'TSUnknownKeyword') return true + if (type.type === 'TSParenthesizedType') return resolvesToUnknown(type.typeAnnotation, visited) + const name = referencedAliasName(type) + if (name === null || visited.has(name)) return false + const alias = aliases.get(name) + if (alias === undefined || (alias.typeParameters !== null && alias.typeParameters !== undefined)) { + return false + } + const nextVisited = new Set(visited) + nextVisited.add(name) + return resolvesToUnknown(alias.typeAnnotation, nextVisited) + } + + return { + Program(node) { + aliases.clear() + for (const statement of node.body) { + const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement + if (declaration?.type === 'TSTypeAliasDeclaration') { + aliases.set(declaration.id.name, declaration) + } + } + for (const alias of aliases.values()) { + if (!resolvesToUnknown(alias.typeAnnotation, new Set([alias.id.name]))) continue + context.report({ + node: alias.id, + messageId: 'unknownAlias', + data: { alias: alias.id.name }, + }) + } + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts new file mode 100644 index 0000000..a521d6e --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts @@ -0,0 +1,124 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +import { + classifyUnsafeDictionary, + classifyUnsafeDictionaryValue, + createTypeEnvironment, + type TypeEnvironment, +} from '../shared/dictionary-types.ts' + +const typeNodeKinds: ReadonlySet = new Set([ + 'JSDocNonNullableType', + 'JSDocNullableType', + 'JSDocUnknownType', + 'TSAnyKeyword', + 'TSArrayType', + 'TSBigIntKeyword', + 'TSBooleanKeyword', + 'TSConditionalType', + 'TSConstructorType', + 'TSFunctionType', + 'TSImportType', + 'TSIndexedAccessType', + 'TSInferType', + 'TSIntersectionType', + 'TSIntrinsicKeyword', + 'TSLiteralType', + 'TSMappedType', + 'TSNamedTupleMember', + 'TSNeverKeyword', + 'TSNullKeyword', + 'TSNumberKeyword', + 'TSObjectKeyword', + 'TSParenthesizedType', + 'TSStringKeyword', + 'TSSymbolKeyword', + 'TSTemplateLiteralType', + 'TSThisType', + 'TSTupleType', + 'TSTypeLiteral', + 'TSTypeOperator', + 'TSTypePredicate', + 'TSTypeQuery', + 'TSTypeReference', + 'TSUndefinedKeyword', + 'TSUnionType', + 'TSUnknownKeyword', + 'TSVoidKeyword', +]) + +function isTypeNode(node: ESTree.Node): node is ESTree.TSType { + return typeNodeKinds.has(node.type) +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === 'Identifier' ? type.typeName.name : null +} + +function isInsideTypeAliasDeclaration(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent + while (current !== null && current.type !== 'Program') { + if (current.type === 'TSTypeAliasDeclaration') return true + current = current.parent + } + return false +} + +function isPlainAliasConsumerUse(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (node.type !== 'TSTypeReference' || node.typeArguments?.params.length) return false + const name = typeReferenceName(node) + return name !== null && environment.aliases.has(name) && !isInsideTypeAliasDeclaration(node) +} + +function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (isPlainAliasConsumerUse(node, environment)) return false + if (classifyUnsafeDictionary(node, environment) === null) return false + let current: ESTree.Node | null = node.parent + while (current !== null && current.type !== 'Program') { + if (isTypeNode(current) && classifyUnsafeDictionary(current, environment) !== null) return false + current = current.parent + } + return true +} + +/** Disallow object-dictionary contracts whose direct value type is an unsafe escape hatch. */ +export const noUnsafeDictionaryTypeRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow object-dictionary contracts whose direct value type is unknown, any, object, {}, or a union/alias containing one of those escape hatches.', + }, + messages: { + unsafeDictionary: + "This dictionary's {{value}} value type gives callers no concrete value contract. Use an owner/schema-derived value type; parse external payloads before insertion.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null + const report = (node: ESTree.Node, value: string) => { + context.report({ node, messageId: 'unsafeDictionary', data: { value } }) + } + const reportIfUnsafe = (node: ESTree.TSType) => { + if (environment === null || !shouldReportType(node, environment)) return + const unsafe = classifyUnsafeDictionary(node, environment) + if (unsafe === null) return + report(node, unsafe.unsafeValue) + } + + return { + Program(node) { + environment = createTypeEnvironment(node) + }, + TSTypeReference: reportIfUnsafe, + TSTypeLiteral: reportIfUnsafe, + TSMappedType: reportIfUnsafe, + TSIndexSignature(node) { + if (environment === null || node.typeAnnotation === null || node.parent.type === 'TSTypeLiteral') return + const unsafe = classifyUnsafeDictionaryValue(node.typeAnnotation.typeAnnotation, environment) + if (unsafe !== null) report(node, unsafe.unsafeValue) + }, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts new file mode 100644 index 0000000..fa15031 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts @@ -0,0 +1,343 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree, Variable } from '@oxlint/plugins' + +type BroadTypeKind = 'top' | 'object' | 'record' + +type KnownValueEvidence = { + readonly type: ESTree.TSType | null +} + +const functionBoundaryTypes = new Set([ + 'ArrowFunctionExpression', + 'FunctionDeclaration', + 'FunctionExpression', + 'TSDeclareFunction', + 'TSEmptyBodyFunctionExpression', +]) + +function unwrapExpressionParentheses(expression: ESTree.Expression): ESTree.Expression { + let current = expression + while (current.type === 'ParenthesizedExpression') current = current.expression + return current +} + +function unwrapTypeParentheses(type: ESTree.TSType): ESTree.TSType { + let current = type + while (current.type === 'TSParenthesizedType') current = current.typeAnnotation + return current +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === 'Identifier' ? type.typeName.name : null +} + +function isUnknownOrAnyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type) + return unwrapped.type === 'TSUnknownKeyword' || unwrapped.type === 'TSAnyKeyword' +} + +function isBroadRecordKeyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type) + if ( + unwrapped.type === 'TSStringKeyword' || + unwrapped.type === 'TSNumberKeyword' || + unwrapped.type === 'TSSymbolKeyword' + ) { + return true + } + if (unwrapped.type === 'TSUnionType') return unwrapped.types.every(isBroadRecordKeyType) + return unwrapped.type === 'TSTypeReference' && typeReferenceName(unwrapped) === 'PropertyKey' +} + +function isBroadRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type) + + if (unwrapped.type === 'TSTypeReference') { + if (typeReferenceName(unwrapped) === 'Readonly') { + const [inner] = unwrapped.typeArguments?.params ?? [] + return inner !== undefined && isBroadRecordType(inner) + } + + if (typeReferenceName(unwrapped) !== 'Record') return false + const parameters = unwrapped.typeArguments?.params ?? [] + return ( + parameters.length === 2 && + parameters[0] !== undefined && + parameters[1] !== undefined && + isBroadRecordKeyType(parameters[0]) && + isUnknownOrAnyType(parameters[1]) + ) + } + + if (unwrapped.type !== 'TSTypeLiteral' || unwrapped.members.length !== 1) return false + const [member] = unwrapped.members + const [parameter] = member?.type === 'TSIndexSignature' ? member.parameters : [] + return ( + member?.type === 'TSIndexSignature' && + member.parameters.length === 1 && + parameter !== undefined && + isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) && + isUnknownOrAnyType(member.typeAnnotation.typeAnnotation) + ) +} + +function broadTypeKind(type: ESTree.TSType): BroadTypeKind | null { + const unwrapped = unwrapTypeParentheses(type) + if (unwrapped.type === 'TSUnknownKeyword' || unwrapped.type === 'TSAnyKeyword') return 'top' + if (unwrapped.type === 'TSObjectKeyword') return 'object' + return isBroadRecordType(unwrapped) ? 'record' : null +} + +function assertedExpression(node: ESTree.TSAsExpression | ESTree.TSTypeAssertion): ESTree.Expression { + return unwrapExpressionParentheses(node.expression) +} + +function assertionFromExpression(expression: ESTree.Expression): ESTree.TSAsExpression | ESTree.TSTypeAssertion | null { + const unwrapped = unwrapExpressionParentheses(expression) + return unwrapped.type === 'TSAsExpression' || unwrapped.type === 'TSTypeAssertion' ? unwrapped : null +} + +function normalizedTypeText(sourceText: string, type: ESTree.TSType): string { + return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, '') +} + +function typesHaveSameSyntax(sourceText: string, left: ESTree.TSType | null, right: ESTree.TSType): boolean { + return ( + left !== null && + normalizedTypeText(sourceText, unwrapTypeParentheses(left)) === + normalizedTypeText(sourceText, unwrapTypeParentheses(right)) + ) +} + +function isDefinitelyObjectType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type) + switch (unwrapped.type) { + case 'TSArrayType': + case 'TSConstructorType': + case 'TSFunctionType': + case 'TSMappedType': + case 'TSObjectKeyword': + case 'TSTupleType': + return true + case 'TSTypeLiteral': + return unwrapped.members.length > 0 + case 'TSIntersectionType': + return unwrapped.types.every(isDefinitelyObjectType) + case 'TSTypeOperator': + return unwrapped.operator === 'readonly' && isDefinitelyObjectType(unwrapped.typeAnnotation) + default: + return false + } +} + +function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type) + if (unwrapped.type === 'TSTypeLiteral') { + return unwrapped.members.some((member) => member.type !== 'TSIndexSignature') + } + + if (unwrapped.type !== 'TSTypeReference') return false + if (typeReferenceName(unwrapped) === 'Readonly') { + const [inner] = unwrapped.typeArguments?.params ?? [] + return inner !== undefined && isDefinitelyNarrowerRecordType(inner) + } + if (typeReferenceName(unwrapped) !== 'Record') return false + + const parameters = unwrapped.typeArguments?.params ?? [] + return parameters.length === 2 && parameters[1] !== undefined && !isUnknownOrAnyType(parameters[1]) +} + +function functionBoundary(node: ESTree.Node): ESTree.Node | null { + let current = node.parent + while (current !== null && current.type !== 'Program') { + if (functionBoundaryTypes.has(current.type)) return current + current = current.parent + } + return null +} + +function resolvedVariableForIdentifier( + scopes: readonly { + readonly references: readonly { + readonly identifier: ESTree.Node + readonly resolved: Variable | null + }[] + }[], + identifier: ESTree.IdentifierReference, +): Variable | null { + for (const scope of scopes) { + const reference = scope.references.find( + (candidate) => + candidate.identifier.start === identifier.start && candidate.identifier.end === identifier.end, + ) + if (reference !== undefined) return reference.resolved + } + return null +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + for (const definition of variable.defs) { + if (definition.type === 'Variable' && definition.node.type === 'VariableDeclarator') { + return definition.node + } + } + return null +} + +function knownValueEvidence( + expression: ESTree.Expression, + scopes: Parameters[0], + boundary: ESTree.Node | null, + visitedVariables: ReadonlySet, +): KnownValueEvidence | null { + const unwrapped = unwrapExpressionParentheses(expression) + + if (unwrapped.type === 'TSAsExpression' || unwrapped.type === 'TSTypeAssertion') { + if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null + return { type: unwrapped.typeAnnotation } + } + + if (unwrapped.type === 'Literal' || unwrapped.type === 'TemplateLiteral') { + return { type: null } + } + + if ( + unwrapped.type === 'ArrayExpression' || + unwrapped.type === 'ArrowFunctionExpression' || + unwrapped.type === 'ClassExpression' || + unwrapped.type === 'FunctionExpression' || + unwrapped.type === 'NewExpression' || + unwrapped.type === 'ObjectExpression' + ) { + return { type: null } + } + + if (unwrapped.type !== 'Identifier') return null + const variable = resolvedVariableForIdentifier(scopes, unwrapped) + if (variable === null || visitedVariables.has(variable)) return null + + const annotatedIdentifier = variable.identifiers.find( + (identifier) => identifier.typeAnnotation !== null && identifier.typeAnnotation !== undefined, + ) + const annotation = annotatedIdentifier?.typeAnnotation?.typeAnnotation + if (annotation !== undefined && annotatedIdentifier !== undefined) { + if (functionBoundary(annotatedIdentifier) !== boundary || broadTypeKind(annotation) !== null) { + return null + } + return { type: annotation } + } + + const declarator = variableDeclarator(variable) + if ( + declarator === null || + declarator.parent.type !== 'VariableDeclaration' || + declarator.parent.kind !== 'const' || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) || + functionBoundary(declarator) !== boundary + ) { + return null + } + + return knownValueEvidence(declarator.init, scopes, boundary, new Set([...visitedVariables, variable])) +} + +function widenedBinding( + variable: Variable, + scopes: Parameters[0], +): { + readonly broadKind: BroadTypeKind + readonly evidence: KnownValueEvidence + readonly declaredAt: number + readonly boundary: ESTree.Node | null +} | null { + const declarator = variableDeclarator(variable) + if ( + declarator === null || + declarator.parent.type !== 'VariableDeclaration' || + declarator.parent.kind !== 'const' || + declarator.id.type !== 'Identifier' || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) + ) { + return null + } + + const boundary = functionBoundary(declarator) + const declaredType = declarator.id.typeAnnotation?.typeAnnotation + const initializerAssertion = assertionFromExpression(declarator.init) + const initializerBroadKind = + initializerAssertion === null ? null : broadTypeKind(initializerAssertion.typeAnnotation) + const declaredBroadKind = declaredType === undefined ? null : broadTypeKind(declaredType) + const broadKind = declaredBroadKind ?? initializerBroadKind + if (broadKind === null) return null + + const originalExpression = + initializerAssertion !== null && initializerBroadKind !== null + ? assertedExpression(initializerAssertion) + : declarator.init + const evidence = knownValueEvidence(originalExpression, scopes, boundary, new Set([variable])) + return evidence === null ? null : { broadKind, evidence, declaredAt: declarator.end, boundary } +} + +function assertionIsNarrower( + sourceText: string, + broadKind: BroadTypeKind, + evidence: KnownValueEvidence, + assertedType: ESTree.TSType, +): boolean { + if (broadTypeKind(assertedType) !== null) return false + if (broadKind === 'top') return true + if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true + if (broadKind === 'object') return isDefinitelyObjectType(assertedType) + return isDefinitelyNarrowerRecordType(assertedType) +} + +/** Detect immutable local bindings that erase a known type and are later asserted back to a narrower type. */ +export const noWidenThenAssertRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.', + }, + messages: { + widenThenAssert: + 'Binding "{{name}}" discards type evidence and later recreates it with an assertion. Keep the precise type from initialization through use; parse boundary input once.', + }, + }, + createOnce(context) { + let scopes: Parameters[0] = [] + + const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => { + const expression = assertedExpression(node) + if (expression.type !== 'Identifier') return + + const variable = resolvedVariableForIdentifier(scopes, expression) + if (variable === null) return + const widened = widenedBinding(variable, scopes) + if ( + widened === null || + node.start <= widened.declaredAt || + functionBoundary(node) !== widened.boundary || + !assertionIsNarrower(context.sourceCode.text, widened.broadKind, widened.evidence, node.typeAnnotation) + ) { + return + } + + context.report({ + node, + messageId: 'widenThenAssert', + data: { name: expression.name }, + }) + } + + return { + Program() { + scopes = context.sourceCode.scopeManager.scopes + }, + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + } + }, +}) diff --git a/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts new file mode 100644 index 0000000..f0338ec --- /dev/null +++ b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts @@ -0,0 +1,60 @@ +import { defineRule } from '@oxlint/plugins' +import type { ESTree, SourceCode } from '@oxlint/plugins' + +type TypeAssertion = ESTree.TSAsExpression | ESTree.TSTypeAssertion + +const commentOwnerKinds = new Set([ + 'ExpressionStatement', + 'PropertyDefinition', + 'ReturnStatement', + 'ThrowStatement', + 'VariableDeclaration', +]) + +function isConstAssertion(node: TypeAssertion): boolean { + return ( + node.typeAnnotation.type === 'TSTypeReference' && + node.typeAnnotation.typeName.type === 'Identifier' && + node.typeAnnotation.typeName.name === 'const' + ) +} + +function hasSafetyComment(sourceCode: SourceCode, node: TypeAssertion): boolean { + let current: ESTree.Node = node + while (true) { + if ( + sourceCode + .getCommentsBefore(current) + .some((comment) => comment.end <= node.start && /\bSAFETY\s*:/u.test(comment.value)) + ) { + return true + } + if (commentOwnerKinds.has(current.type) || current.parent.type === 'Program') return false + current = current.parent + } +} + +/** Require every non-const type assertion to state the invariant TypeScript cannot express. */ +export const requireSafetyCommentForTypeAssertionRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: 'Require a nearby SAFETY comment for every TypeScript type assertion except const assertions.', + }, + messages: { + missingSafetyComment: + 'This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.', + }, + }, + createOnce(context) { + const checkAssertion = (node: TypeAssertion) => { + if (isConstAssertion(node) || hasSafetyComment(context.sourceCode, node)) return + context.report({ node, messageId: 'missingSafetyComment' }) + } + + return { + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + } + }, +}) diff --git a/tools/oxlint/anti-slop/shared/dictionary-types.ts b/tools/oxlint/anti-slop/shared/dictionary-types.ts new file mode 100644 index 0000000..7fcdb37 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/dictionary-types.ts @@ -0,0 +1,443 @@ +import type { ESTree } from '@oxlint/plugins' + +const BUILT_INS = new Set(['Record', 'Readonly', 'Partial', 'Required', 'Pick', 'Omit', 'PropertyKey', 'NonNullable']) +const TRANSPARENT_WRAPPERS = new Set(['Readonly', 'Partial', 'Required', 'NonNullable']) + +type TypeAliasEnvironment = ReadonlyMap + +type ResolvedType = { + readonly type: ESTree.TSType + readonly substitutions: TypeAliasEnvironment +} + +export type UnsafeDictionary = { + readonly kind: 'unsafe-dictionary' + readonly unsafeValue: 'any' | 'empty-object' | 'object' | 'union' | 'unknown' +} + +export type WideningTargetKind = 'anonymous object' | 'generic container' | 'object' | 'open dictionary' | 'unknown' + +export type WideningTarget = { + readonly kind: WideningTargetKind +} + +export type TypeEnvironment = { + readonly aliases: ReadonlyMap + readonly interfaces: ReadonlyMap + readonly shadowedBuiltIns: ReadonlySet +} + +function declaredStatement(statement: ESTree.Statement): ESTree.Node | null { + return statement.type === 'ExportNamedDeclaration' || statement.type === 'ExportDefaultDeclaration' + ? (statement.declaration ?? null) + : statement +} + +export function createTypeEnvironment(program: ESTree.Program): TypeEnvironment { + const aliases = new Map() + const interfaces = new Map() + const shadowedBuiltIns = new Set() + + for (const statement of program.body) { + const declaration = declaredStatement(statement) + if (declaration?.type === 'ImportDeclaration') { + for (const specifier of declaration.specifiers) { + if (BUILT_INS.has(specifier.local.name)) shadowedBuiltIns.add(specifier.local.name) + } + continue + } + + if (declaration?.type === 'TSTypeAliasDeclaration') { + const existing = aliases.get(declaration.id.name) + if (existing === undefined) aliases.set(declaration.id.name, declaration) + else shadowedBuiltIns.add(declaration.id.name) + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name) + continue + } + + if (declaration?.type === 'TSInterfaceDeclaration') { + const declarations = interfaces.get(declaration.id.name) ?? [] + declarations.push(declaration) + interfaces.set(declaration.id.name, declarations) + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name) + continue + } + + if (declaration?.type === 'TSEnumDeclaration') { + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name) + continue + } + + if ( + (declaration?.type === 'ClassDeclaration' || declaration?.type === 'FunctionDeclaration') && + declaration.id !== null + ) { + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name) + } + } + + return { aliases, interfaces, shadowedBuiltIns } +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === 'Identifier' ? type.typeName.name : null +} + +function isBuiltIn(name: string, environment: TypeEnvironment): boolean { + return BUILT_INS.has(name) && !environment.shadowedBuiltIns.has(name) +} + +function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean { + const unwrapped = unwrapTransparentType(type) + return ( + unwrapped.type === 'TSTypeReference' && + typeReferenceName(unwrapped) === name && + (unwrapped.typeArguments === null || + unwrapped.typeArguments === undefined || + unwrapped.typeArguments.params.length === 0) + ) +} + +function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType { + let current = type + while ( + current.type === 'TSParenthesizedType' || + (current.type === 'TSTypeOperator' && current.operator === 'readonly') + ) { + current = current.typeAnnotation + } + return current +} + +function isNeverType(type: ESTree.TSType): boolean { + return unwrapTransparentType(type).type === 'TSNeverKeyword' +} + +function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean { + return ( + member.type === 'TSPropertySignature' && + member.optional === true && + member.typeAnnotation !== null && + member.typeAnnotation !== undefined && + isNeverType(member.typeAnnotation.typeAnnotation) + ) +} + +function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean { + return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember) +} + +function isEffectivelyEmptyInterface(declarations: readonly ESTree.TSInterfaceDeclaration[]): boolean { + if (declarations.length !== 1) return false + const [type] = declarations + return ( + type !== undefined && + type.extends.length === 0 && + (type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember)) + ) +} + +function resolvedSubstitutionArgument( + type: ESTree.TSType, + base: TypeAliasEnvironment, + resolving: ReadonlySet = new Set(), +): ESTree.TSType { + const unwrapped = unwrapTransparentType(type) + if (unwrapped.type !== 'TSTypeReference') return type + const name = typeReferenceName(unwrapped) + if (name === null || resolving.has(name)) return type + const substitution = base.get(name) + if (substitution === undefined) return type + const nextResolving = new Set(resolving) + nextResolving.add(name) + return resolvedSubstitutionArgument(substitution, base, nextResolving) +} + +function aliasSubstitution( + alias: ESTree.TSTypeAliasDeclaration, + type: ESTree.TSTypeReference, + base: TypeAliasEnvironment, +): TypeAliasEnvironment | null { + const parameters = alias.typeParameters?.params ?? [] + const arguments_ = type.typeArguments?.params ?? [] + const next = new Map(base) + for (const [index, parameter] of parameters.entries()) { + const argument = arguments_[index] ?? parameter.default + if (argument === null || argument === undefined) return null + next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next)) + } + return next +} + +function unsafeDirectValue( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): UnsafeDictionary['unsafeValue'] | null { + const unwrapped = unwrapTransparentType(type) + if (unwrapped.type === 'TSUnknownKeyword') return 'unknown' + if (unwrapped.type === 'TSAnyKeyword') return 'any' + if (unwrapped.type === 'TSObjectKeyword') return 'object' + if (unwrapped.type === 'TSTypeLiteral' && isEffectivelyEmptyTypeLiteral(unwrapped)) return 'empty-object' + if (unwrapped.type === 'TSUnionType') { + return unwrapped.types.some( + (member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null, + ) + ? 'union' + : null + } + if (unwrapped.type === 'TSIntersectionType') { + const unsafeMembers = unwrapped.types.map((member) => + unsafeDirectValue(member, environment, substitutions, resolvingAliases), + ) + if (unsafeMembers.includes('any')) return 'any' + return unsafeMembers.length > 0 && unsafeMembers.every((member) => member !== null) ? unsafeMembers[0] : null + } + if (unwrapped.type !== 'TSTypeReference') return null + const name = typeReferenceName(unwrapped) + if (name === null) return null + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0] + return wrapped === undefined ? null : unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases) + } + const substitution = substitutions.get(name) + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : unsafeDirectValue(substitution, environment, substitutions, resolvingAliases) + } + const interfaceDeclarations = environment.interfaces.get(name) + if (interfaceDeclarations !== undefined) { + return isEffectivelyEmptyInterface(interfaceDeclarations) ? 'empty-object' : null + } + const alias = environment.aliases.get(name) + if (alias === undefined || resolvingAliases.has(name)) return null + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions) + if (nextSubstitutions === null) return null + const nextResolving = new Set(resolvingAliases) + nextResolving.add(name) + return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving) +} + +function dictionaryValueTypes( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): readonly ResolvedType[] { + const unwrapped = unwrapTransparentType(type) + + if (unwrapped.type === 'TSTypeLiteral') { + return unwrapped.members.flatMap((member): readonly ResolvedType[] => + member.type === 'TSIndexSignature' && member.typeAnnotation !== null + ? [{ type: member.typeAnnotation.typeAnnotation, substitutions }] + : [], + ) + } + + if (unwrapped.type === 'TSMappedType') { + return unwrapped.typeAnnotation === null ? [] : [{ type: unwrapped.typeAnnotation, substitutions }] + } + + if (unwrapped.type !== 'TSTypeReference') return [] + const name = typeReferenceName(unwrapped) + if (name === null) return [] + + const substitution = substitutions.get(name) + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? [] + : dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases) + } + + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0] + return wrapped === undefined ? [] : dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases) + } + + if (name === 'Record' && isBuiltIn(name, environment)) { + const value = unwrapped.typeArguments?.params[1] ?? null + return value === null ? [] : [{ type: value, substitutions }] + } + + if ((name === 'Pick' || name === 'Omit') && isBuiltIn(name, environment)) { + const source = unwrapped.typeArguments?.params[0] + return source === undefined ? [] : dictionaryValueTypes(source, environment, substitutions, resolvingAliases) + } + + const alias = environment.aliases.get(name) + if (alias === undefined || resolvingAliases.has(name)) return [] + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions) + if (nextSubstitutions === null) return [] + const nextResolving = new Set(resolvingAliases) + nextResolving.add(name) + return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving) +} + +export function classifyUnsafeDictionaryValue( + valueType: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set()) + return unsafeValue === null ? null : { kind: 'unsafe-dictionary', unsafeValue } +} + +export function classifyUnsafeDictionary(type: ESTree.TSType, environment: TypeEnvironment): UnsafeDictionary | null { + for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) { + const unsafeValue = unsafeDirectValue(valueType.type, environment, valueType.substitutions, new Set()) + if (unsafeValue !== null) return { kind: 'unsafe-dictionary', unsafeValue } + } + return null +} + +function resolvesToDictionary( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): boolean { + return dictionaryValueTypes(type, environment, substitutions, resolvingAliases).length > 0 +} + +export function classifyWideningTarget(type: ESTree.TSType, environment: TypeEnvironment): WideningTarget | null { + const unwrapped = unwrapTransparentType(type) + if (unwrapped.type === 'TSUnknownKeyword') return { kind: 'unknown' } + if (unwrapped.type === 'TSObjectKeyword') return { kind: 'object' } + if (unwrapped.type === 'TSTypeLiteral') { + return unwrapped.members.some((member) => member.type === 'TSIndexSignature') + ? { kind: 'open dictionary' } + : unwrapped.members.length > 0 + ? { kind: 'anonymous object' } + : null + } + if (unwrapped.type === 'TSMappedType') return { kind: 'open dictionary' } + if (unwrapped.type !== 'TSTypeReference') return null + const name = typeReferenceName(unwrapped) + if (name === null) return null + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0] + return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment) + } + if (name === 'Record' && isBuiltIn(name, environment)) return { kind: 'open dictionary' } + const alias = environment.aliases.get(name) + if (alias === undefined) return null + if ((alias.typeParameters?.params.length ?? 0) > 0) { + const substitutions = aliasSubstitution(alias, unwrapped, new Map()) + return substitutions !== null && + resolvesToDictionary(alias.typeAnnotation, environment, substitutions, new Set([name])) + ? { kind: 'generic container' } + : null + } + const substitutions = aliasSubstitution(alias, unwrapped, new Map()) + if (substitutions === null) return null + const resolved = classifyAliasBroadTarget(alias.typeAnnotation, environment, substitutions, new Set([name])) + return resolved +} + +function isBroadMappedKey( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, +): boolean { + const unwrapped = unwrapTransparentType(type) + if ( + unwrapped.type === 'TSStringKeyword' || + unwrapped.type === 'TSNumberKeyword' || + unwrapped.type === 'TSSymbolKeyword' + ) { + return true + } + if (unwrapped.type === 'TSUnionType') { + return unwrapped.types.every((member) => isBroadMappedKey(member, environment, substitutions)) + } + if (unwrapped.type !== 'TSTypeReference') return false + const name = typeReferenceName(unwrapped) + if (name === null) return false + const substitution = substitutions.get(name) + if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) { + return isBroadMappedKey(substitution, environment, substitutions) + } + return name === 'PropertyKey' && isBuiltIn(name, environment) +} + +function classifyAliasBroadTarget( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type) + if (unwrapped.type === 'TSUnknownKeyword') return { kind: 'unknown' } + if (unwrapped.type === 'TSObjectKeyword') return { kind: 'object' } + if (unwrapped.type === 'TSTypeLiteral') { + return unwrapped.members.some((member) => member.type === 'TSIndexSignature') + ? { kind: 'open dictionary' } + : null + } + if (unwrapped.type === 'TSMappedType') { + return isBroadMappedKey(unwrapped.constraint, environment, substitutions) ? { kind: 'open dictionary' } : null + } + if (unwrapped.type !== 'TSTypeReference') return null + const name = typeReferenceName(unwrapped) + if (name === null) return null + const substitution = substitutions.get(name) + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : classifyAliasBroadTarget(substitution, environment, substitutions, resolvingAliases) + } + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0] + return wrapped === undefined + ? null + : classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases) + } + if (name === 'Record' && isBuiltIn(name, environment)) { + return { kind: 'open dictionary' } + } + const alias = environment.aliases.get(name) + if (alias === undefined || resolvingAliases.has(name)) return null + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions) + if (nextSubstitutions === null) return null + const nextResolving = new Set(resolvingAliases) + nextResolving.add(name) + return classifyAliasBroadTarget(alias.typeAnnotation, environment, nextSubstitutions, nextResolving) +} + +export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean { + let current = expression + while ( + current.type === 'ParenthesizedExpression' || + current.type === 'TSAsExpression' || + current.type === 'TSTypeAssertion' || + current.type === 'TSNonNullExpression' + ) { + current = current.expression + } + return current.type === 'ObjectExpression' && current.properties.length > 0 +} + +export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean { + let current = expression + while ( + current.type === 'ParenthesizedExpression' || + current.type === 'TSAsExpression' || + current.type === 'TSTypeAssertion' || + current.type === 'TSNonNullExpression' || + current.type === 'TSSatisfiesExpression' + ) { + current = current.expression + } + if (current.type === 'ObjectExpression') return true + return ( + current.type === 'ArrayExpression' || + current.type === 'ArrowFunctionExpression' || + current.type === 'ClassExpression' || + current.type === 'FunctionExpression' || + current.type === 'NewExpression' || + current.type === 'Literal' || + current.type === 'TemplateLiteral' || + current.type === 'UnaryExpression' + ) +} diff --git a/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts index e430f64..e9f106e 100644 --- a/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts +++ b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts @@ -1,6 +1,6 @@ import type { ESTree } from '@oxlint/plugins' -type VisitorKeys = Readonly>> +type VisitorKeys = Readonly> function isNode(value: unknown): value is ESTree.Node { return typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string' @@ -22,6 +22,7 @@ function collectInferTypeParameterNames(node: ESTree.Node, visitorKeys: VisitorK } } +/** Collect type binders that are in scope at a node and can shadow module aliases. */ export function lexicalTypeParameterNames(node: ESTree.Node, visitorKeys: VisitorKeys): ReadonlySet { const names = new Set() let descendant: ESTree.Node = node diff --git a/tools/oxlint/anti-slop/shared/reflect-method.ts b/tools/oxlint/anti-slop/shared/reflect-method.ts index 2dda097..5af5ac7 100644 --- a/tools/oxlint/anti-slop/shared/reflect-method.ts +++ b/tools/oxlint/anti-slop/shared/reflect-method.ts @@ -17,6 +17,7 @@ function isGlobalReflect(sourceCode: SourceCode, expression: ESTree.Expression): return variable === null || variable.defs.length === 0 } +/** Reports whether a call target names one method on the global Reflect object. */ export function isGlobalReflectMethodCall( sourceCode: SourceCode, callee: ESTree.Expression, diff --git a/tools/oxlint/automation/catalog.ts b/tools/oxlint/automation/catalog.ts new file mode 100644 index 0000000..a19082d --- /dev/null +++ b/tools/oxlint/automation/catalog.ts @@ -0,0 +1,134 @@ +export type RuleProfile = 'architecture' | 'core' | 'effect' | 'next-tailwind' | 'react' | 'xstate' + +export type RuleSeverity = 'error' | 'warn' + +export interface OxlintRuleMetadata { + readonly name: string + readonly profile: RuleProfile + readonly severity: RuleSeverity + readonly applicability: string + readonly options: string + readonly exceptions: string +} + +const warningRules = new Set([ + 'no-cascading-layer-provide', + 'no-effect-asvoid', + 'no-nested-layer-provide', + 'no-non-route-layout-files', + 'pipe-max-arguments', + 'prefer-option-from-nullable', +]) + +const profileRules: Readonly>> = { + architecture: [ + 'no-api-backend-imports', + 'no-api-repository-imports', + 'no-comments', + 'no-reexport-only-modules', + 'no-sql-type-parameter', + 'require-tsx-in-ui-folders', + ], + core: [ + 'no-banned-type-assertions', + 'no-multiple-function-params', + 'no-optional-function-parameters', + 'no-single-use-private-functions', + 'no-type-assertion', + 'private-function-prefix', + ], + effect: [ + 'no-ambient-nondeterminism', + 'no-cascading-layer-provide', + 'no-direct-browser-storage', + 'no-direct-fetch', + 'no-disable-validation', + 'no-effect-asvoid', + 'no-global-json', + 'no-in-operator', + 'no-nested-effect-array-methods', + 'no-nested-layer-provide', + 'no-service-option', + 'no-shadowed-standard-array-static', + 'no-silent-error-swallow', + 'no-static-effect-service-forwarders', + 'no-switch', + 'no-try-catch', + 'no-typeof-object', + 'pipe-max-arguments', + 'prefer-effect-match', + 'prefer-option-from-nullable', + 'require-context-service-in-services', + ], + 'next-tailwind': [ + 'no-css-modules', + 'no-fixed-height-on-content', + 'no-jsx-style', + 'no-non-route-layout-files', + 'require-approved-ui-primitives', + 'require-cn-for-classname-composition', + 'require-default-component-export', + ], + react: ['no-react-component-inner-functions', 'no-react-non-component-function-exports', 'no-react-state-hooks'], + xstate: [ + 'no-direct-xstate-create-machine', + 'no-direct-xstate-use-selector', + 'no-multiple-xstate-hooks', + 'no-single-use-xstate-actions', + 'no-single-use-xstate-guards', + 'require-xstate-event-satisfies', + ], +} + +const configurableRules: Readonly> = { + 'no-ambient-nondeterminism': 'allowedDateBasenames, allowedDateExtensions', + 'no-api-backend-imports': 'contractRootMarker, allowedImports', + 'no-api-repository-imports': 'handlerDirectoryNames, repositoryDirectoryNames, testDirectoryNames', + 'no-direct-xstate-use-selector': 'selectorModules, preferredModule', + 'no-fixed-height-on-content': 'allowedHeightUtilities, ignoredPathMarkers', + 'no-jsx-style': 'ignoredPathMarkers', + 'no-multiple-function-params': 'exemptRouteBasenames, exemptFunctionNames', + 'no-non-route-layout-files': 'routeDirectoryNames, allowedLayoutFilenames', + 'no-reexport-only-modules': 'routeDirectoryNames, allowedFilenames', + 'no-react-non-component-function-exports': 'frameworkSegmentBasenames, frameworkSpecialExports', + 'require-approved-ui-primitives': 'applicationPathMarkers, bannedElements, linkModule', + 'require-cn-for-classname-composition': 'utilityModules, utilityName, ignoredPathMarkers', + 'require-context-service-in-services': 'serviceDirectoryNames, ignoredDirectoryNames, ignoredBasenames', + 'require-default-component-export': 'applicationPathMarkers, ignoredFilenamePatterns, wrapperNames', + 'require-tsx-in-ui-folders': 'uiDirectoryNames, allowedTypeScriptFiles', +} + +const exceptionNotes: Readonly> = { + 'no-api-repository-imports': 'Test files are excluded.', + 'no-direct-fetch': 'Use file overrides for scripts or adapters that intentionally own HTTP.', + 'no-reexport-only-modules': 'Framework-reserved route modules and explicit public entry points may need overrides.', + 'no-try-catch': 'Use a scoped override at an integration boundary that must consume throwing APIs.', + 'require-default-component-export': 'Framework-reserved metadata and route handler files are excluded.', +} + +const profileApplicability: Readonly> = { + architecture: 'Layered application and package boundaries', + core: 'TypeScript', + effect: 'Effect', + 'next-tailwind': 'Next.js and Tailwind CSS', + react: 'React', + xstate: 'XState', +} + +export const oxlintRuleMetadata: ReadonlyArray = Object.entries(profileRules).flatMap( + ([profile, names]) => + names.map((name) => ({ + name, + profile: profile as RuleProfile, + severity: warningRules.has(name) ? 'warn' : 'error', + applicability: profileApplicability[profile as RuleProfile], + options: configurableRules[name] ?? 'None.', + exceptions: exceptionNotes[name] ?? 'None.', + })), +) + +export const rulesByProfile = profileRules + +export const severityByRule: Readonly> = Object.fromEntries( + oxlintRuleMetadata.map(({ name, severity }) => [name, severity]), +) diff --git a/tools/oxlint/automation/index.ts b/tools/oxlint/automation/index.ts index daa5bd9..7501006 100644 --- a/tools/oxlint/automation/index.ts +++ b/tools/oxlint/automation/index.ts @@ -1,28 +1,115 @@ import { eslintCompatPlugin } from '@oxlint/plugins' import noAmbientNondeterminism from './rules/no-ambient-nondeterminism.ts' +import noApiBackendImports from './rules/no-api-backend-imports.ts' +import noApiRepositoryImports from './rules/no-api-repository-imports.ts' +import noBannedTypeAssertions from './rules/no-banned-type-assertions.ts' +import noCascadingLayerProvide from './rules/no-cascading-layer-provide.ts' +import noComments from './rules/no-comments.ts' +import noCssModules from './rules/no-css-modules.ts' +import noDirectBrowserStorage from './rules/no-direct-browser-storage.ts' +import noDirectFetch from './rules/no-direct-fetch.ts' +import noDirectXstateCreateMachine from './rules/no-direct-xstate-create-machine.ts' +import noDirectXstateUseSelector from './rules/no-direct-xstate-use-selector.ts' import noDisableValidation from './rules/no-disable-validation.ts' +import noEffectAsvoid from './rules/no-effect-asvoid.ts' +import noFixedHeightOnContent from './rules/no-fixed-height-on-content.ts' +import noGlobalJson from './rules/no-global-json.ts' +import noInOperator from './rules/no-in-operator.ts' +import noJsxStyle from './rules/no-jsx-style.ts' import noManualTagComparison from './rules/no-manual-tag-comparison.ts' import noManualTaggedConstruction from './rules/no-manual-tagged-construction.ts' +import noMultipleFunctionParams from './rules/no-multiple-function-params.ts' +import noMultipleXstateHooks from './rules/no-multiple-xstate-hooks.ts' +import noNestedEffectArrayMethods from './rules/no-nested-effect-array-methods.ts' +import noNestedLayerProvide from './rules/no-nested-layer-provide.ts' +import noNonRouteLayoutFiles from './rules/no-non-route-layout-files.ts' +import noOptionalFunctionParameters from './rules/no-optional-function-parameters.ts' +import noReactComponentInnerFunctions from './rules/no-react-component-inner-functions.ts' +import noReactNonComponentFunctionExports from './rules/no-react-non-component-function-exports.ts' +import noReactStateHooks from './rules/no-react-state-hooks.ts' +import noReexportOnlyModules from './rules/no-reexport-only-modules.ts' import noServiceOption from './rules/no-service-option.ts' import noShadowedStandardArrayStatic from './rules/no-shadowed-standard-array-static.ts' import noSilentErrorSwallow from './rules/no-silent-error-swallow.ts' +import noSingleUsePrivateFunctions from './rules/no-single-use-private-functions.ts' +import noSingleUseXstateActions from './rules/no-single-use-xstate-actions.ts' +import noSingleUseXstateGuards from './rules/no-single-use-xstate-guards.ts' +import noSqlTypeParameter from './rules/no-sql-type-parameter.ts' +import noStaticEffectServiceForwarders from './rules/no-static-effect-service-forwarders.ts' +import noSwitch from './rules/no-switch.ts' +import noTryCatch from './rules/no-try-catch.ts' +import noTypeAssertion from './rules/no-type-assertion.ts' +import noTypeofObject from './rules/no-typeof-object.ts' +import pipeMaxArguments from './rules/pipe-max-arguments.ts' import preferEffectMatch from './rules/prefer-effect-match.ts' import preferOptionFromNullable from './rules/prefer-option-from-nullable.ts' import preferTaggedErrorHandling from './rules/prefer-tagged-error-handling.ts' +import privateFunctionPrefix from './rules/private-function-prefix.ts' +import requireApprovedUiPrimitives from './rules/require-approved-ui-primitives.ts' +import requireCnForClassnameComposition from './rules/require-cn-for-classname-composition.ts' +import requireContextServiceInServices from './rules/require-context-service-in-services.ts' +import requireDefaultComponentExport from './rules/require-default-component-export.ts' +import requireTsxInUiFolders from './rules/require-tsx-in-ui-folders.ts' +import requireXstateEventSatisfies from './rules/require-xstate-event-satisfies.ts' export default eslintCompatPlugin({ meta: { name: 'automation' }, rules: { 'no-ambient-nondeterminism': noAmbientNondeterminism, + 'no-api-backend-imports': noApiBackendImports, + 'no-api-repository-imports': noApiRepositoryImports, + 'no-banned-type-assertions': noBannedTypeAssertions, + 'no-cascading-layer-provide': noCascadingLayerProvide, + 'no-comments': noComments, + 'no-css-modules': noCssModules, + 'no-direct-browser-storage': noDirectBrowserStorage, + 'no-direct-fetch': noDirectFetch, + 'no-direct-xstate-create-machine': noDirectXstateCreateMachine, + 'no-direct-xstate-use-selector': noDirectXstateUseSelector, 'no-disable-validation': noDisableValidation, + 'no-effect-asvoid': noEffectAsvoid, + 'no-fixed-height-on-content': noFixedHeightOnContent, + 'no-global-json': noGlobalJson, + 'no-in-operator': noInOperator, + 'no-jsx-style': noJsxStyle, 'no-manual-tag-comparison': noManualTagComparison, 'no-manual-tagged-construction': noManualTaggedConstruction, + 'no-multiple-function-params': noMultipleFunctionParams, + 'no-multiple-xstate-hooks': noMultipleXstateHooks, + 'no-nested-effect-array-methods': noNestedEffectArrayMethods, + 'no-nested-layer-provide': noNestedLayerProvide, + 'no-non-route-layout-files': noNonRouteLayoutFiles, + 'no-optional-function-parameters': noOptionalFunctionParameters, + 'no-react-component-inner-functions': noReactComponentInnerFunctions, + 'no-react-non-component-function-exports': noReactNonComponentFunctionExports, + 'no-react-state-hooks': noReactStateHooks, + 'no-reexport-only-modules': noReexportOnlyModules, 'no-service-option': noServiceOption, 'no-shadowed-standard-array-static': noShadowedStandardArrayStatic, 'no-silent-error-swallow': noSilentErrorSwallow, + 'no-single-use-private-functions': noSingleUsePrivateFunctions, + 'no-single-use-xstate-actions': noSingleUseXstateActions, + 'no-single-use-xstate-guards': noSingleUseXstateGuards, + 'no-sql-type-parameter': noSqlTypeParameter, + 'no-static-effect-service-forwarders': noStaticEffectServiceForwarders, + 'no-switch': noSwitch, + 'no-try-catch': noTryCatch, + 'no-type-assertion': noTypeAssertion, + 'no-typeof-object': noTypeofObject, + 'pipe-max-arguments': pipeMaxArguments, 'prefer-effect-match': preferEffectMatch, 'prefer-option-from-nullable': preferOptionFromNullable, 'prefer-tagged-error-handling': preferTaggedErrorHandling, + 'private-function-prefix': privateFunctionPrefix, + 'require-approved-ui-primitives': requireApprovedUiPrimitives, + 'require-cn-for-classname-composition': requireCnForClassnameComposition, + 'require-context-service-in-services': requireContextServiceInServices, + 'require-default-component-export': requireDefaultComponentExport, + 'require-tsx-in-ui-folders': requireTsxInUiFolders, + 'require-xstate-event-satisfies': requireXstateEventSatisfies, }, }) + +export { allRules, profile, profiles } from './profiles.ts' +export { oxlintRuleMetadata, rulesByProfile, severityByRule } from './catalog.ts' diff --git a/tools/oxlint/automation/profiles.ts b/tools/oxlint/automation/profiles.ts new file mode 100644 index 0000000..46b0757 --- /dev/null +++ b/tools/oxlint/automation/profiles.ts @@ -0,0 +1,21 @@ +import { oxlintRuleMetadata, rulesByProfile, severityByRule, type RuleProfile } from './catalog.ts' + +type Severity = 'error' | 'warn' + +export const profile = (name: RuleProfile, namespace = 'automation'): Readonly> => + Object.fromEntries( + rulesByProfile[name].map((ruleName) => [`${namespace}/${ruleName}`, severityByRule[ruleName] ?? 'error']), + ) + +export const profiles = Object.freeze({ + architecture: profile('architecture'), + core: profile('core'), + effect: profile('effect'), + nextTailwind: profile('next-tailwind'), + react: profile('react'), + xstate: profile('xstate'), +}) + +export const allRules = Object.freeze( + Object.fromEntries(oxlintRuleMetadata.map(({ name, severity }) => [`automation/${name}`, severity])), +) diff --git a/tools/oxlint/automation/rules/no-api-backend-imports.ts b/tools/oxlint/automation/rules/no-api-backend-imports.ts new file mode 100644 index 0000000..f6e304c --- /dev/null +++ b/tools/oxlint/automation/rules/no-api-backend-imports.ts @@ -0,0 +1,107 @@ +import path from 'node:path' + +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type VisitorObject = ReturnType> +type ImportDeclarationNode = Parameters>[0] +type ExportAllDeclarationNode = Parameters>[0] +type ExportNamedDeclarationNode = Parameters>[0] +type ModuleDeclarationNode = ExportAllDeclarationNode | ExportNamedDeclarationNode | ImportDeclarationNode + +interface Options { + readonly allowedImports?: ReadonlyArray + readonly contractRootMarker?: string +} + +const _contractRoot = ({ filename, rootMarker }: { filename: string; rootMarker: string }) => { + const markerIndex = filename.indexOf(rootMarker) + return markerIndex === -1 ? undefined : filename.slice(0, markerIndex + rootMarker.length).replace(/\/$/, '') +} + +const _isLocalImport = ({ + contractRoot, + filename, + source, +}: { + contractRoot: string + filename: string + source: string +}) => { + if (!source.startsWith('.')) { + return false + } + + const resolved = path.posix.resolve(path.posix.dirname(filename), source) + + return resolved === contractRoot || resolved.startsWith(`${contractRoot}/`) +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Keep the public contract area independent from backend and persistence modules.', + }, + schema: [ + { + type: 'object', + properties: { + allowedImports: { + type: 'array', + items: { type: 'string' }, + }, + contractRootMarker: { type: 'string' }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const filename = (context.filename ?? '').replaceAll('\\', '/') + const options = (context.options[0] ?? {}) as Options + const rootMarker = options.contractRootMarker ?? '/src/contracts/' + const allowedImports = options.allowedImports ?? ['@scope/contracts', 'effect'] + const contractRoot = _contractRoot({ filename, rootMarker }) + + if (contractRoot === undefined) { + return {} + } + const isAllowedImport = ({ source }: { source: string }) => + allowedImports.some((allowed) => source === allowed || source.startsWith(`${allowed}/`)) + + const checkSource = ({ node, source }: { node: ModuleDeclarationNode; source: unknown }) => { + if ( + typeof source !== 'string' || + isAllowedImport({ source }) || + _isLocalImport({ + contractRoot, + filename, + source, + }) + ) { + return + } + + context.report({ + node, + message: + 'The public contract area may import only local modules and explicitly allowed dependencies. Keep backend and persistence models in their owning application.', + }) + } + + return { + ExportAllDeclaration(node) { + checkSource({ node, source: node.source.value }) + }, + ExportNamedDeclaration(node) { + checkSource({ node, source: node.source?.value }) + }, + ImportDeclaration(node) { + checkSource({ node, source: node.source.value }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-api-repository-imports.ts b/tools/oxlint/automation/rules/no-api-repository-imports.ts new file mode 100644 index 0000000..f7c04ba --- /dev/null +++ b/tools/oxlint/automation/rules/no-api-repository-imports.ts @@ -0,0 +1,75 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +interface Options { + readonly handlerDirectoryNames?: ReadonlyArray + readonly repositoryDirectoryNames?: ReadonlyArray + readonly testDirectoryNames?: ReadonlyArray +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: + 'Prevent API and route handlers from importing repositories directly; call the owning service instead.', + }, + schema: [ + { + type: 'object', + properties: { + handlerDirectoryNames: { + type: 'array', + items: { type: 'string' }, + }, + repositoryDirectoryNames: { + type: 'array', + items: { type: 'string' }, + }, + testDirectoryNames: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const options = (context.options[0] ?? {}) as Options + const handlerDirectoryNames = new Set(options.handlerDirectoryNames ?? ['api', 'routes']) + const repositoryDirectoryNames = new Set(options.repositoryDirectoryNames ?? ['repository', 'repositories']) + const testDirectoryNames = new Set(options.testDirectoryNames ?? ['test', 'tests', '__tests__']) + const filename = (context.filename ?? '').replaceAll('\\', '/') + const filenameSegments = filename.split('/') + + if ( + !filenameSegments.some((segment) => handlerDirectoryNames.has(segment)) || + filenameSegments.some((segment) => testDirectoryNames.has(segment)) + ) { + return {} + } + + return { + ImportDeclaration(node) { + if ( + typeof node.source.value !== 'string' || + !node.source.value + .replaceAll('\\', '/') + .split('/') + .some((segment) => repositoryDirectoryNames.has(segment)) + ) { + return + } + + context.report({ + node, + message: 'API and route handlers must call a service instead of importing a repository directly.', + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-banned-type-assertions.ts b/tools/oxlint/automation/rules/no-banned-type-assertions.ts new file mode 100644 index 0000000..c2708e0 --- /dev/null +++ b/tools/oxlint/automation/rules/no-banned-type-assertions.ts @@ -0,0 +1,38 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const bannedTypes = new Set(['TSAnyKeyword', 'TSNeverKeyword', 'TSUnknownKeyword']) + +const message = 'Do not assert to any, never, or unknown. Fix the type or use generics.' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Disallow assertions to any, never, or unknown; fix the type or use generics.', + }, + }, + create(context) { + return { + TSAsExpression(node) { + if (bannedTypes.has(node.typeAnnotation.type)) { + context.report({ + node, + message, + }) + } + }, + TSTypeAssertion(node) { + if (bannedTypes.has(node.typeAnnotation.type)) { + context.report({ + node, + message, + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-cascading-layer-provide.ts b/tools/oxlint/automation/rules/no-cascading-layer-provide.ts new file mode 100644 index 0000000..1cddada --- /dev/null +++ b/tools/oxlint/automation/rules/no-cascading-layer-provide.ts @@ -0,0 +1,98 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type VisitorObject = ReturnType> +type CallExpressionNode = Parameters>[0] +type IdentifierNode = Extract + +const provisioningMethods = new Set(['provide', 'provideMerge']) + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: + 'Combine independent Layer dependencies in one provide call and extract intentional dependency stages.', + }, + }, + create(context) { + const findImportDefinition = ({ node }: { node: IdentifierNode }) => { + let scope: ReturnType | null = context.sourceCode.getScope(node) + + while (scope !== null) { + const variable = scope.set.get(node.name) + + if (variable !== undefined) { + return variable.defs.find( + (definition) => + definition.type === 'ImportBinding' && definition.parent?.type === 'ImportDeclaration', + ) + } + + scope = scope.upper + } + + return undefined + } + + const isEffectLayerIdentifier = ({ node }: { node: IdentifierNode }) => { + const definition = findImportDefinition({ node }) + + return ( + definition?.parent?.type === 'ImportDeclaration' && + definition.parent.source.value === 'effect' && + definition.node.type === 'ImportSpecifier' && + (definition.node.imported.type === 'Identifier' + ? definition.node.imported.name + : definition.node.imported.value) === 'Layer' + ) + } + + const isLayerProvision = ({ node }: { node: CallExpressionNode['arguments'][number] }) => { + if ( + node.type !== 'CallExpression' || + node.callee.type !== 'MemberExpression' || + node.callee.object.type !== 'Identifier' || + !isEffectLayerIdentifier({ node: node.callee.object }) + ) { + return false + } + + const property = node.callee.property + const propertyName = + property.type === 'Identifier' + ? property.name + : property.type === 'Literal' && typeof property.value === 'string' + ? property.value + : undefined + + return provisioningMethods.has(propertyName ?? '') + } + + return { + CallExpression(node) { + if ( + node.callee.type !== 'MemberExpression' || + node.callee.property.type !== 'Identifier' || + node.callee.property.name !== 'pipe' + ) { + return + } + + const provisioningStages = node.arguments.filter((argument) => isLayerProvision({ node: argument })) + + if (provisioningStages.length < 2) { + return + } + + context.report({ + node, + message: + 'Avoid multiple Layer.provide or Layer.provideMerge stages in one pipe. Combine independent dependencies in one Layer.provide([...]); when a layer depends on another layer, extract and name that configured layer before providing it.', + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-comments.ts b/tools/oxlint/automation/rules/no-comments.ts new file mode 100644 index 0000000..6fc3fc2 --- /dev/null +++ b/tools/oxlint/automation/rules/no-comments.ts @@ -0,0 +1,40 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const allowedDirective = /^(?:\/\s*[1] +type VisitorObject = ReturnType> +type ImportDeclarationNode = Parameters>[0] +type ExportAllDeclarationNode = Parameters>[0] +type ExportNamedDeclarationNode = Parameters>[0] +type ImportExpressionNode = Parameters>[0] +type CallExpressionNode = Parameters>[0] +type ReportableNode = + | CallExpressionNode + | ExportAllDeclarationNode + | ExportNamedDeclarationNode + | ImportDeclarationNode + | ImportExpressionNode + +const cssModulePattern = /\.module\.(?:css|less|sass|scss)$/i +const message = 'CSS Modules are not allowed. Use Tailwind utilities instead.' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Disallow CSS Modules so web styling stays in Tailwind utilities.', + }, + }, + create(context) { + const checkSource = ({ node, source }: { node: ReportableNode; source: unknown }) => { + if (typeof source === 'string' && cssModulePattern.test(source)) { + context.report({ node, message }) + } + } + + return { + CallExpression(node) { + if (node.callee.type !== 'Identifier' || node.callee.name !== 'require') { + return + } + + const source = node.arguments[0] + checkSource({ + node, + source: source?.type === 'Literal' ? source.value : undefined, + }) + }, + ExportAllDeclaration(node) { + checkSource({ node, source: node.source.value }) + }, + ExportNamedDeclaration(node) { + checkSource({ node, source: node.source?.value }) + }, + ImportDeclaration(node) { + checkSource({ node, source: node.source.value }) + }, + ImportExpression(node) { + checkSource({ + node, + source: node.source.type === 'Literal' ? node.source.value : undefined, + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-direct-browser-storage.ts b/tools/oxlint/automation/rules/no-direct-browser-storage.ts new file mode 100644 index 0000000..53a54c9 --- /dev/null +++ b/tools/oxlint/automation/rules/no-direct-browser-storage.ts @@ -0,0 +1,100 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +type Node = { + [key: string]: unknown + type: string +} + +const bannedBrowserStorageNames = new Set(['indexedDB', 'localStorage', 'sessionStorage']) + +const isNode = (value: unknown): value is Node => + typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string' + +const getStorageIdentifierName = (node: unknown) => + isNode(node) && + node.type === 'Identifier' && + typeof node.name === 'string' && + bannedBrowserStorageNames.has(node.name) + ? node.name + : undefined + +const messageFor = ({ name }: { name: string }) => + `Do not use ${name} directly. Use Effect's IndexedDb or KeyValueStore modules instead.` + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Disallow direct browser storage APIs in favor of Effect storage modules.', + }, + }, + create(context) { + return { + Identifier(node) { + const storageName = getStorageIdentifierName(node) + + if (storageName === undefined) { + return + } + + const parent = isNode(node.parent) ? node.parent : undefined + + if ( + (parent?.type === 'Property' && + parent.key === node && + (!isNode(parent.parent) || parent.parent.type !== 'ObjectPattern') && + parent.value !== node) || + (parent?.type === 'MemberExpression' && parent.property === node && parent.computed !== true) || + (parent?.type === 'ImportSpecifier' && parent.imported === node && parent.local !== node) || + (parent?.type === 'MemberExpression' && parent.object === node) + ) { + return + } + + context.report({ + node, + message: messageFor({ name: storageName }), + }) + }, + MemberExpression(node) { + const storageName = + node.object.type === 'Identifier' ? getStorageIdentifierName(node.object) : undefined + + if (storageName !== undefined) { + context.report({ + node, + message: messageFor({ name: storageName }), + }) + return + } + + if ( + node.object.type === 'Identifier' && + (node.object.name === 'globalThis' || node.object.name === 'window') + ) { + const propertyName = + node.property.type === 'Identifier' + ? node.property.name + : node.property.type === 'Literal' && typeof node.property.value === 'string' + ? node.property.value + : undefined + + if (!bannedBrowserStorageNames.has(propertyName ?? '')) { + return + } + + context.report({ + node, + message: messageFor({ + name: propertyName ?? '', + }), + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-direct-fetch.ts b/tools/oxlint/automation/rules/no-direct-fetch.ts new file mode 100644 index 0000000..6046de6 --- /dev/null +++ b/tools/oxlint/automation/rules/no-direct-fetch.ts @@ -0,0 +1,44 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const message = 'Do not call fetch directly. Use the existing API client or runtime client service.' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid direct fetch calls; use the existing API client or runtime client service.', + }, + }, + create(context) { + return { + CallExpression(node) { + const callee = node.callee + + if (callee.type === 'Identifier' && callee.name === 'fetch') { + context.report({ + node, + message, + }) + return + } + + if ( + callee.type === 'MemberExpression' && + callee.property.type === 'Identifier' && + callee.property.name === 'fetch' && + callee.object.type === 'Identifier' && + (callee.object.name === 'window' || callee.object.name === 'globalThis') + ) { + context.report({ + node, + message, + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-direct-xstate-create-machine.ts b/tools/oxlint/automation/rules/no-direct-xstate-create-machine.ts new file mode 100644 index 0000000..2bfd34a --- /dev/null +++ b/tools/oxlint/automation/rules/no-direct-xstate-create-machine.ts @@ -0,0 +1,58 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const message = 'Do not use createMachine directly. Define machines with setup().createMachine().' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Create XState machines with setup().createMachine() to define their typed dependencies.', + }, + }, + create(context) { + const namespaceNames = new Set() + + return { + ImportDeclaration(node) { + if (node.source.value !== 'xstate') { + return + } + + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportNamespaceSpecifier') { + namespaceNames.add(specifier.local.name) + continue + } + + if ( + specifier.type === 'ImportSpecifier' && + specifier.imported.type === 'Identifier' && + specifier.imported.name === 'createMachine' + ) { + context.report({ + node: specifier, + message, + }) + } + } + }, + MemberExpression(node) { + if ( + node.object.type === 'Identifier' && + namespaceNames.has(node.object.name) && + node.property.type === 'Identifier' && + node.property.name === 'createMachine' + ) { + context.report({ + node, + message, + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-direct-xstate-use-selector.ts b/tools/oxlint/automation/rules/no-direct-xstate-use-selector.ts new file mode 100644 index 0000000..a878e1a --- /dev/null +++ b/tools/oxlint/automation/rules/no-direct-xstate-use-selector.ts @@ -0,0 +1,84 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +interface Options { + readonly preferredModule?: string + readonly selectorModules?: ReadonlyArray +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: + 'Import typed selector hooks from a repository-owned facade instead of using XState useSelector directly.', + }, + schema: [ + { + type: 'object', + properties: { + preferredModule: { type: 'string' }, + selectorModules: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const options = (context.options[0] ?? {}) as Options + const xstateSelectorModules = new Set(options.selectorModules ?? ['@xstate/react', '@xstate/store/react']) + const preferredModule = options.preferredModule ?? 'a repository-owned typed selector facade' + const message = `Use selector hooks from ${preferredModule} instead of XState useSelector directly.` + const isXstateSelectorModule = ({ value }: { value: unknown }) => + typeof value === 'string' && xstateSelectorModules.has(value) + const namespaceNames = new Set() + + return { + ImportDeclaration(node) { + if (!isXstateSelectorModule({ value: node.source.value })) { + return + } + + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportNamespaceSpecifier') { + namespaceNames.add(specifier.local.name) + continue + } + + if ( + specifier.type === 'ImportSpecifier' && + specifier.imported.type === 'Identifier' && + specifier.imported.name === 'useSelector' + ) { + context.report({ + node: specifier, + message, + }) + } + } + }, + MemberExpression(node) { + const isDirectNamespaceSelector = + node.object.type === 'Identifier' && + namespaceNames.has(node.object.name) && + node.property.type === 'Identifier' && + node.property.name === 'useSelector' + + if (!isDirectNamespaceSelector) { + return + } + + context.report({ + node, + message, + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-effect-asvoid.ts b/tools/oxlint/automation/rules/no-effect-asvoid.ts new file mode 100644 index 0000000..c7b4b20 --- /dev/null +++ b/tools/oxlint/automation/rules/no-effect-asvoid.ts @@ -0,0 +1,33 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const message = 'Avoid Effect.asVoid. Prefer returning the effect directly when the success type is void.' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid Effect.asVoid; return effects whose success type is already void directly.', + }, + }, + create(context) { + return { + MemberExpression(node) { + if ( + node.object.type === 'Identifier' && + node.object.name === 'Effect' && + node.property.type === 'Identifier' && + node.property.name === 'asVoid' + ) { + context.report({ + node, + message, + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-fixed-height-on-content.ts b/tools/oxlint/automation/rules/no-fixed-height-on-content.ts new file mode 100644 index 0000000..fe7ccc9 --- /dev/null +++ b/tools/oxlint/automation/rules/no-fixed-height-on-content.ts @@ -0,0 +1,286 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +interface Options { + readonly allowedHeightUtilities?: ReadonlyArray + readonly ignoredPathMarkers?: ReadonlyArray +} +type VisitorObject = ReturnType> +type JSXOpeningElementNode = Parameters>[0] +type JSXElementNode = Extract +type JSXChildNode = JSXElementNode['children'][number] + +const contentIntrinsicElements = new Set([ + 'a', + 'button', + 'dd', + 'dt', + 'figcaption', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'label', + 'legend', + 'li', + 'p', + 'summary', +]) + +const directContentElements = new Set(['article', 'div', 'footer', 'header', 'main', 'section', 'span']) + +const fixedHeightControlElements = new Set(['input', 'select', 'textarea']) + +const visualIntrinsicElements = new Set(['canvas', 'iframe', 'img', 'picture', 'svg', 'video']) + +const mediaIntrinsicElements = new Set(['canvas', 'img', 'picture', 'video']) + +const relativeHeightUtilities = new Set([ + 'h-auto', + 'h-dvh', + 'h-fit', + 'h-full', + 'h-lvh', + 'h-max', + 'h-min', + 'h-screen', + 'h-svh', + 'size-auto', + 'size-fit', + 'size-full', + 'size-max', + 'size-min', +]) + +const semanticGeometricHeightUtilities = new Set(['h-chart', 'h-panel', 'h-skeleton-line', 'h-table-row']) + +const contentComponentName = + /(?:Badge|Button|Chip|Description|Heading|Label|Link|ListBoxItem|MenuItem|Option|Paragraph|Tag|Text|Title)$/ + +const fixedHeightControlComponentName = /(?:ComboBox|Input|SearchBar|SearchField|Select|TextArea|TextField)$/ + +const fixedHeightControlComponentNames = new Set(['Group']) + +const visualComponentName = /(?:Avatar|Icon|Illustration|Image|Logo|Spinner|Svg)$/ + +const mediaComponentName = /(?:Avatar|Illustration|Image)$/ + +const _elementName = ({ node }: { node: JSXOpeningElementNode }) => + node.name.type === 'JSXIdentifier' ? node.name.name : undefined + +const _hasAttribute = ({ name, node }: { name: string; node: JSXOpeningElementNode }) => + node.attributes.some( + (attribute) => + attribute.type === 'JSXAttribute' && + attribute.name.type === 'JSXIdentifier' && + attribute.name.name === name, + ) + +const _isVisibleExpression = ({ node }: { node: JSXChildNode }) => + node.type === 'JSXExpressionContainer' && node.expression.type !== 'JSXEmptyExpression' + +const _hasTextualContent = ({ children }: { children: JSXElementNode['children'] }): boolean => + children.some((child) => { + if (child.type === 'JSXText') { + return child.value.trim().length > 0 + } + + if (_isVisibleExpression({ node: child })) { + return true + } + + if (child.type === 'JSXFragment') { + return _hasTextualContent({ children: child.children }) + } + + if (child.type !== 'JSXElement') { + return false + } + + const name = _elementName({ node: child.openingElement }) + + if (name !== undefined && (visualIntrinsicElements.has(name) || visualComponentName.test(name))) { + return false + } + + return _hasTextualContent({ children: child.children }) + }) + +const _hasDirectTextualContent = ({ children }: { children: JSXElementNode['children'] }) => + children.some((child) => child.type === 'JSXText' && child.value.trim().length > 0) + +const _hasConfidentTextualContent = ({ children }: { children: JSXElementNode['children'] }): boolean => + children.some((child) => { + if (child.type === 'JSXText') { + return child.value.trim().length > 0 + } + + if (child.type === 'JSXFragment') { + return _hasConfidentTextualContent({ children: child.children }) + } + + if (child.type !== 'JSXElement') { + return false + } + + const name = _elementName({ node: child.openingElement }) + + if (name !== undefined && (visualIntrinsicElements.has(name) || visualComponentName.test(name))) { + return false + } + + return _hasConfidentTextualContent({ children: child.children }) + }) + +const _hasMediaContent = ({ children }: { children: JSXElementNode['children'] }): boolean => + children.some((child) => { + if (child.type === 'JSXFragment') { + return _hasMediaContent({ children: child.children }) + } + + if (child.type !== 'JSXElement') { + return false + } + + const name = _elementName({ node: child.openingElement }) + + if (name !== undefined && (mediaIntrinsicElements.has(name) || mediaComponentName.test(name))) { + return true + } + + return _hasMediaContent({ children: child.children }) + }) + +const _utilityName = ({ token }: { token: string }) => token.split(':').at(-1)?.replace(/^!/, '') + +const _fixedHeightUtilities = ({ + allowedHeightUtilities, + source, +}: { + allowedHeightUtilities: ReadonlySet + source: string +}) => { + const utilities = new Set() + + for (const match of source.matchAll(/[^\s"'`(){}>,]+/g)) { + const token = match[0] + const utility = _utilityName({ token }) + + if ( + utility === undefined || + relativeHeightUtilities.has(utility) || + allowedHeightUtilities.has(utility) || + (!utility.startsWith('h-') && !utility.startsWith('size-')) + ) { + continue + } + + utilities.add(token) + } + + return [...utilities] +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Require content-bearing web elements to derive their height from typography and padding.', + }, + schema: [ + { + type: 'object', + properties: { + allowedHeightUtilities: { + type: 'array', + items: { type: 'string' }, + }, + ignoredPathMarkers: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const filename = (context.filename ?? '').replaceAll('\\', '/') + const options = (context.options[0] ?? {}) as Options + const allowedHeightUtilities = new Set([ + ...semanticGeometricHeightUtilities, + ...(options.allowedHeightUtilities ?? []), + ]) + const ignoredPathMarkers = options.ignoredPathMarkers ?? ['/native/'] + + if (!filename.endsWith('.tsx') || ignoredPathMarkers.some((marker) => filename.includes(marker))) { + return {} + } + + return { + JSXOpeningElement(node) { + const name = _elementName({ node }) + const element = node.parent + + if ( + name === undefined || + element.type !== 'JSXElement' || + _hasAttribute({ name: 'aria-hidden', node }) || + visualIntrinsicElements.has(name) || + visualComponentName.test(name) || + ((name === 'a' || name === 'Link') && _hasMediaContent({ children: element.children })) + ) { + return + } + + const isContentBearing = + contentIntrinsicElements.has(name) || + fixedHeightControlElements.has(name) || + fixedHeightControlComponentName.test(name) || + fixedHeightControlComponentNames.has(name) || + (directContentElements.has(name) && _hasDirectTextualContent({ children: element.children })) || + (contentComponentName.test(name) && _hasTextualContent({ children: element.children })) + + if (!isContentBearing) { + return + } + + for (const attribute of node.attributes) { + if ( + attribute.type !== 'JSXAttribute' || + attribute.name.type !== 'JSXIdentifier' || + attribute.name.name !== 'className' + ) { + continue + } + + const utilities = _fixedHeightUtilities({ + allowedHeightUtilities, + source: context.sourceCode.getText(attribute), + }).filter((token) => { + const utility = _utilityName({ token }) + + return ( + utility?.startsWith('size-') !== true || + (!_hasAttribute({ name: 'aria-label', node }) && + _hasConfidentTextualContent({ children: element.children })) + ) + }) + + if (utilities.length === 0) { + continue + } + + context.report({ + node: attribute, + message: `Content-bearing elements should derive their height from line-height and vertical padding instead of ${utilities.join(', ')}. Use min-h-* for a required minimum or an approved geometric utility when the fixed height is intentional.`, + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-global-json.ts b/tools/oxlint/automation/rules/no-global-json.ts new file mode 100644 index 0000000..9cbb4dd --- /dev/null +++ b/tools/oxlint/automation/rules/no-global-json.ts @@ -0,0 +1,67 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +type Node = { + readonly [key: string]: unknown + readonly type: string +} + +const message = 'Do not use the global JSON API. Use Effect Schema encode/decode APIs instead.' + +const _isNode = (value: unknown): value is Node => + typeof value === 'object' && + value !== null && + Object.hasOwn(value, 'type') && + typeof Reflect.get(value, 'type') === 'string' + +const _nodeField = ({ field, node }: { field: string; node: Node }) => node[field] + +const _isIdentifier = ({ name, node }: { name: string; node: unknown }) => + _isNode(node) && node.type === 'Identifier' && _nodeField({ field: 'name', node }) === name + +const _isGlobalJson = (node: unknown): boolean => { + if (_isIdentifier({ name: 'JSON', node })) { + return true + } + + if (!_isNode(node) || node.type !== 'MemberExpression') { + return false + } + + return ( + _isIdentifier({ + name: 'globalThis', + node: _nodeField({ field: 'object', node }), + }) && + _isIdentifier({ + name: 'JSON', + node: _nodeField({ field: 'property', node }), + }) + ) +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid global JSON APIs; encode and decode JSON with Effect Schema.', + }, + }, + create(context) { + return { + MemberExpression(node) { + if (!_isGlobalJson(node.object)) { + return + } + + context.report({ + node, + message, + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-in-operator.ts b/tools/oxlint/automation/rules/no-in-operator.ts new file mode 100644 index 0000000..d353c54 --- /dev/null +++ b/tools/oxlint/automation/rules/no-in-operator.ts @@ -0,0 +1,30 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: + 'Avoid object key checks with the in operator; refactor the check or use Predicate as a last resort.', + }, + }, + create(context) { + return { + BinaryExpression(node) { + if (node.operator !== 'in') { + return + } + + context.report({ + node, + message: + 'Do not use the "in" operator to check for object keys. Fix or refactor the code so this key check is not needed. Only use Predicate as a last-resort escape hatch.', + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-jsx-style.ts b/tools/oxlint/automation/rules/no-jsx-style.ts new file mode 100644 index 0000000..5177e36 --- /dev/null +++ b/tools/oxlint/automation/rules/no-jsx-style.ts @@ -0,0 +1,51 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +interface Options { + readonly ignoredPathMarkers?: ReadonlyArray +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Disallow JSX style props in web code so styling stays in Tailwind theme utilities.', + }, + schema: [ + { + type: 'object', + properties: { + ignoredPathMarkers: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const filename = (context.filename ?? '').replaceAll('\\', '/') + const options = (context.options[0] ?? {}) as Options + const ignoredPathMarkers = options.ignoredPathMarkers ?? ['/native/'] + + if (!filename.endsWith('.tsx') || ignoredPathMarkers.some((marker) => filename.includes(marker))) { + return {} + } + + return { + JSXAttribute(node) { + if (node.name.type === 'JSXIdentifier' && node.name.name === 'style') { + context.report({ + node, + message: + 'Do not use JSX style props in web code. Use an existing Tailwind theme utility or the closest available theme value.', + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-multiple-function-params.ts b/tools/oxlint/automation/rules/no-multiple-function-params.ts new file mode 100644 index 0000000..4ba1655 --- /dev/null +++ b/tools/oxlint/automation/rules/no-multiple-function-params.ts @@ -0,0 +1,126 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type VisitorObject = ReturnType> +type FunctionDeclarationNode = Parameters>[0] +type FunctionExpressionNode = Parameters>[0] +type ArrowFunctionExpressionNode = Parameters>[0] +type FunctionNode = FunctionDeclarationNode | FunctionExpressionNode | ArrowFunctionExpressionNode + +interface Options { + readonly exemptFunctionNames?: ReadonlyArray + readonly exemptRouteBasenames?: ReadonlyArray +} + +const maxParams = 1 + +const httpMethods = new Set(['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT']) + +const message = 'Functions with more than one parameter must accept a single object parameter.' + +const _isCallback = (parent: { readonly type: string } | null | undefined) => + parent?.type === 'CallExpression' || parent?.type === 'NewExpression' || parent?.type === 'JSXExpressionContainer' + +const _isOwnedFunction = (node: FunctionNode) => { + if (node.type === 'FunctionDeclaration') { + return true + } + + const parent = node.parent + + if (_isCallback(parent)) { + return false + } + + return ( + parent?.type === 'VariableDeclarator' || + parent?.type === 'AssignmentExpression' || + parent?.type === 'ExportDefaultDeclaration' + ) +} + +const _isRouteFile = ({ basenames, filename }: { basenames: ReadonlySet; filename: string }) => + basenames.has(filename.replaceAll('\\', '/').split('/').at(-1) ?? '') + +const _identifierName = ({ node }: { node: { readonly name?: string; readonly type: string } | null }) => + node?.type === 'Identifier' ? node.name : undefined + +const _isExportedHttpRouteHandler = ({ + filename, + httpRouteFunctionNames, + routeBasenames, + node, +}: { + filename: string + httpRouteFunctionNames: ReadonlySet + routeBasenames: ReadonlySet + node: FunctionNode +}) => { + if (!_isRouteFile({ basenames: routeBasenames, filename }) || node.type !== 'FunctionDeclaration') { + return false + } + + const parent = node.parent + const name = _identifierName({ node: node.id }) + + return parent?.type === 'ExportNamedDeclaration' && name !== undefined && httpRouteFunctionNames.has(name) +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Require internally defined functions with multiple inputs to accept one object parameter.', + }, + schema: [ + { + type: 'object', + properties: { + exemptFunctionNames: { + type: 'array', + items: { type: 'string' }, + }, + exemptRouteBasenames: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const options = (context.options[0] ?? {}) as Options + const httpRouteFunctionNames = new Set(options.exemptFunctionNames ?? httpMethods) + const routeBasenames = new Set(options.exemptRouteBasenames ?? ['route.ts', 'route.tsx']) + const filename = context.filename ?? '' + + const check = (node: FunctionNode) => { + if ( + node.params.length <= maxParams || + !_isOwnedFunction(node) || + _isExportedHttpRouteHandler({ + filename, + httpRouteFunctionNames, + routeBasenames, + node, + }) + ) { + return + } + + context.report({ + node, + message, + }) + } + + return { + FunctionDeclaration: check, + FunctionExpression: check, + ArrowFunctionExpression: check, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-multiple-xstate-hooks.ts b/tools/oxlint/automation/rules/no-multiple-xstate-hooks.ts new file mode 100644 index 0000000..87e8f47 --- /dev/null +++ b/tools/oxlint/automation/rules/no-multiple-xstate-hooks.ts @@ -0,0 +1,136 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +type Component = { name: string; node: object } +type ParentNode = { + id?: unknown + parent?: ParentNode | null + type: string +} +type FunctionNode = ParentNode + +const xstateActorHooks = new Set(['useActor', 'useActorRef', 'useMachine']) + +const isComponentName = (name: string | undefined) => name !== undefined && /^[A-Z]/.test(name) + +const identifierName = ({ node }: { node: unknown }) => { + if (typeof node !== 'object' || node === null) { + return undefined + } + + if (!('name' in node) || typeof node.name !== 'string') { + return undefined + } + + return node.name +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: + 'Disallow multiple @xstate/react actor hooks in the same component. Compose machines with actors instead.', + }, + }, + create(context) { + const localHookNames = new Set() + const namespaceNames = new Set() + const componentHookCounts = new WeakMap() + const functionStack = new Array() + + const componentName = (node: FunctionNode): string | undefined => { + if (node.type === 'FunctionDeclaration') { + const name = identifierName({ node: node.id }) + + return isComponentName(name) ? name : undefined + } + + const parent = node.parent + + if (parent?.type === 'VariableDeclarator') { + const name = identifierName({ node: parent.id }) + + return isComponentName(name) ? name : undefined + } + + if (parent?.type === 'CallExpression') { + const variableDeclarator = parent.parent + + if (variableDeclarator?.type === 'VariableDeclarator') { + const name = identifierName({ node: variableDeclarator.id }) + + return isComponentName(name) ? name : undefined + } + } + + return undefined + } + + const enterFunction = (node: FunctionNode) => { + const name = componentName(node) + + functionStack.push(name === undefined ? undefined : { name, node }) + } + + const exitFunction = () => { + functionStack.pop() + } + + return { + ArrowFunctionExpression: enterFunction, + 'ArrowFunctionExpression:exit': exitFunction, + FunctionDeclaration: enterFunction, + 'FunctionDeclaration:exit': exitFunction, + FunctionExpression: enterFunction, + 'FunctionExpression:exit': exitFunction, + ImportDeclaration(node) { + if (node.source.value !== '@xstate/react') return + + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportNamespaceSpecifier') { + namespaceNames.add(specifier.local.name) + } else if ( + specifier.type === 'ImportSpecifier' && + specifier.imported.type === 'Identifier' && + xstateActorHooks.has(specifier.imported.name) + ) { + localHookNames.add(specifier.local.name) + } + } + }, + CallExpression(node) { + const callee = node.callee + const hookName = + callee.type === 'Identifier' && localHookNames.has(callee.name) + ? callee.name + : callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' && + callee.property.type === 'Identifier' && + namespaceNames.has(callee.object.name) && + xstateActorHooks.has(callee.property.name) + ? callee.property.name + : undefined + + if (hookName === undefined) return + + const component = functionStack.at(-1) + + if (component === undefined) return + + const currentCount = componentHookCounts.get(component.node) ?? 0 + componentHookCounts.set(component.node, currentCount + 1) + + if (currentCount === 0) return + + context.report({ + node: callee, + message: `${component.name} uses multiple @xstate/react actor hooks. Compose machines with actors instead.`, + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-nested-effect-array-methods.ts b/tools/oxlint/automation/rules/no-nested-effect-array-methods.ts new file mode 100644 index 0000000..3744e56 --- /dev/null +++ b/tools/oxlint/automation/rules/no-nested-effect-array-methods.ts @@ -0,0 +1,117 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +type Node = { + [key: string]: unknown + type: string +} + +const ignoredKeys = new Set(['parent']) + +const isNode = (value: unknown): value is Node => + typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string' + +const nodeName = ({ value }: { value: unknown }) => + isNode(value) && typeof value.name === 'string' ? value.name : undefined + +const isEffectArrayCall = ({ node }: { node: unknown }) => + isNode(node) && + node.type === 'CallExpression' && + isNode(node.callee) && + node.callee.type === 'MemberExpression' && + isNode(node.callee.object) && + isNode(node.callee.property) && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'Array' && + node.callee.property.type === 'Identifier' + +const containsEffectArrayCall = ({ node, seen }: { node: unknown; seen: WeakSet }): boolean => { + if (node === null || typeof node !== 'object') { + return false + } + + if (seen.has(node)) { + return false + } + + seen.add(node) + + if (isEffectArrayCall({ node })) { + return true + } + + for (const [key, value] of Object.entries(node)) { + if (ignoredKeys.has(key)) { + continue + } + + if (Array.isArray(value)) { + if (value.some((item) => containsEffectArrayCall({ node: item, seen }))) { + return true + } + + continue + } + + if (containsEffectArrayCall({ node: value, seen })) { + return true + } + } + + return false +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid nested Effect Array method calls; compose them with pipe to preserve inference.', + }, + }, + create(context) { + let arrayImportedFromEffect = false + + return { + ImportDeclaration(node) { + if ( + isNode(node) && + isNode(node.source) && + node.source.value === 'effect' && + Array.isArray(node.specifiers) && + node.specifiers + .filter(isNode) + .some( + (specifier) => + specifier.type === 'ImportSpecifier' && + nodeName({ value: specifier.imported }) === 'Array' && + nodeName({ value: specifier.local }) === 'Array', + ) + ) { + arrayImportedFromEffect = true + } + }, + CallExpression(node) { + if (!arrayImportedFromEffect || !isEffectArrayCall({ node })) { + return + } + + if ( + node.arguments.some((argument) => + containsEffectArrayCall({ + node: argument, + seen: new WeakSet(), + }), + ) + ) { + context.report({ + node, + message: 'Do not nest Effect Array method calls. Use pipe to preserve inference.', + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-nested-layer-provide.ts b/tools/oxlint/automation/rules/no-nested-layer-provide.ts new file mode 100644 index 0000000..ac1ceb1 --- /dev/null +++ b/tools/oxlint/automation/rules/no-nested-layer-provide.ts @@ -0,0 +1,53 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +type Node = { + [key: string]: unknown + type: string +} + +const isNode = (value: unknown): value is Node => + typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string' + +const isLayerProvide = (node: unknown) => + isNode(node) && + node.type === 'CallExpression' && + isNode(node.callee) && + node.callee.type === 'MemberExpression' && + isNode(node.callee.object) && + isNode(node.callee.property) && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'Layer' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'provide' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid nested Layer.provide calls; extract the inner layer or use Layer.provideMerge.', + }, + }, + create(context) { + return { + CallExpression(node) { + if (!isLayerProvide(node)) { + return + } + + for (const argument of node.arguments) { + if (isLayerProvide(argument)) { + context.report({ + node: argument, + message: + 'Avoid nested Layer.provide calls. Extract the inner layer or use Layer.provideMerge.', + }) + } + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-non-route-layout-files.ts b/tools/oxlint/automation/rules/no-non-route-layout-files.ts new file mode 100644 index 0000000..ad27f42 --- /dev/null +++ b/tools/oxlint/automation/rules/no-non-route-layout-files.ts @@ -0,0 +1,60 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +interface Options { + readonly allowedLayoutFilenames?: ReadonlyArray + readonly routeDirectoryNames?: ReadonlyArray +} + +const rule: Rule = { + meta: { + type: 'suggestion' as const, + docs: { + description: + 'Avoid non-route layout files in app folders; rename them or use the nearest Next.js layout.tsx.', + }, + schema: [ + { + type: 'object', + properties: { + allowedLayoutFilenames: { + type: 'array', + items: { type: 'string' }, + }, + routeDirectoryNames: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const options = (context.options[0] ?? {}) as Options + const allowedLayoutFilenames = new Set(options.allowedLayoutFilenames ?? ['layout.tsx', '_layout.tsx']) + const routeDirectoryNames = new Set(options.routeDirectoryNames ?? ['app']) + const filename = (context.filename ?? '').replaceAll('\\', '/') + const basename = filename.slice(filename.lastIndexOf('/') + 1).toLowerCase() + + if ( + !filename.split('/').some((segment) => routeDirectoryNames.has(segment)) || + !basename.includes('layout') || + allowedLayoutFilenames.has(basename) + ) { + return {} + } + + return { + Program(node) { + context.report({ + node, + message: 'Rename this file or use the nearest Next.js "layout.tsx" route primitive.', + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-optional-function-parameters.ts b/tools/oxlint/automation/rules/no-optional-function-parameters.ts new file mode 100644 index 0000000..d65e9d1 --- /dev/null +++ b/tools/oxlint/automation/rules/no-optional-function-parameters.ts @@ -0,0 +1,67 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +type Node = { + [key: string]: unknown + type: string +} + +const isNode = (value: unknown): value is Node => + typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string' + +const message = 'Optional function parameters are banned. Use an explicit union with undefined or null.' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid optional function parameters; use an explicit union with undefined or null.', + }, + }, + create(context) { + return { + FunctionDeclaration(node) { + for (const param of node.params) { + if ( + isNode(param) && + (param.optional === true || (isNode(param.parameter) && param.parameter.optional === true)) + ) { + context.report({ + node: param, + message, + }) + } + } + }, + FunctionExpression(node) { + for (const param of node.params) { + if ( + isNode(param) && + (param.optional === true || (isNode(param.parameter) && param.parameter.optional === true)) + ) { + context.report({ + node: param, + message, + }) + } + } + }, + ArrowFunctionExpression(node) { + for (const param of node.params) { + if ( + isNode(param) && + (param.optional === true || (isNode(param.parameter) && param.parameter.optional === true)) + ) { + context.report({ + node: param, + message, + }) + } + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-react-component-inner-functions.ts b/tools/oxlint/automation/rules/no-react-component-inner-functions.ts new file mode 100644 index 0000000..c094085 --- /dev/null +++ b/tools/oxlint/automation/rules/no-react-component-inner-functions.ts @@ -0,0 +1,266 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type VisitorObject = ReturnType> +type FunctionDeclarationNode = Parameters>[0] +type FunctionExpressionNode = Parameters>[0] +type ArrowFunctionExpressionNode = Parameters>[0] +type FunctionNode = FunctionDeclarationNode | FunctionExpressionNode | ArrowFunctionExpressionNode + +type Node = { + readonly [key: string]: unknown + readonly type: string +} + +const functionTypes = new Set(['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression']) + +const expressionWrapperTypes = new Set([ + 'ChainExpression', + 'ParenthesizedExpression', + 'TSAsExpression', + 'TSNonNullExpression', + 'TSSatisfiesExpression', + 'TSTypeAssertion', +]) + +const _isNode = (value: unknown): value is Node => + typeof value === 'object' && + value !== null && + Object.hasOwn(value, 'type') && + typeof Reflect.get(value, 'type') === 'string' + +const _nodeField = ({ field, node }: { field: string; node: Node }) => node[field] + +const _nodeArray = ({ field, node }: { field: string; node: Node }) => { + const value = _nodeField({ field, node }) + + return Array.isArray(value) ? value.filter(_isNode) : [] +} + +const _nodeName = ({ node }: { node: unknown }) => { + if (!_isNode(node)) { + return undefined + } + + const name = _nodeField({ field: 'name', node }) + + return typeof name === 'string' ? name : undefined +} + +const _nodeParent = ({ node }: { node: Node }) => { + const parent = _nodeField({ field: 'parent', node }) + + return _isNode(parent) ? parent : undefined +} + +const _nodeExpression = ({ node }: { node: Node }) => { + const expression = _nodeField({ field: 'expression', node }) + + return _isNode(expression) ? expression : undefined +} + +const _isTsxFile = ({ filename }: { filename: string }) => filename.endsWith('.tsx') + +const _isFunction = ({ node }: { node: Node | undefined }) => node !== undefined && functionTypes.has(node.type) + +const _isComponentName = ({ name }: { name: string | undefined }) => name !== undefined && /^[A-Z]/.test(name) + +const _componentName = ({ node }: { node: Node }): string | undefined => { + if (node.type === 'FunctionDeclaration') { + const name = _nodeName({ node: _nodeField({ field: 'id', node }) }) + + return _isComponentName({ name }) ? name : undefined + } + + const parent = _nodeParent({ node }) + + if (parent?.type === 'VariableDeclarator') { + const name = _nodeName({ node: _nodeField({ field: 'id', node: parent }) }) + + return _isComponentName({ name }) ? name : undefined + } + + if (parent?.type === 'CallExpression') { + const variableDeclarator = _nodeParent({ node: parent }) + + if (variableDeclarator?.type === 'VariableDeclarator') { + const name = _nodeName({ + node: _nodeField({ field: 'id', node: variableDeclarator }), + }) + + return _isComponentName({ name }) ? name : undefined + } + } + + return undefined +} + +const _owningComponent = ({ node }: { node: Node }) => { + let current = _nodeParent({ node }) + + while (current !== undefined) { + if (_isFunction({ node: current })) { + const name = _componentName({ node: current }) + + return name === undefined ? undefined : { name, node: current } + } + + current = _nodeParent({ node: current }) + } + + return undefined +} + +const _outerExpression = ({ node }: { node: Node }) => { + let current = node + let parent = _nodeParent({ node: current }) + + while ( + parent !== undefined && + expressionWrapperTypes.has(parent.type) && + _nodeExpression({ node: parent }) === current + ) { + current = parent + parent = _nodeParent({ node: current }) + } + + return current +} + +const _isAssignedValue = ({ node }: { node: Node }) => { + const parent = _nodeParent({ node }) + + return ( + (parent?.type === 'VariableDeclarator' && _nodeField({ field: 'init', node: parent }) === node) || + (parent?.type === 'AssignmentExpression' && _nodeField({ field: 'right', node: parent }) === node) + ) +} + +const _memberPropertyName = ({ node }: { node: Node }) => { + const property = _nodeField({ field: 'property', node }) + + return _isNode(property) && property.type === 'Identifier' ? _nodeName({ node: property }) : undefined +} + +const _isUseCallbackCall = ({ node }: { node: Node }) => { + const callee = _nodeField({ field: 'callee', node }) + + if (!_isNode(callee)) { + return false + } + + return ( + (callee.type === 'Identifier' && _nodeName({ node: callee }) === 'useCallback') || + (callee.type === 'MemberExpression' && _memberPropertyName({ node: callee }) === 'useCallback') + ) +} + +const _isUseCallbackValue = ({ node }: { node: Node }) => { + const expression = _outerExpression({ node }) + const callExpression = _nodeParent({ node: expression }) + const [firstArgument] = callExpression === undefined ? [] : _nodeArray({ field: 'arguments', node: callExpression }) + + if ( + callExpression?.type !== 'CallExpression' || + firstArgument !== expression || + !_isUseCallbackCall({ node: callExpression }) + ) { + return false + } + + return _isAssignedValue({ + node: _outerExpression({ node: callExpression }), + }) +} + +const _isComponentOwnedFunction = ({ node }: { node: Node }) => { + if (node.type === 'FunctionDeclaration') { + return true + } + + const expression = _outerExpression({ node }) + + return _isAssignedValue({ node: expression }) || _isUseCallbackValue({ node }) +} + +const _functionName = ({ node }: { node: Node }) => { + if (node.type === 'FunctionDeclaration') { + return _nodeName({ node: _nodeField({ field: 'id', node }) }) + } + + const expression = _outerExpression({ node }) + const parent = _nodeParent({ node: expression }) + + if (parent?.type === 'VariableDeclarator') { + return _nodeName({ node: _nodeField({ field: 'id', node: parent }) }) + } + + if (parent?.type === 'AssignmentExpression') { + return _nodeName({ node: _nodeField({ field: 'left', node: parent }) }) + } + + if (parent?.type === 'CallExpression' && _isUseCallbackCall({ node: parent })) { + const callbackOwner = _nodeParent({ + node: _outerExpression({ node: parent }), + }) + + return ( + _nodeName({ + node: callbackOwner === undefined ? undefined : _nodeField({ field: 'id', node: callbackOwner }), + }) ?? + _nodeName({ + node: callbackOwner === undefined ? undefined : _nodeField({ field: 'left', node: callbackOwner }), + }) + ) + } + + return undefined +} + +const _message = ({ name }: { name: string | undefined }) => + name === undefined + ? 'Move this component-local function out of the component. If it contains business logic, model it in a state machine or actor instead. Avoid inlining the function into the component body unless no better boundary is possible.' + : `Move "${name}" out of the component. If it contains business logic, model it in a state machine or actor instead. Avoid inlining the function into the component body unless no better boundary is possible.` + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Move named component-local functions to a state machine, actor, or external helper.', + }, + }, + create(context) { + const filename = context.filename ?? '' + + if (!_isTsxFile({ filename })) { + return {} + } + + const check_ = (node: FunctionNode) => { + if (!_isNode(node)) { + return + } + + if ( + _nodeField({ field: 'body', node }) === undefined || + _owningComponent({ node }) === undefined || + !_isComponentOwnedFunction({ node }) + ) { + return + } + + context.report({ + node, + message: _message({ name: _functionName({ node }) }), + }) + } + + return { + ArrowFunctionExpression: check_, + FunctionDeclaration: check_, + FunctionExpression: check_, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-react-non-component-function-exports.ts b/tools/oxlint/automation/rules/no-react-non-component-function-exports.ts new file mode 100644 index 0000000..f5dff7a --- /dev/null +++ b/tools/oxlint/automation/rules/no-react-non-component-function-exports.ts @@ -0,0 +1,575 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type VisitorObject = ReturnType> +type ProgramNode = Parameters>[0] + +type Node = { + readonly [key: string]: unknown + readonly type: string +} + +type RangedNode = Node & { + readonly range: [number, number] +} + +type FunctionBinding = { + readonly name: string + readonly node: Node + readonly value: Node +} + +type FunctionExport = { + readonly binding: FunctionBinding | undefined + readonly name: string | undefined + readonly node: RangedNode +} + +interface Options { + readonly frameworkSegmentBasenames?: ReadonlyArray + readonly frameworkSpecialExports?: ReadonlyArray +} + +const functionExpressionTypes = new Set(['ArrowFunctionExpression', 'FunctionExpression']) + +const expressionWrapperTypes = new Set([ + 'ChainExpression', + 'ParenthesizedExpression', + 'TSAsExpression', + 'TSNonNullExpression', + 'TSSatisfiesExpression', + 'TSTypeAssertion', +]) + +const componentWrapperNames = new Set(['forwardRef', 'memo']) + +let frameworkSpecialFunctionExports: ReadonlySet = new Set([ + 'generateImageMetadata', + 'generateMetadata', + 'generateSitemaps', + 'generateStaticParams', + 'generateViewport', +]) + +const _isNode = (value: unknown): value is Node => + typeof value === 'object' && + value !== null && + Object.hasOwn(value, 'type') && + typeof Reflect.get(value, 'type') === 'string' + +const _isRangedNode = (value: unknown): value is RangedNode => + _isNode(value) && + Array.isArray(value.range) && + value.range.length === 2 && + typeof value.range[0] === 'number' && + typeof value.range[1] === 'number' + +const _nodeField = ({ field, node }: { field: string; node: Node }) => node[field] + +const _nodeArray = ({ field, node }: { field: string; node: Node }) => { + const value = _nodeField({ field, node }) + + return Array.isArray(value) ? value.filter(_isNode) : [] +} + +const _nodeName = ({ node }: { node: unknown }) => { + if (!_isNode(node)) { + return undefined + } + + const name = _nodeField({ field: 'name', node }) + + return typeof name === 'string' ? name : undefined +} + +const _nodeValue = ({ node }: { node: unknown }) => (_isNode(node) ? _nodeField({ field: 'value', node }) : undefined) + +const _nodeExpression = ({ node }: { node: Node }) => { + const expression = _nodeField({ field: 'expression', node }) + + return _isNode(expression) ? expression : undefined +} + +const _isTsxFile = ({ filename }: { filename: string }) => filename.endsWith('.tsx') + +const _isPascalCase = ({ name }: { name: string | undefined }) => name !== undefined && /^[A-Z]/.test(name) + +let frameworkSegmentBasenames: ReadonlySet = new Set(['page.tsx', 'layout.tsx']) + +const _isFrameworkSegmentFile = ({ filename }: { filename: string }) => + frameworkSegmentBasenames.has(filename.replaceAll('\\', '/').split('/').at(-1) ?? '') + +const _isNextAppRouterSpecialFunctionExport = ({ + exportedFunction, + filename, +}: { + exportedFunction: FunctionExport + filename: string +}) => { + if (!_isFrameworkSegmentFile({ filename })) { + return false + } + + const name = exportedFunction.binding?.name ?? exportedFunction.name + + return name !== undefined && frameworkSpecialFunctionExports.has(name) +} + +const _bindingIdentifierName = ({ node }: { node: unknown }) => + _isNode(node) && node.type === 'Identifier' ? _nodeName({ node }) : undefined + +const _memberPropertyName = ({ node }: { node: Node }) => { + const property = _nodeField({ field: 'property', node }) + const propertyValue = _nodeValue({ node: property }) + + if (_isNode(property) && property.type === 'Identifier') { + return _nodeName({ node: property }) + } + + return typeof propertyValue === 'string' ? propertyValue : undefined +} + +const _innerExpression = ({ node }: { node: Node }) => { + let current = node + let expression = _nodeExpression({ node: current }) + + while (expressionWrapperTypes.has(current.type) && expression !== undefined) { + current = expression + expression = _nodeExpression({ node: current }) + } + + return current +} + +const _isFunctionExpression = ({ node }: { node: Node }) => functionExpressionTypes.has(_innerExpression({ node }).type) + +const _componentWrapperName = ({ node }: { node: Node }) => { + if (node.type === 'Identifier') { + const name = _nodeName({ node }) + + return name !== undefined && componentWrapperNames.has(name) ? name : undefined + } + + if (node.type !== 'MemberExpression') { + return undefined + } + + return _memberPropertyName({ node }) +} + +const _isComponentWrapperCall = ({ node }: { node: Node }) => { + if (node.type !== 'CallExpression') { + return false + } + + const callee = _nodeField({ field: 'callee', node }) + + if (!_isNode(callee)) { + return false + } + + const name = _componentWrapperName({ node: callee }) + + return name !== undefined && componentWrapperNames.has(name) +} + +const _isFunctionValue = ({ + bindings, + node, +}: { + bindings: ReadonlyMap | undefined + node: Node +}): boolean => { + const expression = _innerExpression({ node }) + const name = _nodeName({ node: expression }) + + if (expression.type === 'Identifier' && name !== undefined) { + return bindings?.has(name) ?? false + } + + if (expression.type === 'FunctionDeclaration') { + return true + } + + if (_isFunctionExpression({ node: expression })) { + return true + } + + if (!_isComponentWrapperCall({ node: expression })) { + return false + } + + const [argument] = _nodeArray({ field: 'arguments', node: expression }) + + return argument !== undefined && _isFunctionValue({ bindings, node: argument }) +} + +const _functionBindingFromDeclaration = ({ node }: { node: Node }): FunctionBinding | undefined => { + if (node.type !== 'FunctionDeclaration') { + return undefined + } + + const name = _bindingIdentifierName({ + node: _nodeField({ field: 'id', node }), + }) + + return name === undefined ? undefined : { name, node, value: node } +} + +const _functionBindingFromDeclarator = ({ + bindings, + node, +}: { + bindings: ReadonlyMap | undefined + node: Node +}): FunctionBinding | undefined => { + const name = _bindingIdentifierName({ + node: _nodeField({ field: 'id', node }), + }) + const init = _nodeField({ field: 'init', node }) + + if (name === undefined || !_isNode(init) || !_isFunctionValue({ bindings, node: init })) { + return undefined + } + + return { name, node, value: init } +} + +const _functionBindingFromExpression = ({ + bindings, + node, +}: { + bindings: ReadonlyMap + node: Node +}): FunctionBinding | undefined => { + const expression = _innerExpression({ node }) + const name = _nodeName({ node: expression }) + + if (expression.type === 'Identifier' && name !== undefined) { + return bindings.get(name) + } + + if (!_isComponentWrapperCall({ node: expression })) { + return undefined + } + + const [argument] = _nodeArray({ field: 'arguments', node: expression }) + + return argument === undefined ? undefined : _functionBindingFromExpression({ bindings, node: argument }) +} + +const _functionNameFromExpression = ({ + bindings, + node, +}: { + bindings: ReadonlyMap + node: Node +}): string | undefined => { + const binding = _functionBindingFromExpression({ bindings, node }) + + if (binding !== undefined) { + return binding.name + } + + const expression = _innerExpression({ node }) + + if (expression.type === 'FunctionDeclaration' || expression.type === 'FunctionExpression') { + return _bindingIdentifierName({ + node: _nodeField({ field: 'id', node: expression }), + }) + } + + if (!_isComponentWrapperCall({ node: expression })) { + return undefined + } + + const [argument] = _nodeArray({ field: 'arguments', node: expression }) + + return argument === undefined ? undefined : _functionNameFromExpression({ bindings, node: argument }) +} + +const _addTopLevelBinding = ({ bindings, node }: { bindings: Map; node: Node }) => { + const binding = + node.type === 'FunctionDeclaration' + ? _functionBindingFromDeclaration({ node }) + : node.type === 'VariableDeclarator' + ? _functionBindingFromDeclarator({ bindings, node }) + : undefined + + if (binding === undefined || bindings.has(binding.name)) { + return false + } + + bindings.set(binding.name, binding) + + return true +} + +const _topLevelDeclarations = ({ node }: { node: Node }) => { + const declarations: Array = [] + + for (const statement of _nodeArray({ field: 'body', node })) { + const source = _nodeField({ field: 'source', node: statement }) + const declaration = _nodeField({ field: 'declaration', node: statement }) + + if ( + statement.type === 'ExportNamedDeclaration' && + (source === null || source === undefined) && + _isNode(declaration) + ) { + declarations.push(declaration) + continue + } + + declarations.push(statement) + } + + return declarations +} + +const _addTopLevelDeclarationBindings = ({ + bindings, + node, +}: { + bindings: Map + node: Node +}) => { + if (node.type === 'FunctionDeclaration') { + return _addTopLevelBinding({ bindings, node }) + } + + if (node.type !== 'VariableDeclaration') { + return false + } + + let added = false + + for (const declaration of _nodeArray({ field: 'declarations', node })) { + if (_addTopLevelBinding({ bindings, node: declaration })) { + added = true + } + } + + return added +} + +const _topLevelFunctionBindings = ({ node }: { node: Node }) => { + const bindings = new Map() + const declarations = _topLevelDeclarations({ node }) + let added = true + + while (added) { + added = false + + for (const declaration of declarations) { + if (_addTopLevelDeclarationBindings({ bindings, node: declaration })) { + added = true + } + } + } + + return bindings +} + +const _functionExport = ({ + binding, + name, + node, +}: { + binding: FunctionBinding | undefined + name: string | undefined + node: Node +}) => (_isRangedNode(node) ? [{ binding, name, node }] : []) + +const _exportsFromDeclaration = ({ + bindings, + declaration, + node, +}: { + bindings: ReadonlyMap + declaration: Node | undefined + node: Node +}) => { + if (declaration?.type === 'FunctionDeclaration') { + const binding = _functionBindingFromDeclaration({ node: declaration }) + + return binding === undefined ? [] : _functionExport({ binding, name: binding.name, node }) + } + + if (declaration?.type !== 'VariableDeclaration') { + return [] + } + + return _nodeArray({ field: 'declarations', node: declaration }).flatMap((variable) => { + const binding = _functionBindingFromDeclarator({ + bindings, + node: variable, + }) + + return binding === undefined ? [] : _functionExport({ binding, name: binding.name, node }) + }) +} + +const _exportsFromSpecifier = ({ bindings, node }: { bindings: ReadonlyMap; node: Node }) => { + const name = _bindingIdentifierName({ + node: _nodeField({ field: 'local', node }), + }) + const binding = name === undefined ? undefined : bindings.get(name) + + return binding === undefined ? [] : _functionExport({ binding, name, node }) +} + +const _exportsFromDefaultDeclaration = ({ + bindings, + node, +}: { + bindings: ReadonlyMap + node: Node +}) => { + const declaration = _nodeField({ field: 'declaration', node }) + + if (!_isNode(declaration)) { + return [] + } + + if (declaration.type === 'Identifier') { + const name = _nodeName({ node: declaration }) + const binding = name === undefined ? undefined : bindings.get(name) + + return binding === undefined ? [] : _functionExport({ binding, name, node }) + } + + if (!_isFunctionValue({ bindings, node: declaration })) { + return [] + } + + const binding = _functionBindingFromExpression({ + bindings, + node: declaration, + }) + const name = binding?.name ?? _functionNameFromExpression({ bindings, node: declaration }) + + return _functionExport({ binding, name, node }) +} + +const _functionExports = ({ node }: { node: Node }) => { + const bindings = _topLevelFunctionBindings({ node }) + const exports: Array = [] + + for (const statement of _nodeArray({ field: 'body', node })) { + if (statement.type === 'ExportDefaultDeclaration') { + exports.push(..._exportsFromDefaultDeclaration({ bindings, node: statement })) + continue + } + + if (statement.type !== 'ExportNamedDeclaration') { + continue + } + + const source = _nodeField({ field: 'source', node: statement }) + + if (source !== null && source !== undefined) { + continue + } + + const declaration = _nodeField({ field: 'declaration', node: statement }) + + exports.push( + ..._exportsFromDeclaration({ + bindings, + declaration: _isNode(declaration) ? declaration : undefined, + node: statement, + }), + ) + + for (const specifier of _nodeArray({ + field: 'specifiers', + node: statement, + })) { + exports.push(..._exportsFromSpecifier({ bindings, node: specifier })) + } + } + + return exports +} + +const _componentName = ({ exportedFunction }: { exportedFunction: FunctionExport }) => + exportedFunction.binding?.name ?? exportedFunction.name + +const _isComponentExport = ({ exportedFunction }: { exportedFunction: FunctionExport }) => + _isPascalCase({ name: _componentName({ exportedFunction }) }) + +const _message = ({ name }: { name: string | undefined }) => + name === undefined + ? 'Move this exported function out of the TSX file. TSX files should export components only.' + : `Move "${name}" out of the TSX file. TSX files should export components only.` + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Export only components from TSX files; move other exported functions to a non-TSX module.', + }, + schema: [ + { + type: 'object', + properties: { + frameworkSegmentBasenames: { + type: 'array', + items: { type: 'string' }, + }, + frameworkSpecialExports: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const options = (context.options[0] ?? {}) as Options + frameworkSegmentBasenames = new Set(options.frameworkSegmentBasenames ?? ['page.tsx', 'layout.tsx']) + frameworkSpecialFunctionExports = new Set( + options.frameworkSpecialExports ?? [ + 'generateImageMetadata', + 'generateMetadata', + 'generateSitemaps', + 'generateStaticParams', + 'generateViewport', + ], + ) + const filename = context.filename ?? '' + + if (!_isTsxFile({ filename })) { + return {} + } + + return { + Program(node: ProgramNode) { + if (!_isNode(node)) { + return + } + + for (const exportedFunction of _functionExports({ node })) { + if ( + _isComponentExport({ exportedFunction }) || + _isNextAppRouterSpecialFunctionExport({ + exportedFunction, + filename, + }) + ) { + continue + } + + context.report({ + node: exportedFunction.node, + message: _message({ name: _componentName({ exportedFunction }) }), + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-react-state-hooks.ts b/tools/oxlint/automation/rules/no-react-state-hooks.ts new file mode 100644 index 0000000..1d8728f --- /dev/null +++ b/tools/oxlint/automation/rules/no-react-state-hooks.ts @@ -0,0 +1,62 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const bannedHooks = new Set(['useEffect', 'useReducer', 'useState']) + +const message = (hookName: string) => `${hookName} is banned. Use xstate actors instead.` + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid React state and effect hooks; model component behavior with XState actors.', + }, + }, + create(context) { + const bannedImportedNames = new Set() + + return { + ImportDeclaration(node) { + if (node.source.value !== 'react') return + + for (const specifier of node.specifiers) { + if (specifier.type !== 'ImportSpecifier' || specifier.imported.type !== 'Identifier') { + continue + } + + const importedName = specifier.imported.name + const localName = specifier.local.name + + if (importedName !== undefined && localName !== undefined && bannedHooks.has(importedName)) { + bannedImportedNames.add(localName) + } + } + }, + CallExpression(node) { + const callee = node.callee + + if ( + callee.type === 'Identifier' && + (bannedImportedNames.has(callee.name) || bannedHooks.has(callee.name)) + ) { + context.report({ + node: callee, + message: message(callee.name), + }) + } else if ( + callee.type === 'MemberExpression' && + callee.property.type === 'Identifier' && + bannedHooks.has(callee.property.name) + ) { + context.report({ + node: callee, + message: message(callee.property.name), + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-reexport-only-modules.ts b/tools/oxlint/automation/rules/no-reexport-only-modules.ts new file mode 100644 index 0000000..0e88dd2 --- /dev/null +++ b/tools/oxlint/automation/rules/no-reexport-only-modules.ts @@ -0,0 +1,72 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +interface Options { + readonly allowedFilenames?: ReadonlyArray + readonly routeDirectoryNames?: ReadonlyArray +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Disallow modules whose only responsibility is re-exporting other modules.', + }, + schema: [ + { + type: 'object', + properties: { + allowedFilenames: { + type: 'array', + items: { type: 'string' }, + }, + routeDirectoryNames: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const options = (context.options[0] ?? {}) as Options + const allowedFilenames = new Set(options.allowedFilenames ?? ['loading.tsx', 'not-found.tsx']) + const routeDirectoryNames = new Set(options.routeDirectoryNames ?? ['app']) + const filename = (context.filename ?? '').replaceAll('\\', '/') + const segments = filename.split('/') + const basename = segments.at(-1) ?? '' + + if (segments.some((segment) => routeDirectoryNames.has(segment)) && allowedFilenames.has(basename)) { + return {} + } + + return { + Program(node) { + const statements = node.body.filter( + (statement) => statement.type !== 'ExpressionStatement' || typeof statement.directive !== 'string', + ) + + if ( + statements.length === 0 || + !statements.every( + (statement) => + statement.type === 'ExportAllDeclaration' || + (statement.type === 'ExportNamedDeclaration' && statement.source !== null), + ) + ) { + return + } + + context.report({ + node, + message: + 'Do not create re-export-only modules. Import from the owning module directly or add this intentional public entrypoint as an exact lint override.', + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-single-use-private-functions.ts b/tools/oxlint/automation/rules/no-single-use-private-functions.ts new file mode 100644 index 0000000..bd0a116 --- /dev/null +++ b/tools/oxlint/automation/rules/no-single-use-private-functions.ts @@ -0,0 +1,359 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +type Node = { + arguments?: unknown + body?: unknown + callee?: unknown + declaration?: unknown + declarations?: unknown + expression?: unknown + id?: unknown + init?: unknown + local?: unknown + name?: unknown + object?: unknown + parent?: Node | null + property?: unknown + source?: unknown + specifiers?: unknown + type: string + value?: unknown +} + +type CandidateKind = 'effect-function' | 'effect-program' | 'function' | 'type' + +type ReferenceLike = { + identifier: Node + isRead: () => boolean +} + +const functionExpressionTypes = new Set(['ArrowFunctionExpression', 'FunctionExpression']) + +const isNode = (value: unknown): value is Node => + typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string' + +const nodeList = ({ value }: { value: unknown }) => (Array.isArray(value) ? value.filter(isNode) : []) + +const nodeField = ({ field, node }: { field: keyof Node; node: Node }) => + isNode(node[field]) ? node[field] : undefined + +const isFunctionExpression = ({ node }: { node: unknown }) => isNode(node) && functionExpressionTypes.has(node.type) + +const isPascalCase = ({ name }: { name: string }) => /^[A-Z]/.test(name) + +const memberPropertyName = ({ node }: { node: Node }) => { + const property = nodeField({ field: 'property', node }) + + if (property?.type === 'Identifier' && typeof property.name === 'string') { + return property.name + } + + if (typeof property?.value === 'string') { + return property.value + } + + return undefined +} + +const effectCalleeName = ({ node }: { node: Node }) => { + const object = nodeField({ field: 'object', node }) + + if (node.type !== 'MemberExpression' || object?.type !== 'Identifier' || object.name !== 'Effect') { + return undefined + } + + return memberPropertyName({ node }) +} + +const isEffectCall = ({ name, node }: { name: 'fn' | 'fnUntraced' | 'gen'; node: unknown }) => + isNode(node) && + node.type === 'CallExpression' && + effectCalleeName({ node: nodeField({ field: 'callee', node }) ?? node }) === name + +const isEffectFunctionInitializer = ({ node }: { node: unknown }) => + isEffectCall({ name: 'fnUntraced', node }) || + isEffectCall({ name: 'fn', node }) || + (isNode(node) && + node.type === 'CallExpression' && + isEffectCall({ name: 'fn', node: nodeField({ field: 'callee', node }) })) + +const isEffectProgramInitializer = ({ node }: { node: unknown }) => isEffectCall({ name: 'gen', node }) + +const topLevelVariableKind = ({ node }: { node: Node }): CandidateKind | undefined => { + const id = nodeField({ field: 'id', node }) + + if ( + id?.type !== 'Identifier' || + node.parent?.type !== 'VariableDeclaration' || + node.parent.parent?.type !== 'Program' + ) { + return undefined + } + + if (isFunctionExpression({ node: node.init })) { + return 'function' + } + + if (isEffectFunctionInitializer({ node: node.init })) { + return 'effect-function' + } + + if (isEffectProgramInitializer({ node: node.init })) { + return 'effect-program' + } + + return undefined +} + +const addIdentifierName = ({ names, node }: { names: Set; node: unknown }) => { + if (isNode(node) && node.type === 'Identifier' && typeof node.name === 'string') { + names.add(node.name) + } +} + +const addDeclarationNames = ({ declaration, names }: { declaration: unknown; names: Set }) => { + if (!isNode(declaration)) { + return + } + + if (declaration.type === 'FunctionDeclaration') { + addIdentifierName({ + names, + node: nodeField({ field: 'id', node: declaration }), + }) + return + } + + if (declaration.type === 'VariableDeclaration') { + for (const variable of nodeList({ value: declaration.declarations })) { + addIdentifierName({ + names, + node: nodeField({ field: 'id', node: variable }), + }) + } + } + + if (declaration.type === 'TSTypeAliasDeclaration' || declaration.type === 'TSInterfaceDeclaration') { + addIdentifierName({ + names, + node: nodeField({ field: 'id', node: declaration }), + }) + } +} + +const exportedNames = ({ node }: { node: Node }) => { + const names = new Set() + + for (const statement of nodeList({ value: node.body })) { + if (statement.type === 'ExportDefaultDeclaration') { + const declaration = nodeField({ field: 'declaration', node: statement }) + + addIdentifierName({ names, node: declaration }) + addIdentifierName({ + names, + node: declaration === undefined ? undefined : nodeField({ field: 'id', node: declaration }), + }) + } + + if (statement.type === 'ExportNamedDeclaration') { + addDeclarationNames({ declaration: statement.declaration, names }) + + if (statement.source === null || statement.source === undefined) { + for (const specifier of nodeList({ value: statement.specifiers })) { + addIdentifierName({ + names, + node: nodeField({ field: 'local', node: specifier }), + }) + } + } + } + + if (statement.type === 'TSExportAssignment') { + addIdentifierName({ names, node: statement.expression }) + } + } + + return names +} + +const hasTypeAncestor = ({ node }: { node: Node }) => { + let current = node.parent + + while (current !== undefined && current !== null) { + if (current.type.startsWith('TS')) { + return true + } + + if (current.type === 'Program' || current.type === 'BlockStatement' || current.type === 'ExpressionStatement') { + return false + } + + current = current.parent + } + + return false +} + +const hasExportAncestor = ({ node }: { node: Node }) => { + let current = node.parent + + while (current !== undefined && current !== null) { + if ( + current.type === 'ExportDefaultDeclaration' || + current.type === 'ExportSpecifier' || + current.type === 'TSExportAssignment' + ) { + return true + } + + if (current.type === 'Program' || current.type === 'BlockStatement') { + return false + } + + current = current.parent + } + + return false +} + +const isRuntimeReadReference = ({ reference }: { reference: ReferenceLike }) => + reference.isRead() && + !hasTypeAncestor({ node: reference.identifier }) && + !hasExportAncestor({ node: reference.identifier }) + +const runtimeReadCount = ({ references }: { references: Array }) => + references.filter((reference) => isRuntimeReadReference({ reference })).length + +const isTypeReadReference = ({ reference }: { reference: ReferenceLike }) => + reference.isRead() && + hasTypeAncestor({ node: reference.identifier }) && + !hasExportAncestor({ node: reference.identifier }) + +const typeReadCount = ({ references }: { references: Array }) => + references.filter((reference) => isTypeReadReference({ reference })).length + +const singleUseCount = ({ + candidate, +}: { + candidate: { + kind: CandidateKind + variable: { + references: Array + } + } +}) => + candidate.kind === 'type' + ? typeReadCount({ references: candidate.variable.references }) + : runtimeReadCount({ references: candidate.variable.references }) + +const message = ({ kind, name }: { kind: CandidateKind; name: string }) => { + if (kind === 'effect-function') { + return `Inline the private Effect function "${name}" at its only usage site instead of defining it as a top-level function.` + } + + if (kind === 'effect-program') { + return `Inline the private Effect program "${name}" at its only usage site instead of defining it as a top-level value.` + } + + if (kind === 'type') { + return `Inline the private type "${name}" at its only usage site instead of defining it as a top-level type.` + } + + return `Inline the private function "${name}" at its only usage site instead of defining it as a top-level function.` +} + +const rule: Rule = { + meta: { + type: 'suggestion' as const, + docs: { + description: 'Inline private top-level functions, Effect values, and types referenced only once.', + }, + }, + create(context) { + return { + Program(node) { + type SourceNode = Parameters[0] + type ScopeVariable = ReturnType[number] + type Candidate = { + kind: CandidateKind + name: string + node: SourceNode + variable: ScopeVariable + } + + const exported = exportedNames({ node }) + const candidates = node.body.flatMap((statement): Array => { + if (statement.type === 'FunctionDeclaration') { + const name = statement.id?.name + + if (name === undefined || isPascalCase({ name }) || exported.has(name)) { + return [] + } + + const variable = context.sourceCode + .getDeclaredVariables(statement) + .find((item) => item.name === name) + + return variable === undefined ? [] : [{ kind: 'function', name, node: statement, variable }] + } + + if (statement.type === 'TSTypeAliasDeclaration' || statement.type === 'TSInterfaceDeclaration') { + const name = statement.id.name + + if (exported.has(name)) { + return [] + } + + const variable = context.sourceCode + .getDeclaredVariables(statement) + .find((item) => item.name === name) + + return variable === undefined ? [] : [{ kind: 'type', name, node: statement, variable }] + } + + if (statement.type === 'VariableDeclaration') { + return statement.declarations.flatMap((declaration): Array => { + const name = declaration.id.type === 'Identifier' ? declaration.id.name : undefined + const kind = topLevelVariableKind({ node: declaration }) + + if ( + name === undefined || + kind === undefined || + isPascalCase({ name }) || + exported.has(name) + ) { + return [] + } + + const variable = context.sourceCode + .getDeclaredVariables(declaration) + .find((item) => item.name === name) + + return variable === undefined ? [] : [{ kind, name, node: declaration, variable }] + }) + } + + return [] + }) + + for (const candidate of candidates) { + if (singleUseCount({ candidate }) !== 1) { + continue + } + + context.report({ + node: candidate.node, + message: message({ + kind: candidate.kind, + name: candidate.name, + }), + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-single-use-xstate-actions.ts b/tools/oxlint/automation/rules/no-single-use-xstate-actions.ts new file mode 100644 index 0000000..9c32f97 --- /dev/null +++ b/tools/oxlint/automation/rules/no-single-use-xstate-actions.ts @@ -0,0 +1,105 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +import { + collectSetupDefinitions, + countMachineReferences, + isNode, + nodeAt, + nodeList, + objectProperty, + propertyValue, + setupCallFromCreateMachineCall, + stringLiteralValue, +} from './xstate-single-use.ts' + +type Rule = Parameters[1] + +export const actionReferenceNames = ({ node }: { node: { type: string } }): Array => { + if (!isNode(node)) { + return [] + } + + const literalValue = stringLiteralValue({ node }) + + if (literalValue !== undefined) { + return [literalValue] + } + + if (node.type === 'ArrayExpression') { + return nodeList({ value: node.elements }).flatMap((element) => actionReferenceNames({ node: element })) + } + + const typeProperty = objectProperty({ name: 'type', node }) + const typeValue = typeProperty === undefined ? undefined : propertyValue({ node: typeProperty }) + + if (typeValue === undefined) { + return [] + } + + const reference = stringLiteralValue({ node: typeValue }) + + return reference === undefined ? [] : [reference] +} + +const rule: Rule = { + meta: { + type: 'suggestion' as const, + docs: { + description: 'Inline custom XState setup actions referenced only once in the machine config.', + }, + }, + create(context) { + return { + CallExpression(node) { + if (!isNode(node)) { + return + } + + const setupCall = setupCallFromCreateMachineCall({ node }) + + if (setupCall === undefined) { + return + } + + const definitions = collectSetupDefinitions({ + definitionPropertyName: 'actions', + node: setupCall, + }) + + if (definitions.length === 0) { + return + } + + const machineConfig = nodeAt({ index: 0, value: node.arguments }) + + if (machineConfig === undefined) { + return + } + + const definedNames = new Set(definitions.map((definition) => definition.name)) + const counts = new Map() + + countMachineReferences({ + counts, + definedNames, + node: machineConfig, + referenceNames: actionReferenceNames, + referencePropertyName: 'actions', + }) + + for (const definition of definitions) { + if ((counts.get(definition.name) ?? 0) !== 1) { + continue + } + + context.report({ + node: definition.node, + message: `Inline the "${definition.name}" XState action at its only usage site instead of defining it in setup actions.`, + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-single-use-xstate-guards.ts b/tools/oxlint/automation/rules/no-single-use-xstate-guards.ts new file mode 100644 index 0000000..d31a5c3 --- /dev/null +++ b/tools/oxlint/automation/rules/no-single-use-xstate-guards.ts @@ -0,0 +1,79 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +import { + collectSetupDefinitions, + countMachineReferences, + isNode, + nodeAt, + setupCallFromCreateMachineCall, + typedReferenceName, +} from './xstate-single-use.ts' + +type Rule = Parameters[1] + +const rule: Rule = { + meta: { + type: 'suggestion' as const, + docs: { + description: 'Inline custom XState setup guards referenced only once in the machine config.', + }, + }, + create(context) { + return { + CallExpression(node) { + if (!isNode(node)) { + return + } + + const setupCall = setupCallFromCreateMachineCall({ node }) + + if (setupCall === undefined) { + return + } + + const definitions = collectSetupDefinitions({ + definitionPropertyName: 'guards', + node: setupCall, + }) + + if (definitions.length === 0) { + return + } + + const machineConfig = nodeAt({ index: 0, value: node.arguments }) + + if (machineConfig === undefined) { + return + } + + const definedNames = new Set(definitions.map((definition) => definition.name)) + const counts = new Map() + + countMachineReferences({ + counts, + definedNames, + node: machineConfig, + referenceNames: ({ node: guard }) => { + const reference = typedReferenceName({ node: guard }) + + return reference === undefined ? [] : [reference] + }, + referencePropertyName: 'guard', + }) + + for (const definition of definitions) { + if ((counts.get(definition.name) ?? 0) !== 1) { + continue + } + + context.report({ + node: definition.node, + message: `Inline the "${definition.name}" XState guard at its only usage site instead of defining it in setup guards.`, + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-sql-type-parameter.ts b/tools/oxlint/automation/rules/no-sql-type-parameter.ts new file mode 100644 index 0000000..48f77d9 --- /dev/null +++ b/tools/oxlint/automation/rules/no-sql-type-parameter.ts @@ -0,0 +1,36 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid sql templates; use typed query APIs or validated schemas.', + }, + }, + create(context) { + return { + TaggedTemplateExpression(node) { + const isSqlTag = + (node.tag.type === 'Identifier' && node.tag.name === 'sql') || + (node.tag.type === 'MemberExpression' && + node.tag.property.type === 'Identifier' && + node.tag.property.name === 'sql') + const hasTypeParameters = + (node.typeArguments !== undefined && node.typeArguments !== null) || + ('typeParameters' in node && node.typeParameters !== undefined && node.typeParameters !== null) + + if (isSqlTag && hasTypeParameters) { + context.report({ + node, + message: + 'Do not use sql templates. Use typed Drizzle/D1 query APIs or validated schemas instead.', + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-static-effect-service-forwarders.ts b/tools/oxlint/automation/rules/no-static-effect-service-forwarders.ts new file mode 100644 index 0000000..88f0899 --- /dev/null +++ b/tools/oxlint/automation/rules/no-static-effect-service-forwarders.ts @@ -0,0 +1,297 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type VisitorObject = ReturnType> +type CallExpressionNode = Parameters>[0] +type ImportDeclarationNode = Parameters>[0] +type IdentifierNode = Parameters>[0] +type CallbackExpression = { + expression: unknown + parameterName: string | undefined +} + +const message = + 'Do not expose an Effect service method through a static forwarder. Yield the service at the usage site and call the method directly.' + +const _nodeName = ({ node }: { node: unknown }) => + typeof node === 'object' && node !== null && 'name' in node && typeof node.name === 'string' ? node.name : undefined + +const _isCallExpressionNode = (node: unknown): node is CallExpressionNode => + typeof node === 'object' && node !== null && 'type' in node && node.type === 'CallExpression' + +const _isIdentifierNode = (node: unknown): node is IdentifierNode => + typeof node === 'object' && node !== null && 'type' in node && node.type === 'Identifier' + +const _importedName = ({ specifier }: { specifier: ImportDeclarationNode['specifiers'][number] }) => + specifier.type === 'ImportSpecifier' ? _nodeName({ node: specifier.imported }) : undefined + +const _isStaticClassField = ({ node }: { node: CallExpressionNode }) => { + let current: unknown = node.parent + + while (typeof current === 'object' && current !== null && 'type' in current) { + if (current.type === 'PropertyDefinition') { + return 'static' in current && current.static === true + } + + if (current.type === 'Program') { + return false + } + + current = 'parent' in current ? current.parent : undefined + } + + return false +} + +const _callbackExpression = ({ node }: { node: unknown }): CallbackExpression | undefined => { + if (typeof node !== 'object' || node === null || !('type' in node)) { + return undefined + } + + if (node.type !== 'ArrowFunctionExpression' && node.type !== 'FunctionExpression') { + return undefined + } + + if (!('params' in node) || !Array.isArray(node.params)) { + return undefined + } + + const [parameter] = node.params + if ( + node.params.length !== 1 || + typeof parameter !== 'object' || + parameter === null || + !('type' in parameter) || + parameter.type !== 'Identifier' + ) { + return undefined + } + + if (!('body' in node) || typeof node.body !== 'object' || node.body === null) { + return undefined + } + + if (!('type' in node.body) || node.body.type !== 'BlockStatement') { + return { + expression: node.body, + parameterName: _nodeName({ node: parameter }), + } + } + + if (!('body' in node.body) || !Array.isArray(node.body.body)) { + return undefined + } + + const [statement] = node.body.body + if ( + node.body.body.length !== 1 || + typeof statement !== 'object' || + statement === null || + !('type' in statement) || + statement.type !== 'ReturnStatement' || + !('argument' in statement) + ) { + return undefined + } + + return { + expression: statement.argument, + parameterName: _nodeName({ node: parameter }), + } +} + +const _isDirectParameterMember = ({ + expression, + parameterName, +}: { + expression: unknown + parameterName: string | undefined +}) => { + if ( + parameterName === undefined || + typeof expression !== 'object' || + expression === null || + !('type' in expression) + ) { + return false + } + + const member = + expression.type === 'CallExpression' && + 'callee' in expression && + typeof expression.callee === 'object' && + expression.callee !== null + ? expression.callee + : expression + + return ( + 'type' in member && + member.type === 'MemberExpression' && + 'object' in member && + typeof member.object === 'object' && + member.object !== null && + 'type' in member.object && + member.object.type === 'Identifier' && + _nodeName({ node: member.object }) === parameterName + ) +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: + 'Disallow static Effect service method forwarders; acquire the service where its method is used.', + }, + }, + create(context) { + const effectNamespaceNames = new Set() + const flatMapNames = new Set() + const serviceNames = new Set() + const pipeNames = new Set() + + const isImportedIdentifier = ({ names, node }: { names: ReadonlySet; node: IdentifierNode }) => { + if (!names.has(node.name)) { + return false + } + + let scope: ReturnType | null = context.sourceCode.getScope(node) + + while (scope !== null) { + const variable = scope.set.get(node.name) + if (variable !== undefined) { + return variable.defs.some((definition) => definition.type === 'ImportBinding') + } + + scope = scope.upper + } + + return false + } + + const effectCall = ({ + name, + names, + node, + }: { + name: 'flatMap' | 'service' + names: ReadonlySet + node: unknown + }): CallExpressionNode | undefined => { + if (!_isCallExpressionNode(node)) { + return undefined + } + + if (_isIdentifierNode(node.callee)) { + return isImportedIdentifier({ + names, + node: node.callee, + }) + ? node + : undefined + } + + return node.callee.type === 'MemberExpression' && + _isIdentifierNode(node.callee.object) && + isImportedIdentifier({ + names: effectNamespaceNames, + node: node.callee.object, + }) && + _nodeName({ node: node.callee.property }) === name + ? node + : undefined + } + + const isServiceThisCall = ({ node }: { node: unknown }) => { + const call = effectCall({ name: 'service', names: serviceNames, node }) + return call !== undefined && call.arguments.length === 1 && call.arguments[0]?.type === 'ThisExpression' + } + + const isForwardingFlatMap = ({ node }: { node: unknown }) => { + const call = effectCall({ name: 'flatMap', names: flatMapNames, node }) + if (call === undefined) { + return false + } + + const callback = _callbackExpression({ node: call.arguments.at(-1) }) + return callback !== undefined && _isDirectParameterMember(callback) + } + + const isPipeCall = ({ node }: { node: CallExpressionNode }) => { + if (node.callee.type === 'Identifier') { + return isImportedIdentifier({ + names: pipeNames, + node: node.callee, + }) + } + + return node.callee.type === 'MemberExpression' && _nodeName({ node: node.callee.property }) === 'pipe' + } + + return { + ImportDeclaration(node) { + if (node.source.value === 'effect') { + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportSpecifier' && _importedName({ specifier }) === 'Effect') { + effectNamespaceNames.add(specifier.local.name) + } + + if (specifier.type === 'ImportSpecifier' && _importedName({ specifier }) === 'pipe') { + pipeNames.add(specifier.local.name) + } + } + return + } + + if (node.source.value !== 'effect/Effect') { + return + } + + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportNamespaceSpecifier') { + effectNamespaceNames.add(specifier.local.name) + continue + } + + const importedName = _importedName({ specifier }) + if (importedName === 'flatMap') { + flatMapNames.add(specifier.local.name) + } else if (importedName === 'service') { + serviceNames.add(specifier.local.name) + } + } + }, + CallExpression(node) { + if (!_isStaticClassField({ node })) { + return + } + + if ( + effectCall({ name: 'flatMap', names: flatMapNames, node }) !== undefined && + node.arguments.length >= 2 && + isServiceThisCall({ node: node.arguments[0] }) && + isForwardingFlatMap({ node }) + ) { + context.report({ message, node }) + return + } + + if (!isPipeCall({ node })) { + return + } + + const pipedEffect = node.callee.type === 'MemberExpression' ? node.callee.object : node.arguments[0] + const operators = node.callee.type === 'MemberExpression' ? node.arguments : node.arguments.slice(1) + + if ( + isServiceThisCall({ node: pipedEffect }) && + operators.some((operator) => isForwardingFlatMap({ node: operator })) + ) { + context.report({ message, node }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-switch.ts b/tools/oxlint/automation/rules/no-switch.ts new file mode 100644 index 0000000..895726c --- /dev/null +++ b/tools/oxlint/automation/rules/no-switch.ts @@ -0,0 +1,26 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const message = 'Switch statements are banned. Use Match from effect.' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Disallow switch statements. Use Match from effect instead.', + }, + }, + create(context) { + return { + SwitchStatement(node) { + context.report({ + node, + message, + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-try-catch.ts b/tools/oxlint/automation/rules/no-try-catch.ts new file mode 100644 index 0000000..fdef733 --- /dev/null +++ b/tools/oxlint/automation/rules/no-try-catch.ts @@ -0,0 +1,26 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const message = 'Do not use try/catch. Use Effect.try, Effect.tryPromise, or explicit error channels instead.' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid try/catch; use Effect.try, Effect.tryPromise, or explicit error channels.', + }, + }, + create(context) { + return { + TryStatement(node) { + context.report({ + node, + message, + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-type-assertion.ts b/tools/oxlint/automation/rules/no-type-assertion.ts new file mode 100644 index 0000000..66c3a59 --- /dev/null +++ b/tools/oxlint/automation/rules/no-type-assertion.ts @@ -0,0 +1,92 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type NodeLike = { + type: string + key?: NodeLike + name?: unknown + value?: unknown + parent?: NodeLike | null + callee?: NodeLike +} + +const message = 'Avoid type assertions. Prefer typed construction, validation, or inference.' + +const propertyName = (node: NodeLike) => + (node.type === 'Identifier' || node.type === 'PrivateIdentifier') && typeof node.name === 'string' + ? node.name + : node.type === 'StringLiteral' && typeof node.value === 'string' + ? node.value + : undefined + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid TypeScript type assertions; prefer typed construction, validation, or inference.', + }, + }, + create(context) { + return { + TSAsExpression(node) { + if ( + node.typeAnnotation.type === 'TSTypeReference' && + node.typeAnnotation.typeName?.type === 'Identifier' && + node.typeAnnotation.typeName.name === 'const' + ) { + return + } + + const typedProperty = node.parent + const typedKey = + typedProperty?.type === 'Property' && typedProperty.key + ? propertyName(typedProperty.key) + : undefined + const typesProperty = typedProperty?.parent?.parent + const setupCall = typesProperty?.parent?.parent + const property = node.parent?.type === 'TSSatisfiesExpression' ? node.parent.parent : node.parent + const call = property?.parent?.parent + const isSetupTypeAssertion = + typedProperty?.type === 'Property' && + (typedKey === 'input' || + typedKey === 'events' || + typedKey === 'context' || + typedKey === 'children' || + typedKey === 'output') && + typedProperty.parent?.type === 'ObjectExpression' && + typesProperty?.type === 'Property' && + typesProperty.key !== undefined && + propertyName(typesProperty.key) === 'types' && + typesProperty.parent?.type === 'ObjectExpression' && + setupCall?.type === 'CallExpression' && + setupCall.callee?.type === 'Identifier' && + setupCall.callee.name === 'setup' + const isCreateStoreContextAssertion = + property?.type === 'Property' && + property.key !== undefined && + propertyName(property.key) === 'context' && + property.parent?.type === 'ObjectExpression' && + call?.type === 'CallExpression' && + call.callee?.type === 'Identifier' && + call.callee.name === 'createStore' + + if (isSetupTypeAssertion || isCreateStoreContextAssertion) { + return + } + + context.report({ + node, + message, + }) + }, + TSTypeAssertion(node) { + context.report({ + node, + message, + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/no-typeof-object.ts b/tools/oxlint/automation/rules/no-typeof-object.ts new file mode 100644 index 0000000..bca019e --- /dev/null +++ b/tools/oxlint/automation/rules/no-typeof-object.ts @@ -0,0 +1,43 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const isObjectLiteral = (node: { type: string; value?: unknown }) => node.type === 'Literal' && node.value === 'object' + +const isTypeofExpression = (node: { type: string; operator?: string }) => + node.type === 'UnaryExpression' && node.operator === 'typeof' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Avoid typeof object checks; prefer Effect Schema or explicit null and object validation.', + }, + }, + create(context) { + return { + BinaryExpression(node) { + if ( + !( + (node.operator === '===' || + node.operator === '!==' || + node.operator === '==' || + node.operator === '!=') && + ((isTypeofExpression(node.left) && isObjectLiteral(node.right)) || + (isObjectLiteral(node.left) && isTypeofExpression(node.right))) + ) + ) { + return + } + + context.report({ + node, + message: + 'Do not compare typeof values with "object". First consider whether Effect Schema is a better solution and whether this runtime check is necessary. If it is necessary, use an explicit null and object validation helper instead.', + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/pipe-max-arguments.ts b/tools/oxlint/automation/rules/pipe-max-arguments.ts new file mode 100644 index 0000000..859183e --- /dev/null +++ b/tools/oxlint/automation/rules/pipe-max-arguments.ts @@ -0,0 +1,33 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +const maxPipeArguments = 20 + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Limit pipe calls to 20 arguments; split longer pipelines into named steps.', + }, + }, + create(context) { + return { + CallExpression(node) { + if ( + node.callee.type === 'MemberExpression' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'pipe' && + node.arguments.length > maxPipeArguments + ) { + context.report({ + node, + message: 'This pipe has too many arguments. Split it into smaller named steps.', + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/private-function-prefix.ts b/tools/oxlint/automation/rules/private-function-prefix.ts new file mode 100644 index 0000000..d72d852 --- /dev/null +++ b/tools/oxlint/automation/rules/private-function-prefix.ts @@ -0,0 +1,58 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type Ranged = { range: [number, number] } + +const functionTypes = new Set(['ArrowFunctionExpression', 'FunctionExpression']) + +const message = 'Private top-level functions must start with an underscore.' + +const _isPascalCase = (name: string) => /^[A-Z]/.test(name) + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Require private top-level functions and const function values to start with an underscore.', + }, + }, + create(context) { + const report = (node: Ranged) => { + context.report({ + node, + message, + }) + } + + return { + FunctionDeclaration(node) { + if ( + node.id !== null && + (node.parent?.type === 'Program' || node.parent?.type === 'ExportNamedDeclaration') && + node.parent?.type !== 'ExportNamedDeclaration' && + !_isPascalCase(node.id.name) && + !node.id.name.startsWith('_') + ) { + report(node.id) + } + }, + VariableDeclarator(node) { + if ( + node.id.type !== 'Identifier' || + node.init === null || + !functionTypes.has(node.init.type) || + node.parent?.type !== 'VariableDeclaration' || + node.parent.parent?.type !== 'Program' + ) { + return + } + + if (!_isPascalCase(node.id.name) && !node.id.name.startsWith('_')) { + report(node.id) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/require-approved-ui-primitives.ts b/tools/oxlint/automation/rules/require-approved-ui-primitives.ts new file mode 100644 index 0000000..ef953e6 --- /dev/null +++ b/tools/oxlint/automation/rules/require-approved-ui-primitives.ts @@ -0,0 +1,86 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +interface Options { + readonly applicationPathMarkers?: ReadonlyArray + readonly bannedElements?: ReadonlyArray + readonly linkModule?: string +} + +const _identifierName = (node: { readonly name?: string; readonly type: string }) => + node.type === 'Identifier' ? node.name : undefined + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Require app UI to use approved interactive components and import Link from next/link.', + }, + schema: [ + { + type: 'object', + properties: { + applicationPathMarkers: { + type: 'array', + items: { type: 'string' }, + }, + bannedElements: { + type: 'array', + items: { type: 'string' }, + }, + linkModule: { type: 'string' }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const filename = (context.filename ?? '').replaceAll('\\', '/') + const options = (context.options[0] ?? {}) as Options + const applicationPathMarkers = options.applicationPathMarkers ?? ['/applications/'] + const bannedIntrinsicElements = new Set(options.bannedElements ?? ['button', 'form']) + const linkModule = options.linkModule ?? 'next/link' + + if (!applicationPathMarkers.some((marker) => filename.includes(marker)) || !filename.endsWith('.tsx')) { + return {} + } + + return { + ImportDeclaration(node) { + if (node.source.value === linkModule) { + return + } + + const importsLink = node.specifiers.some((specifier) => { + if (specifier.type === 'ImportSpecifier') { + return _identifierName(specifier.imported) === 'Link' || specifier.local.name === 'Link' + } + + return specifier.local.name === 'Link' + }) + + if (!importsLink) { + return + } + + context.report({ + node, + message: `Import Link from ${linkModule}.`, + }) + }, + JSXOpeningElement(node) { + if (node.name.type !== 'JSXIdentifier' || !bannedIntrinsicElements.has(node.name.name)) { + return + } + + context.report({ + node: node.name, + message: `Use an approved design-system component instead of <${node.name.name}>.`, + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/require-cn-for-classname-composition.ts b/tools/oxlint/automation/rules/require-cn-for-classname-composition.ts new file mode 100644 index 0000000..3c803d1 --- /dev/null +++ b/tools/oxlint/automation/rules/require-cn-for-classname-composition.ts @@ -0,0 +1,375 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type VisitorObject = ReturnType> +type JSXAttributeNode = Parameters>[0] +type IdentifierNode = Parameters>[0] +type IfStatementNode = Parameters>[0] +type ReturnStatementNode = Parameters>[0] +type ExpressionNode = NonNullable + +interface Options { + readonly ignoredPathMarkers?: ReadonlyArray + readonly utilityModules?: ReadonlyArray + readonly utilityName?: string +} + +const literalExpressionTypes = new Set([ + 'BigIntLiteral', + 'BooleanLiteral', + 'Literal', + 'NullLiteral', + 'NumericLiteral', + 'RegExpLiteral', + 'StringLiteral', +]) + +const message = 'Compose conditional or combined className values with cn from the approved shared utility.' + +const _isClassNameAttribute = ({ node }: { node: JSXAttributeNode }) => + node.name.type === 'JSXIdentifier' && (node.name.name === 'className' || node.name.name.endsWith('ClassName')) + +const _unwrapExpression = ({ node }: { node: ExpressionNode }): ExpressionNode => { + if ( + node.type === 'ChainExpression' || + node.type === 'ParenthesizedExpression' || + node.type === 'TSAsExpression' || + node.type === 'TSInstantiationExpression' || + node.type === 'TSNonNullExpression' || + node.type === 'TSSatisfiesExpression' || + node.type === 'TSTypeAssertion' + ) { + return _unwrapExpression({ node: node.expression }) + } + + return node +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Require dynamic JSX class name composition to use the approved cn utility.', + }, + schema: [ + { + type: 'object', + properties: { + ignoredPathMarkers: { + type: 'array', + items: { type: 'string' }, + }, + utilityModules: { + type: 'array', + items: { type: 'string' }, + }, + utilityName: { type: 'string' }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const filename = (context.filename ?? '').replaceAll('\\', '/') + const options = (context.options[0] ?? {}) as Options + const ignoredPathMarkers = options.ignoredPathMarkers ?? ['/native/'] + const utilityModules = options.utilityModules ?? ['@scope/ui-utils'] + const utilityName = options.utilityName ?? 'cn' + + if (!filename.endsWith('.tsx') || ignoredPathMarkers.some((marker) => filename.includes(marker))) { + return {} + } + + const reportedAttributes = new Set() + const conditionalCallbackAttributes = new Set() + const isApprovedCnSource = ({ source }: { source: string }) => utilityModules.includes(source) + + const findVariable = ({ node }: { node: IdentifierNode }) => { + let scope: ReturnType | null = context.sourceCode.getScope(node) + + while (scope !== null) { + const variable = scope.set.get(node.name) + + if (variable !== undefined) { + return variable + } + + scope = scope.upper + } + + return undefined + } + + const findImportDefinition = ({ node }: { node: IdentifierNode }) => + findVariable({ node })?.defs.find( + (definition) => definition.type === 'ImportBinding' && definition.parent?.type === 'ImportDeclaration', + ) + + const isApprovedCnIdentifier = ({ node }: { node: IdentifierNode }) => { + const definition = findImportDefinition({ node }) + + return ( + definition !== undefined && + definition.parent?.type === 'ImportDeclaration' && + isApprovedCnSource({ source: definition.parent.source.value }) && + definition.node.type === 'ImportSpecifier' && + (definition.node.imported.type === 'Identifier' + ? definition.node.imported.name + : definition.node.imported.value) === utilityName + ) + } + + const isNextFontFactory = ({ node }: { node: IdentifierNode }) => { + const definition = findImportDefinition({ node }) + + if (definition === undefined || definition.parent?.type !== 'ImportDeclaration') { + return false + } + + const source = definition.parent.source.value + + return ( + (source === 'next/font/local' && definition.node.type === 'ImportDefaultSpecifier') || + (source === 'next/font/google' && definition.node.type === 'ImportSpecifier') + ) + } + + const isNextFontValue = ({ node }: { node: IdentifierNode }) => { + const variable = findVariable({ node }) + + return ( + variable?.defs.some((definition) => { + if ( + definition.type !== 'Variable' || + definition.node.type !== 'VariableDeclarator' || + definition.node.init?.type !== 'CallExpression' || + definition.node.init.callee.type !== 'Identifier' + ) { + return false + } + + return isNextFontFactory({ + node: definition.node.init.callee, + }) + }) === true + ) + } + + const isNextFontTemplate = ({ node }: { node: Extract }) => + node.expressions.length > 0 && + node.quasis.every((quasi) => quasi.value.raw.trim().length === 0) && + node.expressions.every((expression) => { + const value = _unwrapExpression({ node: expression }) + + return ( + value.type === 'MemberExpression' && + value.computed === false && + value.object.type === 'Identifier' && + value.property.type === 'Identifier' && + (value.property.name === 'className' || value.property.name === 'variable') && + isNextFontValue({ node: value.object }) + ) + }) + + const findVariableDefinition = ({ node }: { node: IdentifierNode }) => + findVariable({ node })?.defs.find( + (definition) => definition.type === 'Variable' && definition.node.type === 'VariableDeclarator', + ) + + const resolveConstInitializer = ({ node }: { node: IdentifierNode }) => { + const definition = findVariableDefinition({ node }) + + if ( + definition?.node.type !== 'VariableDeclarator' || + definition.node.parent?.type !== 'VariableDeclaration' || + definition.node.parent.kind !== 'const' || + definition.node.init === null + ) { + return undefined + } + + return definition.node.init + } + + const isAllowedExpression = ({ + node, + visited, + }: { + node: ExpressionNode + visited: Set + }): boolean => { + const value = _unwrapExpression({ node }) + + if (visited.has(value)) { + return true + } + + visited.add(value) + + if (literalExpressionTypes.has(value.type)) { + return true + } + + if (value.type === 'Identifier') { + const definition = findVariableDefinition({ node: value }) + + if (definition !== undefined) { + const initializer = resolveConstInitializer({ node: value }) + + return initializer !== undefined && isAllowedExpression({ node: initializer, visited }) + } + + return true + } + + if (value.type === 'MemberExpression') { + return true + } + + if (value.type === 'TemplateLiteral') { + return value.expressions.length === 0 || isNextFontTemplate({ node: value }) + } + + if (value.type === 'CallExpression') { + return value.callee.type === 'Identifier' && isApprovedCnIdentifier({ node: value.callee }) + } + + if (value.type === 'ArrowFunctionExpression' || value.type === 'FunctionExpression') { + if (value.body === null) { + return false + } + + return value.body.type === 'BlockStatement' ? true : isAllowedExpression({ node: value.body, visited }) + } + + return false + } + + const isCnComposition = ({ + node, + visited, + }: { + node: ExpressionNode + visited: Set + }): boolean => { + const value = _unwrapExpression({ node }) + + if (visited.has(value)) { + return false + } + + visited.add(value) + + if (value.type === 'CallExpression') { + return value.callee.type === 'Identifier' && isApprovedCnIdentifier({ node: value.callee }) + } + + if (value.type !== 'Identifier') { + return false + } + + const initializer = resolveConstInitializer({ node: value }) + + return initializer !== undefined && isCnComposition({ node: initializer, visited }) + } + + const report = ({ node }: { node: JSXAttributeNode }) => { + if (reportedAttributes.has(node.range[0])) { + return + } + + reportedAttributes.add(node.range[0]) + context.report({ + node, + message, + }) + } + + const enclosingClassNameAttribute = ({ node }: { node: IfStatementNode | ReturnStatementNode }) => { + let current: IfStatementNode['parent'] | null = node.parent + + while ( + current !== null && + current.type !== 'ArrowFunctionExpression' && + current.type !== 'FunctionExpression' + ) { + current = current.parent + } + + if ( + current === null || + current.parent?.type !== 'JSXExpressionContainer' || + current.parent.parent?.type !== 'JSXAttribute' || + !_isClassNameAttribute({ node: current.parent.parent }) + ) { + return undefined + } + + return current.parent.parent + } + + return { + IfStatement(node) { + const attribute = enclosingClassNameAttribute({ node }) + + if (attribute !== undefined) { + conditionalCallbackAttributes.add(attribute.range[0]) + } + }, + JSXAttribute(node) { + if ( + !_isClassNameAttribute({ node }) || + node.value?.type !== 'JSXExpressionContainer' || + node.value.expression.type === 'JSXEmptyExpression' + ) { + return + } + + const expression = _unwrapExpression({ + node: node.value.expression, + }) + + if ( + (expression.type === 'ArrowFunctionExpression' || expression.type === 'FunctionExpression') && + expression.body !== null && + expression.body.type === 'BlockStatement' + ) { + return + } + + if ( + !isAllowedExpression({ + node: expression, + visited: new Set(), + }) + ) { + report({ node }) + } + }, + ReturnStatement(node) { + if (node.argument === null) { + return + } + + const enclosingAttribute = enclosingClassNameAttribute({ node }) + + if ( + enclosingAttribute !== undefined && + ((conditionalCallbackAttributes.has(enclosingAttribute.range[0]) && + !isCnComposition({ + node: node.argument, + visited: new Set(), + })) || + !isAllowedExpression({ + node: node.argument, + visited: new Set(), + })) + ) { + report({ node: enclosingAttribute }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/require-context-service-in-services.ts b/tools/oxlint/automation/rules/require-context-service-in-services.ts new file mode 100644 index 0000000..9307537 --- /dev/null +++ b/tools/oxlint/automation/rules/require-context-service-in-services.ts @@ -0,0 +1,142 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type VisitorObject = ReturnType> +type ClassDeclarationNode = Parameters>[0] + +interface Options { + readonly ignoredBasenames?: ReadonlyArray + readonly ignoredDirectoryNames?: ReadonlyArray + readonly serviceDirectoryNames?: ReadonlyArray +} + +type Node = { + readonly [key: string]: unknown + readonly type: string +} + +const _isNode = (value: unknown): value is Node => + typeof value === 'object' && + value !== null && + Object.hasOwn(value, 'type') && + typeof Reflect.get(value, 'type') === 'string' + +const _nodeField = ({ field, node }: { field: string; node: Node }) => node[field] + +const _identifierName = ({ node }: { node: unknown }) => + _isNode(node) && node.type === 'Identifier' && typeof _nodeField({ field: 'name', node }) === 'string' + ? _nodeField({ field: 'name', node }) + : undefined + +const _isContextService = ({ node }: { node: unknown }): boolean => { + if (!_isNode(node)) { + return false + } + + if (node.type === 'MemberExpression') { + return ( + _identifierName({ + node: _nodeField({ field: 'object', node }), + }) === 'Context' && + _identifierName({ + node: _nodeField({ field: 'property', node }), + }) === 'Service' + ) + } + + if (node.type === 'CallExpression') { + return _isContextService({ + node: _nodeField({ field: 'callee', node }), + }) + } + + if ( + node.type === 'ChainExpression' || + node.type === 'ParenthesizedExpression' || + node.type === 'TSInstantiationExpression' + ) { + return _isContextService({ + node: _nodeField({ field: 'expression', node }), + }) + } + + return false +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Require production modules in services folders to define a class extending Context.Service.', + }, + schema: [ + { + type: 'object', + properties: { + ignoredBasenames: { + type: 'array', + items: { type: 'string' }, + }, + ignoredDirectoryNames: { + type: 'array', + items: { type: 'string' }, + }, + serviceDirectoryNames: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const options = (context.options[0] ?? {}) as Options + const ignoredBasenames = new Set(options.ignoredBasenames ?? ['index.ts', 'main.ts']) + const ignoredDirectoryNames = new Set(options.ignoredDirectoryNames ?? ['test', 'tests', '__tests__']) + const serviceDirectoryNames = new Set(options.serviceDirectoryNames ?? ['service', 'services']) + const filename = (context.filename ?? '').replaceAll('\\', '/') + const basename = filename.slice(filename.lastIndexOf('/') + 1) + const segments = filename.split('/') + + if ( + (!filename.endsWith('.ts') && !filename.endsWith('.tsx')) || + filename.endsWith('.d.ts') || + !segments.some((segment) => serviceDirectoryNames.has(segment)) || + segments.some((segment) => ignoredDirectoryNames.has(segment)) || + basename.endsWith('.test.ts') || + basename.endsWith('.test.tsx') || + basename.endsWith('.spec.ts') || + basename.endsWith('.spec.tsx') || + ignoredBasenames.has(basename) + ) { + return {} + } + + let definesContextService = false + + return { + ClassDeclaration(node: ClassDeclarationNode) { + if ( + _isContextService({ + node: node.superClass, + }) + ) { + definesContextService = true + } + }, + 'Program:exit'(node) { + if (definesContextService) { + return + } + + context.report({ + node, + message: 'Files inside services folders must define a class extending Context.Service.', + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/require-default-component-export.ts b/tools/oxlint/automation/rules/require-default-component-export.ts new file mode 100644 index 0000000..8e11c45 --- /dev/null +++ b/tools/oxlint/automation/rules/require-default-component-export.ts @@ -0,0 +1,288 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] +type VisitorObject = ReturnType> +type ProgramNode = Parameters>[0] + +interface Options { + readonly applicationPathMarkers?: ReadonlyArray + readonly ignoredFilenamePatterns?: ReadonlyArray + readonly wrapperNames?: ReadonlyArray +} + +type Node = { + readonly [key: string]: unknown + readonly type: string +} + +type RangedNode = Node & { + readonly range: [number, number] +} + +let componentWrapperNames: ReadonlySet = new Set(['forwardRef', 'memo']) + +const _isNode = (value: unknown): value is Node => + typeof value === 'object' && + value !== null && + Object.hasOwn(value, 'type') && + typeof Reflect.get(value, 'type') === 'string' + +const _isRangedNode = (value: unknown): value is RangedNode => + _isNode(value) && + Array.isArray(value.range) && + value.range.length === 2 && + typeof value.range[0] === 'number' && + typeof value.range[1] === 'number' + +const _nodeField = ({ field, node }: { field: string; node: Node }) => node[field] + +const _nodeArray = ({ field, node }: { field: string; node: Node }) => { + const value = _nodeField({ field, node }) + + return Array.isArray(value) ? value.filter(_isNode) : [] +} + +const _nodeName = ({ node }: { node: unknown }) => { + if (!_isNode(node)) { + return undefined + } + + const name = _nodeField({ field: 'name', node }) + + return typeof name === 'string' ? name : undefined +} + +const _isPascalCase = ({ name }: { name: string | undefined }) => name !== undefined && /^[A-Z]/.test(name) + +const _isComponentValue = ({ node }: { node: unknown }) => { + if (!_isNode(node)) { + return false + } + + if ( + node.type === 'ArrowFunctionExpression' || + node.type === 'FunctionExpression' || + node.type === 'ClassExpression' + ) { + return true + } + + if (node.type !== 'CallExpression') { + return false + } + + const callee = _nodeField({ field: 'callee', node }) + + if (!_isNode(callee)) { + return false + } + + const wrapperName = + callee.type === 'Identifier' + ? _nodeName({ node: callee }) + : callee.type === 'MemberExpression' + ? _nodeName({ + node: _nodeField({ field: 'property', node: callee }), + }) + : undefined + + return wrapperName !== undefined && componentWrapperNames.has(wrapperName) +} + +const _declarationComponentNames = ({ node }: { node: Node }) => { + if (node.type === 'FunctionDeclaration' || node.type === 'ClassDeclaration') { + const name = _nodeName({ node: _nodeField({ field: 'id', node }) }) + + return _isPascalCase({ name }) && name !== undefined ? [name] : [] + } + + if (node.type !== 'VariableDeclaration') { + return [] + } + + return _nodeArray({ field: 'declarations', node }).flatMap((declaration) => { + const name = _nodeName({ + node: _nodeField({ field: 'id', node: declaration }), + }) + const value = _nodeField({ field: 'init', node: declaration }) + + return _isPascalCase({ name }) && name !== undefined && _isComponentValue({ node: value }) ? [name] : [] + }) +} + +const _componentBindings = ({ node }: { node: Node }) => { + const names = new Set() + + for (const statement of _nodeArray({ field: 'body', node })) { + const declaration = + statement.type === 'ExportNamedDeclaration' + ? _nodeField({ field: 'declaration', node: statement }) + : statement + + if (!_isNode(declaration)) { + continue + } + + for (const name of _declarationComponentNames({ node: declaration })) { + names.add(name) + } + } + + return names +} + +const _exportedComponentNames = ({ + componentBindings, + node, +}: { + componentBindings: ReadonlySet + node: Node +}) => { + const declaration = _nodeField({ field: 'declaration', node }) + const names = _isNode(declaration) ? _declarationComponentNames({ node: declaration }) : [] + + for (const specifier of _nodeArray({ field: 'specifiers', node })) { + const exportedName = _nodeName({ + node: _nodeField({ field: 'exported', node: specifier }), + }) + const localName = _nodeName({ + node: _nodeField({ field: 'local', node: specifier }), + }) + + if (exportedName !== 'default' && localName !== undefined && componentBindings.has(localName)) { + names.push(localName) + } + } + + return names +} + +const _deferredDefaultComponentNames = ({ + componentBindings, + node, +}: { + componentBindings: ReadonlySet + node: Node +}) => { + if (node.type === 'ExportDefaultDeclaration') { + const name = _nodeName({ + node: _nodeField({ field: 'declaration', node }), + }) + + return name !== undefined && componentBindings.has(name) ? [name] : [] + } + + if (node.type !== 'ExportNamedDeclaration') { + return [] + } + + return _nodeArray({ field: 'specifiers', node }).flatMap((specifier) => { + const exportedName = _nodeName({ + node: _nodeField({ field: 'exported', node: specifier }), + }) + const localName = _nodeName({ + node: _nodeField({ field: 'local', node: specifier }), + }) + + return exportedName === 'default' && localName !== undefined && componentBindings.has(localName) + ? [localName] + : [] + }) +} + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: + 'Require app component files to default-export one main component and keep supporting components local.', + }, + schema: [ + { + type: 'object', + properties: { + applicationPathMarkers: { + type: 'array', + items: { type: 'string' }, + }, + ignoredFilenamePatterns: { + type: 'array', + items: { type: 'string' }, + }, + wrapperNames: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const options = (context.options[0] ?? {}) as Options + const applicationPathMarkers = options.applicationPathMarkers ?? ['/applications/'] + const ignoredFilenamePatterns = options.ignoredFilenamePatterns ?? ['(?:^|-)icons\\.tsx$'] + componentWrapperNames = new Set(options.wrapperNames ?? ['forwardRef', 'memo']) + const filename = (context.filename ?? '').replaceAll('\\', '/') + const basename = filename.slice(filename.lastIndexOf('/') + 1) + + if ( + !applicationPathMarkers.some((marker) => filename.includes(marker)) || + !filename.endsWith('.tsx') || + ignoredFilenamePatterns.some((pattern) => new RegExp(pattern).test(basename)) + ) { + return {} + } + + return { + Program(node: ProgramNode) { + if (!_isNode(node)) { + return + } + + const componentBindings = _componentBindings({ node }) + + for (const statement of _nodeArray({ field: 'body', node })) { + const deferredDefaultNames = _deferredDefaultComponentNames({ + componentBindings, + node: statement, + }) + + if (deferredDefaultNames.length > 0 && _isRangedNode(statement)) { + context.report({ + node: statement, + message: `Declare ${deferredDefaultNames.join(', ')} with export default directly on the function definition.`, + }) + } + + if ( + statement.type !== 'ExportNamedDeclaration' || + _nodeField({ field: 'source', node: statement }) !== null + ) { + continue + } + + const names = _exportedComponentNames({ + componentBindings, + node: statement, + }) + + if (names.length === 0) { + continue + } + + if (!_isRangedNode(statement)) { + continue + } + + context.report({ + node: statement, + message: `Export one main component as default and keep supporting components local. Named component export${names.length === 1 ? '' : 's'}: ${names.join(', ')}.`, + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/require-tsx-in-ui-folders.ts b/tools/oxlint/automation/rules/require-tsx-in-ui-folders.ts new file mode 100644 index 0000000..7f68cf6 --- /dev/null +++ b/tools/oxlint/automation/rules/require-tsx-in-ui-folders.ts @@ -0,0 +1,74 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +interface Options { + readonly allowedTypeScriptFiles?: ReadonlyArray + readonly uiDirectoryNames?: ReadonlyArray +} + +const defaultAllowedTypeScriptFiles = [ + 'apple-icon.ts', + 'icon.ts', + 'manifest.ts', + 'opengraph-image.ts', + 'robots.ts', + 'route.ts', + 'sitemap.ts', + 'twitter-image.ts', +] + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: + 'Keep UI modules in app and components folders as TSX; move non-UI modules to their owning lib folder.', + }, + schema: [ + { + type: 'object', + properties: { + allowedTypeScriptFiles: { + type: 'array', + items: { type: 'string' }, + }, + uiDirectoryNames: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + create(context) { + const options = (context.options[0] ?? {}) as Options + const allowedTypeScriptFiles = new Set(options.allowedTypeScriptFiles ?? defaultAllowedTypeScriptFiles) + const uiDirectoryNames = new Set(options.uiDirectoryNames ?? ['app', 'components']) + const filename = (context.filename ?? '').replaceAll('\\', '/') + const basename = filename.slice(filename.lastIndexOf('/') + 1) + const segments = filename.split('/') + + if ( + !filename.endsWith('.ts') || + filename.endsWith('.d.ts') || + allowedTypeScriptFiles.has(basename) || + !segments.some((segment) => uiDirectoryNames.has(segment)) + ) { + return {} + } + + return { + Program(node) { + context.report({ + node, + message: + 'Only TSX files belong in app and components folders. Move this non-UI module to its owning lib folder.', + }) + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/require-xstate-event-satisfies.ts b/tools/oxlint/automation/rules/require-xstate-event-satisfies.ts new file mode 100644 index 0000000..7649195 --- /dev/null +++ b/tools/oxlint/automation/rules/require-xstate-event-satisfies.ts @@ -0,0 +1,159 @@ +import type { RuleTester } from 'oxlint/plugins-dev' + +type Rule = Parameters[1] + +type Node = { + argument?: unknown + arguments?: Array + body?: unknown + callee?: unknown + expression?: unknown + name?: unknown + range: [number, number] + type: string +} + +const message = 'XState sent object events must use satisfies with the target event type.' + +const unwrapExpression = ({ node }: { node: Node }): Node => { + if ( + node.type === 'ParenthesizedExpression' || + node.type === 'ChainExpression' || + node.type === 'TSNonNullExpression' + ) { + return !isNode(node.expression) ? node : unwrapExpression({ node: node.expression }) + } + + return node +} + +const isNode = (value: unknown): value is Node => { + if (typeof value !== 'object' || value === null) { + return false + } + + return 'type' in value && typeof value.type === 'string' && 'range' in value && Array.isArray(value.range) +} + +const nodeList = ({ value }: { value: unknown }): Array => (Array.isArray(value) ? value.filter(isNode) : []) + +const isSatisfiedObjectEvent = ({ node }: { node: Node }) => { + const expression = unwrapExpression({ node }) + + return ( + expression.type === 'TSSatisfiesExpression' && + isNode(expression.expression) && + unwrapExpression({ node: expression.expression }).type === 'ObjectExpression' + ) +} + +const isBareObjectEvent = ({ node }: { node: Node }) => unwrapExpression({ node }).type === 'ObjectExpression' + +const rule: Rule = { + meta: { + type: 'problem' as const, + docs: { + description: 'Require XState sent object events to use a satisfies event type.', + }, + }, + create(context) { + const collectEventObjectsMissingSatisfies = ({ node }: { node: Node }): Array => { + const eventFactory = unwrapExpression({ node }) + + if (isSatisfiedObjectEvent({ node: eventFactory })) { + return [] + } + + if (isBareObjectEvent({ node: eventFactory })) { + return [eventFactory] + } + + if (eventFactory.type === 'ArrowFunctionExpression' || eventFactory.type === 'FunctionExpression') { + if (eventFactory.body === undefined) { + return [] + } + + if ( + !Array.isArray(eventFactory.body) && + isNode(eventFactory.body) && + eventFactory.body.type === 'BlockStatement' && + Array.isArray(eventFactory.body.body) + ) { + return nodeList({ value: eventFactory.body.body }).flatMap((statement) => { + if (statement.type !== 'ReturnStatement' || !isNode(statement.argument)) { + return [] + } + + const argument = unwrapExpression({ node: statement.argument }) + + if (isSatisfiedObjectEvent({ node: argument })) { + return [] + } + + return isBareObjectEvent({ node: argument }) ? [argument] : [] + }) + } + + if (!Array.isArray(eventFactory.body) && isNode(eventFactory.body)) { + const body = unwrapExpression({ node: eventFactory.body }) + + if (isSatisfiedObjectEvent({ node: body })) { + return [] + } + + return isBareObjectEvent({ node: body }) ? [body] : [] + } + + return nodeList({ value: eventFactory.body }).flatMap((statement) => { + if (statement.type !== 'ReturnStatement' || !isNode(statement.argument)) { + return [] + } + + const argument = unwrapExpression({ node: statement.argument }) + + if (isSatisfiedObjectEvent({ node: argument })) { + return [] + } + + return isBareObjectEvent({ node: argument }) ? [argument] : [] + }) + } + + return [] + } + + return { + CallExpression(node) { + const eventArgumentIndex = + isNode(node.callee) && node.callee.type === 'Identifier' + ? node.callee.name === 'sendParent' + ? 0 + : node.callee.name === 'sendTo' + ? 1 + : undefined + : undefined + + if (eventArgumentIndex === undefined) { + return + } + + const eventArgument = node.arguments.at(eventArgumentIndex) + + if (!isNode(eventArgument)) { + return + } + + for (const eventObject of collectEventObjectsMissingSatisfies({ + node: eventArgument, + })) { + context.report({ + node: eventObject, + message, + }) + } + }, + } + }, +} + +export default rule diff --git a/tools/oxlint/automation/rules/xstate-single-use.ts b/tools/oxlint/automation/rules/xstate-single-use.ts new file mode 100644 index 0000000..5707209 --- /dev/null +++ b/tools/oxlint/automation/rules/xstate-single-use.ts @@ -0,0 +1,184 @@ +type Node = { + [key: string]: unknown + type: string +} + +type ReportNode = Node & { range: [number, number] } + +export type Definition = { + name: string + node: ReportNode +} + +export type ReferenceNames = ({ node }: { node: Node }) => Array + +export const isNode = (value: unknown): value is Node => + typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string' + +export const isReportNode = (value: unknown): value is ReportNode => + isNode(value) && + 'range' in value && + Array.isArray(value.range) && + value.range.length === 2 && + typeof value.range[0] === 'number' && + typeof value.range[1] === 'number' + +export const nodeList = ({ value }: { value: unknown }) => (Array.isArray(value) ? value.filter(isNode) : []) + +export const nodeAt = ({ index, value }: { index: number; value: unknown }) => + Array.isArray(value) && isNode(value[index]) ? value[index] : undefined + +const nodeField = ({ field, node }: { field: string; node: Node }) => (isNode(node[field]) ? node[field] : undefined) + +export const propertyName = ({ node }: { node: Node }) => { + const key = nodeField({ field: 'key', node }) + + if (key?.type === 'Identifier' && typeof key.name === 'string') { + return key.name + } + + if (typeof key?.value === 'string') { + return key.value + } + + const property = nodeField({ field: 'property', node }) + + if (property?.type === 'Identifier' && typeof property.name === 'string') { + return property.name + } + + if (typeof property?.value === 'string') { + return property.value + } + + return undefined +} + +export const propertyValue = ({ node }: { node: Node }) => nodeField({ field: 'value', node }) + +export const stringLiteralValue = ({ node }: { node: Node }) => + typeof node.value === 'string' ? node.value : undefined + +export const objectProperty = ({ name, node }: { name: string; node: Node }) => { + if (node.type !== 'ObjectExpression') { + return undefined + } + + return nodeList({ value: node.properties }).find((property) => propertyName({ node: property }) === name) +} + +export const collectSetupDefinitions = ({ + definitionPropertyName, + node, +}: { + definitionPropertyName: string + node: Node +}) => { + const setupOptions = nodeAt({ index: 0, value: node.arguments }) + + if (setupOptions === undefined) { + return [] + } + + const definitionsProperty = objectProperty({ + name: definitionPropertyName, + node: setupOptions, + }) + const definitionsObject = + definitionsProperty === undefined ? undefined : propertyValue({ node: definitionsProperty }) + + if (definitionsObject?.type !== 'ObjectExpression') { + return [] + } + + return nodeList({ value: definitionsObject.properties }).flatMap((property): Array => { + const name = propertyName({ node: property }) + + return name !== undefined && isReportNode(property) ? [{ name, node: property }] : [] + }) +} + +export const typedReferenceName = ({ node }: { node: Node }) => { + const literalValue = stringLiteralValue({ node }) + + if (literalValue !== undefined) { + return literalValue + } + + const typeProperty = objectProperty({ name: 'type', node }) + const typeValue = typeProperty === undefined ? undefined : propertyValue({ node: typeProperty }) + + return typeValue === undefined ? undefined : stringLiteralValue({ node: typeValue }) +} + +export const countMachineReferences = ({ + counts, + definedNames, + node, + referenceNames, + referencePropertyName, +}: { + counts: Map + definedNames: Set + node: Node + referenceNames: ReferenceNames + referencePropertyName: string +}) => { + if (node.type === 'ObjectExpression') { + for (const property of nodeList({ value: node.properties })) { + const value = propertyValue({ node: property }) + + if (propertyName({ node: property }) === referencePropertyName) { + if (value !== undefined) { + for (const name of referenceNames({ node: value })) { + if (definedNames.has(name)) { + counts.set(name, (counts.get(name) ?? 0) + 1) + } + } + } + + continue + } + + if (value !== undefined) { + countMachineReferences({ + counts, + definedNames, + node: value, + referenceNames, + referencePropertyName, + }) + } + } + } + + if (node.type === 'ArrayExpression') { + for (const element of nodeList({ value: node.elements })) { + countMachineReferences({ + counts, + definedNames, + node: element, + referenceNames, + referencePropertyName, + }) + } + } +} + +export const setupCallFromCreateMachineCall = ({ node }: { node: Node }) => { + const callee = nodeField({ field: 'callee', node }) + const setupCall = nodeField({ field: 'object', node: callee ?? node }) + const setupCallee = setupCall === undefined ? undefined : nodeField({ field: 'callee', node: setupCall }) + + if ( + callee?.type !== 'MemberExpression' || + propertyName({ node: callee }) !== 'createMachine' || + setupCall?.type !== 'CallExpression' || + setupCallee?.type !== 'Identifier' || + setupCallee.name !== 'setup' + ) { + return undefined + } + + return setupCall +} diff --git a/tools/oxlint/import-extensions.mjs b/tools/oxlint/import-extensions.mjs new file mode 100644 index 0000000..2142b49 --- /dev/null +++ b/tools/oxlint/import-extensions.mjs @@ -0,0 +1,50 @@ +import { defineRule, eslintCompatPlugin } from '@oxlint/plugins' + +const relativeSourceExtension = /^\.{1,2}\/.*\.(?:[cm]?[jt]sx?)(?:[?#].*)?$/u + +export default eslintCompatPlugin({ + meta: { name: 'import-extensions' }, + rules: { + 'no-relative-source-extensions': defineRule({ + meta: { + type: 'problem', + schema: [], + messages: { + extension: 'Use an extensionless relative import instead of a TypeScript or JavaScript extension.', + }, + }, + createOnce(context) { + function check(node) { + if (!node) return + const value = + node.type === 'TemplateLiteral' + ? node.quasis.map((quasi) => quasi.value.cooked ?? '').join('') + : node.value + if (value !== null && value !== undefined && relativeSourceExtension.test(value)) { + context.report({ node, messageId: 'extension' }) + } + } + return { + ImportDeclaration(node) { + check(node.source) + }, + ExportNamedDeclaration(node) { + check(node.source) + }, + ExportAllDeclaration(node) { + check(node.source) + }, + ImportExpression(node) { + check(node.source) + }, + TSImportType(node) { + check(node.source) + }, + TSExternalModuleReference(node) { + check(node.expression) + }, + } + }, + }), + }, +}) diff --git a/tools/typed/README.md b/tools/typed/README.md new file mode 100644 index 0000000..defc716 --- /dev/null +++ b/tools/typed/README.md @@ -0,0 +1,23 @@ +# Type-aware and file rules + +**Agent action: copy the engine or selected rules, then adapt discovery and +configuration.** + +The three `src/rules` checks use the TypeScript Compiler API. The +`src/workspace-rules` area demonstrates a file-policy check for SVG markup. +`src/registry.ts` joins both kinds and `src/engine.ts` discovers projects, +normalizes relative diagnostics, sorts output, and removes duplicates. + +`src/cli.ts` has honest command behavior: errors return `1`, configuration or +usage failures return `2`, and `fix` returns `2` without changing files because +the included rules are diagnostic-only. + +Start from +[`examples/configuration/typed-lint/config.ts`](../../examples/configuration/typed-lint/config.ts). +Replace project globs, exclusions, severities, and file paths with the target +codebase’s ownership model. Invoke the scanner directly from its validation +boundary so every run observes current files. + +Unit tests cover rule behavior and configuration. `test/integration` covers +recursive discovery, sorting, deduplication, configuration failures, exit +statuses, and command execution. diff --git a/tools/typed/src/cli.ts b/tools/typed/src/cli.ts new file mode 100644 index 0000000..86626fa --- /dev/null +++ b/tools/typed/src/cli.ts @@ -0,0 +1,120 @@ +#!/usr/bin/env node +import fs from 'node:fs' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { loadConfig } from './config.js' +import { formatDiagnostic, runTypedLint } from './engine.js' + +type CliOptions = { + command: 'check' | 'fix' + configPath?: string + format: 'json' | 'stylish' + rootDir: string +} + +const usage = `Usage: typed-lint [check|fix] [options] + +Options: + --config Load a configuration file + --format