From 46a22ad3b48a9bf9a6bedbaf469f9c97ac3c9ec6 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sat, 23 May 2026 02:13:39 +0300 Subject: [PATCH] feat: AGENT_PROFILE_KINDS + toAgentProfileJson + buildSandboxAgentProfileCell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #82. 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 `AgentProfileJson`, 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. Exports: - `AGENT_PROFILE_KINDS` — typed const with `SANDBOX_AGENT_PROFILE`. Open for extension when new profile shapes land. - `toAgentProfileJson(value)` — JSON round-trip with `AgentProfileCell- ValidationError` on functions / BigInt / cycles / `undefined` results. - `buildSandboxAgentProfileCell(profile, rest)` — higher-level wrapper that hard-codes `profileId = ${name}@${version}` and the sandbox kind, reserving the manual `buildAgentProfileCell` call for advanced cases. 11 new tests; full suite 1326/1326 pass; `tsc --noEmit` clean. Tests explicitly verify the cross-product invariant — a hand-rolled cell call following the README recipe produces the identical hash to the helper. --- src/agent-profile-cell.ts | 105 ++++++++++++++++++++++++++ src/index.ts | 5 ++ tests/agent-profile-cell.test.ts | 126 +++++++++++++++++++++++++++++++ 3 files changed, 236 insertions(+) diff --git a/src/agent-profile-cell.ts b/src/agent-profile-cell.ts index cf3a6f1..99ccee8 100644 --- a/src/agent-profile-cell.ts +++ b/src/agent-profile-cell.ts @@ -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: }`. + * + * 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, +): Promise { + 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), + }, + }) +} diff --git a/src/index.ts b/src/index.ts index 9607371..f33a148 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' diff --git a/tests/agent-profile-cell.test.ts b/tests/agent-profile-cell.test.ts index 4173b93..4822212 100644 --- a/tests/agent-profile-cell.test.ts +++ b/tests/agent-profile-cell.test.ts @@ -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' @@ -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 = { 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) + }) +})