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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/agent/core/domain/harness/index.ts
Original file line number Diff line number Diff line change
@@ -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'
93 changes: 93 additions & 0 deletions src/agent/core/domain/harness/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -169,3 +179,86 @@ export const EvaluationScenarioSchema = z
.strict()
export type EvaluationScenario = z.input<typeof EvaluationScenarioSchema>
export type ValidatedEvaluationScenario = z.output<typeof EvaluationScenarioSchema>

// ---------------------------------------------------------------------------
// 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'
Comment thread
danhdoan marked this conversation as resolved.
Comment thread
danhdoan marked this conversation as resolved.
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<CurateResult>
readonly readFile: (filePath: string, options?: ReadFileOptions) => Promise<FileContent>
}

/**
* 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<CurateResult>
readonly meta: () => HarnessMeta
readonly query?: (ctx: HarnessContext) => Promise<unknown>
Comment thread
danhdoan marked this conversation as resolved.
}

/**
* 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}
Comment thread
danhdoan marked this conversation as resolved.
Comment thread
danhdoan marked this conversation as resolved.
125 changes: 125 additions & 0 deletions test/unit/agent/harness/types-context.test.ts
Original file line number Diff line number Diff line change
@@ -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'],
Comment thread
danhdoan marked this conversation as resolved.
Comment thread
danhdoan marked this conversation as resolved.
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<HarnessModule>()
expectTypeOf(result.version).toEqualTypeOf<HarnessVersion>()
}
})

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<HarnessLoadResult, {loaded: false}>
expectTypeOf<keyof FailureVariant>().toEqualTypeOf<'loaded' | 'reason'>()
})
})
})
Loading