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
105 changes: 105 additions & 0 deletions src/agent-profile-cell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,3 +357,108 @@ function requireSha256Hex(value: unknown, path: string): string {
}
return value
}

// ── Consumer helpers ─────────────────────────────────────────────────
//
// Two pieces of boilerplate every product consuming `buildAgentProfileCell`
// has been duplicating (gtm-agent #137, blueprint-agent #1756/#1757):
//
// 1. A `JSON.parse(JSON.stringify(value))` helper that canonicalizes an
// arbitrary sandbox-SDK `AgentProfile` into the recursive
// `AgentProfileJson` shape, with a fail-loud error when the profile
// is not JSON-serializable.
//
// 2. The magic string `'sandbox-agent-profile'` for `sourceProfile.kind`.
//
// Both belong here so the cross-product cell join (same canonical profile
// hashes to the same `sourceProfile.hash` across products) is enforced by
// the type system, not by every consumer remembering to do it right.
// See blueprint-agent issue tangle-network/agent-eval#82.

/** Canonical `sourceProfile.kind` values. Two products fingerprinting the
* same canonical profile MUST use the same kind for their cells to share
* `sourceProfile.hash`. Extend rather than create new strings — adding a
* new kind is a deliberate cross-product schema change. */
export const AGENT_PROFILE_KINDS = {
/** A profile declared via `defineAgentProfile(...)` from
* `@tangle-network/sandbox`. The default kind for sandbox-hosted
* products (gtm-agent, blueprint-agent, sandbox, evals). */
SANDBOX_AGENT_PROFILE: 'sandbox-agent-profile',
} as const

export type AgentProfileKind = (typeof AGENT_PROFILE_KINDS)[keyof typeof AGENT_PROFILE_KINDS]

/** Canonicalize an arbitrary value into `AgentProfileJson` by JSON
* round-trip. Throws when the value contains anything not representable
* as JSON (functions, BigInt, cycles) — non-portable profiles fail loud
* rather than silently dropping fields. */
export function toAgentProfileJson(value: unknown): AgentProfileJson {
let serialized: string | undefined
try {
serialized = JSON.stringify(value)
} catch (err) {
throw new AgentProfileCellValidationError(
`agent profile must be JSON-serializable: ${err instanceof Error ? err.message : String(err)}`,
'sourceProfile.profile',
)
}
if (serialized === undefined) {
throw new AgentProfileCellValidationError(
'agent profile must be JSON-serializable (got undefined after JSON.stringify)',
'sourceProfile.profile',
)
}
return JSON.parse(serialized) as AgentProfileJson
}

/** Minimal shape required of any sandbox-SDK `AgentProfile` — anything
* with a non-empty `name` and `version` plus JSON-serializable contents.
* Compatible with `defineAgentProfile(...)` output from
* `@tangle-network/sandbox`; products that have not yet declared a real
* profile can pass a `{ name, version, ...metadata }` stub. */
export interface SandboxAgentProfileLike {
name: string
version: string
[key: string]: unknown
}

/** Higher-level helper that hard-codes the canonical
* `sandbox-agent-profile` kind plus the JSON canonicalization. Equivalent
* to calling `buildAgentProfileCell` with `profileId = \`${name}@${version}\``
* and `sourceProfile = { kind: SANDBOX_AGENT_PROFILE, profile: <round-tripped> }`.
*
* Use this from any product consuming a sandbox-SDK `AgentProfile`; the
* manual `buildAgentProfileCell` call is reserved for advanced cases
* (custom kinds, pre-computed source hashes, alternate profileId
* conventions). */
export async function buildSandboxAgentProfileCell(
profile: SandboxAgentProfileLike,
input: Omit<AgentProfileCellInput, 'profileId' | 'sourceProfile'>,
): Promise<AgentProfileCell> {
if (!profile || typeof profile !== 'object') {
throw new AgentProfileCellValidationError(
'sandbox AgentProfile must be an object',
'profile',
)
}
if (typeof profile.name !== 'string' || profile.name.length === 0) {
throw new AgentProfileCellValidationError(
'sandbox AgentProfile must have a non-empty `name`',
'profile.name',
)
}
if (typeof profile.version !== 'string' || profile.version.length === 0) {
throw new AgentProfileCellValidationError(
'sandbox AgentProfile must have a non-empty `version`',
'profile.version',
)
}
return buildAgentProfileCell({
...input,
profileId: `${profile.name}@${profile.version}`,
sourceProfile: {
kind: AGENT_PROFILE_KINDS.SANDBOX_AGENT_PROFILE,
profile: toAgentProfileJson(profile),
},
})
}
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,22 @@ export type {
AgentProfileDimensionValue,
AgentProfileHarness,
AgentProfileJson,
AgentProfileKind,
AgentProfileSource,
AgentProfileSourceInput,
SandboxAgentProfileLike,
} from './agent-profile-cell'
export {
AGENT_PROFILE_KINDS,
AgentProfileCellValidationError,
agentProfileCellHashMaterial,
agentProfileCellKey,
assertRunAgentProfileCell,
buildAgentProfileCell,
buildSandboxAgentProfileCell,
groupRunsByAgentProfileCell,
requireAgentProfileCell,
toAgentProfileJson,
validateAgentProfileCell,
verifyAgentProfileCell,
} from './agent-profile-cell'
Expand Down
126 changes: 126 additions & 0 deletions tests/agent-profile-cell.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { describe, expect, it } from 'vitest'
import {
AGENT_PROFILE_KINDS,
type AgentProfileCellInput,
AgentProfileCellValidationError,
agentProfileCellKey,
assertRunAgentProfileCell,
buildAgentProfileCell,
buildSandboxAgentProfileCell,
groupRunsByAgentProfileCell,
requireAgentProfileCell,
toAgentProfileJson,
validateAgentProfileCell,
verifyAgentProfileCell,
} from '../src/agent-profile-cell'
Expand Down Expand Up @@ -126,3 +129,126 @@ describe('agent profile cells', () => {
).rejects.toThrow(/does not match model/)
})
})

// ── Consumer helpers ─────────────────────────────────────────────────

describe('AGENT_PROFILE_KINDS', () => {
it('exposes the canonical sandbox-agent-profile kind', () => {
expect(AGENT_PROFILE_KINDS.SANDBOX_AGENT_PROFILE).toBe('sandbox-agent-profile')
})
})

describe('toAgentProfileJson', () => {
it('round-trips a JSON-serializable object', () => {
const profile = { name: 'x', version: '1.0', nested: { arr: [1, 'a', null, true] } }
expect(toAgentProfileJson(profile)).toEqual({
name: 'x',
version: '1.0',
nested: { arr: [1, 'a', null, true] },
})
})

it('throws AgentProfileCellValidationError when the value is not JSON-serializable (function at top level)', () => {
expect(() => toAgentProfileJson(() => 1)).toThrow(AgentProfileCellValidationError)
expect(() => toAgentProfileJson(() => 1)).toThrow(/JSON-serializable/)
})

it('throws on a circular reference', () => {
const cyclic: Record<string, unknown> = { name: 'x', version: '1.0' }
cyclic.self = cyclic
expect(() => toAgentProfileJson(cyclic)).toThrow(AgentProfileCellValidationError)
})

it('throws on a BigInt anywhere in the payload', () => {
expect(() => toAgentProfileJson({ n: 1n })).toThrow(AgentProfileCellValidationError)
})
})

describe('buildSandboxAgentProfileCell', () => {
const profile = {
name: 'test-agent',
version: '0.1.0',
prompt: { system: 'You are a test agent.' },
capabilities: { code: true },
}

it('hard-codes profileId = `${name}@${version}` and the sandbox-agent-profile kind', async () => {
const cell = await buildSandboxAgentProfileCell(profile, {
harness: { id: 'test-harness', version: 'v1' },
model: 'claude-sonnet-4-6',
promptHash: 'p'.repeat(64),
dimensions: { backend: 'opencode' },
})
expect(cell.profileId).toBe('test-agent@0.1.0')
expect(cell.sourceProfile.kind).toBe(AGENT_PROFILE_KINDS.SANDBOX_AGENT_PROFILE)
expect(cell.sourceProfile.hash).toMatch(/^[0-9a-f]{64}$/)
expect(cell.harness?.id).toBe('test-harness')
expect(cell.dimensions?.backend).toBe('opencode')
expect(await verifyAgentProfileCell(cell)).toBe(true)
})

it('produces a cell whose sourceProfile.hash + cellId equal a hand-rolled buildAgentProfileCell of the documented recipe', async () => {
// The whole point — every consumer using the helper agrees on the
// canonical hash. A hand-rolled call following the README recipe
// MUST produce the same hash.
const handCell = await buildAgentProfileCell({
profileId: `${profile.name}@${profile.version}`,
sourceProfile: {
kind: AGENT_PROFILE_KINDS.SANDBOX_AGENT_PROFILE,
profile: toAgentProfileJson(profile),
},
model: 'claude-sonnet-4-6',
promptHash: 'p'.repeat(64),
})
const helperCell = await buildSandboxAgentProfileCell(profile, {
model: 'claude-sonnet-4-6',
promptHash: 'p'.repeat(64),
})
expect(helperCell.sourceProfile.hash).toBe(handCell.sourceProfile.hash)
expect(helperCell.cellId).toBe(handCell.cellId)
})

it('rejects profiles missing `name` or `version`', async () => {
await expect(
buildSandboxAgentProfileCell({ name: '', version: '0.1.0' }, {}),
).rejects.toThrow(/non-empty `name`/)
await expect(
buildSandboxAgentProfileCell({ name: 'x', version: '' }, {}),
).rejects.toThrow(/non-empty `version`/)
})

it('rejects non-object input', async () => {
await expect(
buildSandboxAgentProfileCell(null as never, {}),
).rejects.toThrow(/must be an object/)
await expect(
buildSandboxAgentProfileCell('a profile' as never, {}),
).rejects.toThrow(/must be an object/)
})

it('passes through harness, model, promptHash, dimensions verbatim', async () => {
const cell = await buildSandboxAgentProfileCell(profile, {
harness: { id: 'h1', version: 'v2.3' },
model: 'gpt-5-1',
promptHash: 'q'.repeat(64),
dimensions: { backend: 'codex', verticalSlug: 'biz', cliBridge: true },
})
expect(cell.harness).toEqual({ id: 'h1', version: 'v2.3' })
expect(cell.model).toBe('gpt-5-1')
expect(cell.promptHash).toBe('q'.repeat(64))
expect(cell.dimensions).toEqual({ backend: 'codex', cliBridge: true, verticalSlug: 'biz' })
})

it('two callers fingerprinting the SAME profile object share `sourceProfile.hash` (cross-product join)', async () => {
// Property test for the cross-product cell join — the entire reason
// this helper exists. Two products MUST hash identically.
const a = await buildSandboxAgentProfileCell(profile, {
model: 'm', promptHash: 'a'.repeat(64), dimensions: { backend: 'x' },
})
const b = await buildSandboxAgentProfileCell(profile, {
model: 'm2', promptHash: 'b'.repeat(64), dimensions: { backend: 'y' },
})
expect(a.sourceProfile.hash).toBe(b.sourceProfile.hash)
expect(a.cellId).not.toBe(b.cellId)
})
})
Loading