diff --git a/src/agent/core/domain/harness/index.ts b/src/agent/core/domain/harness/index.ts new file mode 100644 index 000000000..8d1790858 --- /dev/null +++ b/src/agent/core/domain/harness/index.ts @@ -0,0 +1,9 @@ +/** + * AutoHarness V2 core-domain barrel. + * + * Re-exports every harness domain type so consumers import from a + * single path: `import type {HarnessContext, HarnessModule} from + * '.../core/domain/harness'`. + */ + +export * from './types.js' diff --git a/src/agent/core/domain/harness/types.ts b/src/agent/core/domain/harness/types.ts index cd088fb38..f2297f56e 100644 --- a/src/agent/core/domain/harness/types.ts +++ b/src/agent/core/domain/harness/types.ts @@ -8,6 +8,16 @@ import {z} from 'zod' +import type { + CurateOperation, + CurateOptions, + CurateResult, +} from '../../interfaces/i-curate-service.js' +import type { + FileContent, + ReadFileOptions, +} from '../file-system/types.js' + // --------------------------------------------------------------------------- // Enums // --------------------------------------------------------------------------- @@ -169,3 +179,86 @@ export const EvaluationScenarioSchema = z .strict() export type EvaluationScenario = z.input export type ValidatedEvaluationScenario = z.output + +// --------------------------------------------------------------------------- +// Phase 3 — HarnessContext + module contract +// --------------------------------------------------------------------------- + +/** + * Environment metadata surfaced to a harness function at call time. + * Scoped deliberately narrow — the context must be cheap to construct + * per call and must not leak session-specific references beyond what + * the template actually uses. Extend additively when a real consumer + * materializes. + */ +export interface HarnessContextEnv { + readonly commandType: 'chat' | 'curate' | 'query' + readonly projectType: ProjectType + readonly workingDirectory: string +} + +/** + * Tool surface exposed to harness functions inside the VM. Each member + * is a bound proxy into the outer sandbox's `ToolsSDK` — harness code + * calls `ctx.tools.curate(...)` and the call bridges out to the real + * `tools.curate`. + * + * Signatures mirror `ToolsSDK` exactly. Every referenced type lives in + * `core/` — `CurateOperation` / `CurateOptions` / `CurateResult` in + * `core/interfaces/i-curate-service.ts`; `ReadFileOptions` / `FileContent` + * in `core/domain/file-system/types.ts`. That keeps `HarnessContextTools` + * free of `infra/` imports. + * + * v1.0 surface is `curate` + `readFile` — exactly what Phase 4's + * pass-through templates need. Adding more members (`grep`, + * `searchKnowledge`, etc.) is additive when a real consumer asks. + * The cost of a new member is moving its types into `core/` if they + * don't live there already; for `grep` / `searchKnowledge` that means + * splitting `SearchKnowledgeOptions` / `GrepOptions` out of + * `infra/sandbox/tools-sdk.ts` first. + */ +export interface HarnessContextTools { + readonly curate: ( + operations: CurateOperation[], + options?: CurateOptions, + ) => Promise + readonly readFile: (filePath: string, options?: ReadFileOptions) => Promise +} + +/** + * Context passed as the sole argument to every harness function call. + * Frozen at call boundary so a compromised harness can't mutate what + * it sees. `readonly` is compile-time; Phase 3 Task 3.2's module + * builder enforces the invariant at runtime via `Object.freeze`. + */ +export interface HarnessContext { + readonly abort: AbortSignal + readonly env: HarnessContextEnv + readonly tools: HarnessContextTools +} + +/** + * Shape exported by every harness module (template or refined). + * `meta` is always required; `curate` / `query` are optional and must + * be present iff declared in `meta().capabilities`. Phase 3 Task 3.2 + * validates this invariant at load time. + */ +export interface HarnessModule { + readonly curate?: (ctx: HarnessContext) => Promise + readonly meta: () => HarnessMeta + readonly query?: (ctx: HarnessContext) => Promise +} + +/** + * Result of `SandboxService.loadHarness`. Discriminated on `loaded` so + * consumers narrow cleanly: `{loaded: true}` carries the module and + * its source version; `{loaded: false}` carries a machine-readable + * `reason` that distinguishes "nothing to load" from "harness code is + * broken." + * + * Consumers never throw on a failed load — the sandbox degrades to + * raw `tools.*` orchestration transparently. + */ +export type HarnessLoadResult = + | {loaded: false; reason: 'meta-invalid' | 'meta-threw' | 'no-version' | 'syntax'} + | {loaded: true; module: HarnessModule; version: HarnessVersion} diff --git a/test/unit/agent/harness/types-context.test.ts b/test/unit/agent/harness/types-context.test.ts new file mode 100644 index 000000000..76ea97f8a --- /dev/null +++ b/test/unit/agent/harness/types-context.test.ts @@ -0,0 +1,125 @@ +import {expect} from 'chai' +import {expectTypeOf} from 'expect-type' + +import type { + HarnessContext, + HarnessContextEnv, + HarnessContextTools, + HarnessLoadResult, + HarnessModule, + HarnessVersion, +} from '../../../../src/agent/core/domain/harness/index.js' +import type {CurateResult} from '../../../../src/agent/core/interfaces/i-curate-service.js' + +describe('HarnessContext + module contract', () => { + describe('HarnessModule', () => { + it('requires `meta` and makes `curate` / `query` optional', () => { + // Minimal valid module: just meta. + const minimal: HarnessModule = { + meta: () => ({ + capabilities: [], + commandType: 'curate', + projectPatterns: [], + version: 1, + }), + } + expect(minimal.curate).to.equal(undefined) + expect(minimal.query).to.equal(undefined) + expectTypeOf(minimal.meta).returns.toMatchTypeOf<{commandType: string}>() + }) + + it('rejects a module missing `meta` at compile time', () => { + // @ts-expect-error — meta is required + const broken: HarnessModule = {curate: async () => ({} as CurateResult)} + expect(broken).to.exist + }) + }) + + describe('HarnessContext readonly enforcement (compile-time only)', () => { + // `readonly` is a TypeScript-level invariant. The tests below pass + // iff the `@ts-expect-error` directives are consumed (i.e., TS would + // report an error without them). Runtime writes still execute — + // that's fine; the guarantee we're testing is the compile-time one, + // and Phase 3 Task 3.2's module builder enforces runtime frozenness + // via `Object.freeze` separately. + + it('rejects reassigning `ctx.env`', () => { + const ctx: HarnessContext = { + abort: new AbortController().signal, + env: {commandType: 'curate', projectType: 'typescript', workingDirectory: '/'}, + tools: {} as HarnessContextTools, + } + const replacement: HarnessContextEnv = { + commandType: 'chat', + projectType: 'generic', + workingDirectory: '/other', + } + // @ts-expect-error — env is readonly on HarnessContext + ctx.env = replacement + expect(ctx).to.exist + }) + + it('rejects reassigning `env.workingDirectory`', () => { + const env: HarnessContextEnv = { + commandType: 'curate', + projectType: 'typescript', + workingDirectory: '/tmp', + } + // @ts-expect-error — workingDirectory is readonly + env.workingDirectory = '/elsewhere' + expect(env).to.exist + }) + + it('rejects reassigning `tools.curate`', () => { + const tools: HarnessContextTools = { + curate: (async () => ({}) as CurateResult) as HarnessContextTools['curate'], + readFile: (async () => ({}) as never) as HarnessContextTools['readFile'], + } + const replacement = (async () => ({}) as CurateResult) as HarnessContextTools['curate'] + // @ts-expect-error — curate is readonly + tools.curate = replacement + expect(tools).to.exist + }) + }) + + describe('HarnessLoadResult discriminated union', () => { + it('narrows to `module` + `version` when `loaded === true`', () => { + const result: HarnessLoadResult = { + loaded: true, + module: { + meta: () => ({ + capabilities: [], + commandType: 'curate', + projectPatterns: [], + version: 1, + }), + }, + version: {} as HarnessVersion, + } + + if (result.loaded) { + expectTypeOf(result.module).toEqualTypeOf() + expectTypeOf(result.version).toEqualTypeOf() + } + }) + + it('narrows to `reason` when `loaded === false`', () => { + const result: HarnessLoadResult = {loaded: false, reason: 'no-version'} + + if (!result.loaded) { + expectTypeOf(result.reason).toEqualTypeOf< + 'meta-invalid' | 'meta-threw' | 'no-version' | 'syntax' + >() + } + }) + + it('does NOT expose `module` on the `loaded: false` variant', () => { + // Type-level assertion: the failure variant's keys exclude `module`. + // Extracting the failure variant from the union and checking its + // keys is safer than trying `@ts-expect-error` inside a runtime + // narrowing block, which is sensitive to unrelated TS lenience. + type FailureVariant = Extract + expectTypeOf().toEqualTypeOf<'loaded' | 'reason'>() + }) + }) +})