diff --git a/.changeset/generic-environment-create-idempotency.md b/.changeset/generic-environment-create-idempotency.md new file mode 100644 index 0000000..c4d5d2f --- /dev/null +++ b/.changeset/generic-environment-create-idempotency.md @@ -0,0 +1,11 @@ +--- +"@tangle-network/agent-interface": patch +"@tangle-network/agent-provider-testkit": patch +"@tangle-network/agent-provider-tangle": patch +"@tangle-network/agent-provider-cli-bridge": patch +"@tangle-network/agent-provider-e2b": patch +"@tangle-network/agent-provider-computesdk": patch +"@tangle-network/agent-provider-daytona": patch +--- + +Define and enforce canonical idempotency for generic environment creation. diff --git a/packages/agent-interface/README.md b/packages/agent-interface/README.md index 9107688..cd5e625 100644 --- a/packages/agent-interface/README.md +++ b/packages/agent-interface/README.md @@ -52,6 +52,13 @@ Every returned resource repeats and validates that identity, lookups recover rem A checkpoint with dependent forks returns `in_use` plus the blocking environment identifiers and remains recoverable until those forks are destroyed. The older `checkpoint()` and `fork()` methods remain source-compatible for providers that have not yet implemented recovery semantics, but clients must not present them as durable workspace branching. +`CreateAgentEnvironmentInput.idempotencyKey` makes generic environment creation one retry-safe operation. +When a caller repeats that key, the provider must canonicalize every create field except the key and attempt signal. +The same canonical input must return or reconstruct the same environment, including after an ambiguous provider response. +The same key with any changed create field must reject before a second create effect. +Providers backed by a remote service must forward the key and retain its canonical input through environment reconstruction. +The existing `AgentEnvironmentProvider.create()` method carries this contract; it does not add a second create method or capability flag. + All new wire values have exported Zod schemas on the package root. Omitting `interactions` and `nativeContinuation`, or leaving the three durable branching flags false, is the compatible declaration for existing providers. diff --git a/packages/agent-interface/src/environment-provider.test.ts b/packages/agent-interface/src/environment-provider.test.ts index ac5fbc0..696b8b0 100644 --- a/packages/agent-interface/src/environment-provider.test.ts +++ b/packages/agent-interface/src/environment-provider.test.ts @@ -1,9 +1,12 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { AgentEnvironmentCapabilitiesSchema, AgentNativeContextContinuationResultSchema, agentNativeContextContinuationResultMatchesRequest, + agentEnvironmentCreateInputDigest, + createAgentEnvironmentWithIdempotency, } from "./environment-provider.js"; +import type { AgentEnvironmentCreateIdempotencyRecord } from "./environment-provider.js"; import { nativeContextContinuationRequestDigest, nativeContextContinuationTurnDigest, @@ -68,6 +71,76 @@ const capabilities = { }, }; +describe("generic environment create idempotency", () => { + const input = { + profile: { name: "worker" }, + metadata: { z: 1, a: 2 }, + idempotencyKey: "create-1", + signal: new AbortController().signal, + }; + + it("uses canonical create material without the key or attempt signal", () => { + expect( + agentEnvironmentCreateInputDigest(input), + ).toBe( + agentEnvironmentCreateInputDigest({ + signal: new AbortController().signal, + idempotencyKey: "create-1", + metadata: { a: 2, z: 1 }, + profile: { name: "worker" }, + }), + ); + expect( + agentEnvironmentCreateInputDigest({ ...input, metadata: { a: 3, z: 1 } }), + ).not.toBe(agentEnvironmentCreateInputDigest(input)); + }); + + it("coalesces same-key retries and rejects changed input", async () => { + const records = new Map< + string, + AgentEnvironmentCreateIdempotencyRecord<{ id: string }> + >(); + const create = vi.fn(async () => ({ id: "environment-1" })); + + const first = await createAgentEnvironmentWithIdempotency( + records, + input, + create, + ); + const replay = await createAgentEnvironmentWithIdempotency( + records, + { + profile: { name: "worker" }, + metadata: { a: 2, z: 1 }, + idempotencyKey: "create-1", + signal: new AbortController().signal, + }, + create, + ); + + expect(replay).toBe(first); + expect(create).toHaveBeenCalledOnce(); + await expect( + createAgentEnvironmentWithIdempotency( + records, + { ...input, metadata: { a: 3, z: 1 } }, + create, + ), + ).rejects.toThrow(/conflicts with a different create input/); + expect(create).toHaveBeenCalledOnce(); + + const aborted = new AbortController(); + aborted.abort(new Error("retry cancelled")); + await expect( + createAgentEnvironmentWithIdempotency( + records, + { ...input, signal: aborted.signal }, + create, + ), + ).rejects.toThrow("retry cancelled"); + }); +}); + describe("AgentEnvironmentCapabilitiesSchema", () => { it("accepts a complete strict capability document", () => { expect(AgentEnvironmentCapabilitiesSchema.parse(capabilities)).toEqual( diff --git a/packages/agent-interface/src/environment-runtime.ts b/packages/agent-interface/src/environment-runtime.ts index 28fef78..55e3a12 100644 --- a/packages/agent-interface/src/environment-runtime.ts +++ b/packages/agent-interface/src/environment-runtime.ts @@ -1,4 +1,6 @@ import { z } from "zod"; +import { canonicalCandidateDigest } from "./agent-candidate-schema-common.js"; +import type { Sha256Digest } from "./agent-candidate.js"; import type { AgentProfileCapabilities, AgentProfileValidationResult } from "./agent-profile.js"; import type { InputPart } from "./parts.js"; import type { StreamEvent } from "./stream-events.js"; @@ -622,11 +624,88 @@ export interface CreateAgentEnvironmentInput { secrets?: string[] | Record; metadata?: Record; name?: string; + /** + * Stable identity for one logical environment create. + * + * When present, the provider must use this key as one idempotent operation: + * the same key with canonically equal create input must return or reconstruct + * the same environment, while a different input must be rejected. + * `signal` controls one attempt and is not part of create identity. + */ idempotencyKey?: string; signal?: AbortSignal; providerOptions?: Record; } +/** + * Compute the canonical identity of a generic environment create request. + * + * The operation key names the request and the abort signal controls one + * attempt, so neither belongs in the input identity. Every other field is + * canonicalized with the shared RFC 8785 JSON representation. + * @internal + */ +export function agentEnvironmentCreateInputDigest( + input: CreateAgentEnvironmentInput, +): Sha256Digest { + const { idempotencyKey: _idempotencyKey, signal: _signal, ...material } = input; + return canonicalCandidateDigest({ + kind: "agent-environment-create.v1", + input: material, + }); +} + +/** @internal State held by one provider adapter for keyed create retries. */ +export interface AgentEnvironmentCreateIdempotencyRecord { + readonly digest: Sha256Digest; + readonly pending: Promise; + environment?: T; +} + +/** + * Apply the generic create contract to one provider adapter's keyed requests. + * + * The provider's backing service remains responsible for retaining the key + * across adapter reconstruction. This helper coalesces concurrent retries and + * rejects collisions before the provider performs another create effect. + * @internal + */ +export async function createAgentEnvironmentWithIdempotency( + records: Map>, + input: CreateAgentEnvironmentInput, + create: () => Promise, +): Promise { + input.signal?.throwIfAborted(); + const key = input.idempotencyKey; + if (key === undefined) return create(); + + const digest = agentEnvironmentCreateInputDigest(input); + const existing = records.get(key); + if (existing !== undefined) { + if (existing.digest !== digest) { + throw new Error( + "agent environment create idempotency key conflicts with a different create input", + ); + } + return existing.environment ?? existing.pending; + } + + const pending = Promise.resolve().then(create); + const record: AgentEnvironmentCreateIdempotencyRecord = { + digest, + pending, + }; + records.set(key, record); + try { + const environment = await pending; + if (records.get(key) === record) record.environment = environment; + return environment; + } catch (error) { + if (records.get(key) === record) records.delete(key); + throw error; + } +} + export interface AgentEnvironmentProvider { readonly name: string; readonly exactProcess?: AgentExactProcessProvider; @@ -636,6 +715,13 @@ export interface AgentEnvironmentProvider { validateProfile?( profile: AgentProfileRef, ): AgentProfileValidationResult | Promise; + /** + * Create or reconstruct one environment. + * + * With `input.idempotencyKey`, the provider must return the same environment + * for the same canonical input and reject any changed input before creating. + * Without a key, each call may create a fresh environment. + */ create(input: CreateAgentEnvironmentInput): Promise; get?(id: string, options?: { signal?: AbortSignal }): Promise; list?(query?: AgentEnvironmentQuery, options?: { signal?: AbortSignal }): Promise; diff --git a/packages/agent-interface/src/index.ts b/packages/agent-interface/src/index.ts index bafdc8a..062d2d7 100644 --- a/packages/agent-interface/src/index.ts +++ b/packages/agent-interface/src/index.ts @@ -15,6 +15,7 @@ export { AgentNativeContextContinuationResultSchema, AgentTurnInputSchema, AgentTurnResultSchema, + agentEnvironmentCreateInputDigest, agentNativeContextContinuationResultMatchesRequest, AccountUsageSchema, AgentEnvironmentObservationSchema, @@ -57,6 +58,7 @@ export { agentInteractiveSessionRunRef, agentInteractiveSessionStatusMatchesRef, exactAgentInteractiveSessionStart, + createAgentEnvironmentWithIdempotency, TerminalAttachRequestSchema, TerminalAttachResultSchema, TerminalDetachAckSchema, diff --git a/packages/agent-provider-cli-bridge/src/index.test.ts b/packages/agent-provider-cli-bridge/src/index.test.ts index f330764..17d425e 100644 --- a/packages/agent-provider-cli-bridge/src/index.test.ts +++ b/packages/agent-provider-cli-bridge/src/index.test.ts @@ -27,6 +27,33 @@ describe("createCliBridgeProvider", () => { expect(called).toBe(false); }); + it("reuses a keyed generic create and rejects changed input", async () => { + const provider = createCliBridgeProvider({ + baseUrl: "http://bridge.local", + fetch: async () => new Response(), + }); + const input = { + profile: { name: "worker", harness: "pi" as const }, + metadata: { z: "last", a: "first" }, + idempotencyKey: "environment-create-1", + }; + + const first = await provider.create(input); + const replay = await provider.create({ + idempotencyKey: input.idempotencyKey, + metadata: { a: "first", z: "last" }, + profile: { harness: "pi", name: "worker" }, + }); + + expect(replay).toBe(first); + await expect( + provider.create({ + ...input, + profile: { name: "different-worker", harness: "pi" }, + }), + ).rejects.toThrow(/conflicts with a different create input/); + }); + it("keeps profile authority separate from the task and forwards it unchanged", async () => { let body: Record | undefined; const profile: AgentProfile = { diff --git a/packages/agent-provider-cli-bridge/src/index.ts b/packages/agent-provider-cli-bridge/src/index.ts index dd9c706..db56ed1 100644 --- a/packages/agent-provider-cli-bridge/src/index.ts +++ b/packages/agent-provider-cli-bridge/src/index.ts @@ -1,5 +1,8 @@ +import { createAgentEnvironmentWithIdempotency } from "@tangle-network/agent-interface/environment-provider"; import type { + AgentEnvironment, AgentEnvironmentCapabilities, + AgentEnvironmentCreateIdempotencyRecord, AgentEnvironmentProvider, CreateAgentEnvironmentInput, } from "@tangle-network/agent-interface/environment-provider"; @@ -23,6 +26,10 @@ export function createCliBridgeProvider( ): AgentEnvironmentProvider { assertCliBridgeProviderOptions(options); const name = options.name ?? "cli-bridge"; + const createRecords = new Map< + string, + AgentEnvironmentCreateIdempotencyRecord + >(); // The observation surfaces are declared as intent and narrowed to the // sources this bridge can put a value on, so the environment offers the // operation exactly where the document claims it. @@ -35,29 +42,38 @@ export function createCliBridgeProvider( observation: narrowedCliBridgeObservation(declared.observation, options), }; }; + const createEnvironment = async ( + input: CreateAgentEnvironmentInput, + ): Promise => { + if (typeof input.profile === "string") { + throw new Error( + `createCliBridgeProvider requires an inline AgentProfile; named profile "${input.profile}" is unsupported`, + ); + } + const environmentInput: CreateAgentEnvironmentInput = { + ...input, + profile: snapshotAgentProfile(input.profile), + }; + const environmentId = input.idempotencyKey ?? crypto.randomUUID(); + return createCliBridgeEnvironment({ + options, + providerName: name, + environmentInput, + environmentId, + allowDispatch: true, + cancelRunsOnDestroy: true, + capabilities: resolveCapabilities(), + }); + }; return { name, capabilities: resolveCapabilities, - async create(input) { - if (typeof input.profile === "string") { - throw new Error( - `createCliBridgeProvider requires an inline AgentProfile; named profile "${input.profile}" is unsupported`, - ); - } - const environmentInput: CreateAgentEnvironmentInput = { - ...input, - profile: snapshotAgentProfile(input.profile), - }; - const environmentId = input.idempotencyKey ?? crypto.randomUUID(); - return createCliBridgeEnvironment({ - options, - providerName: name, - environmentInput, - environmentId, - allowDispatch: true, - cancelRunsOnDestroy: true, - capabilities: resolveCapabilities(), - }); + create(input) { + return createAgentEnvironmentWithIdempotency( + createRecords, + input, + () => createEnvironment(input), + ); }, async get(id) { if (id.length === 0 || id.trim() !== id) { diff --git a/packages/agent-provider-computesdk/src/index.ts b/packages/agent-provider-computesdk/src/index.ts index 0d806b5..a84e4d2 100644 --- a/packages/agent-provider-computesdk/src/index.ts +++ b/packages/agent-provider-computesdk/src/index.ts @@ -1,7 +1,9 @@ +import { createAgentEnvironmentWithIdempotency } from "@tangle-network/agent-interface/environment-provider"; import type { AgentEnvironment, AgentEnvironmentCapabilities, AgentEnvironmentEvent, + AgentEnvironmentCreateIdempotencyRecord, AgentEnvironmentProvider, AgentEnvironmentQuery, AgentEnvironmentSummary, @@ -49,14 +51,27 @@ export interface ComputeSdkProviderOptions { export function createComputeSdkProvider(options: ComputeSdkProviderOptions): AgentEnvironmentProvider { const name = options.name ?? "computesdk"; + const createRecords = new Map< + string, + AgentEnvironmentCreateIdempotencyRecord + >(); + const createEnvironment = async ( + input: CreateAgentEnvironmentInput, + ): Promise => { + const sandbox = await options.compute.sandbox.create( + options.mapCreateInput?.(input) ?? computeCreateOptions(input), + ); + return computeSandboxAsEnvironment(options, name, sandbox); + }; return { name, capabilities: () => options.capabilities ?? defaultComputeSdkCapabilities(), - async create(input) { - const sandbox = await options.compute.sandbox.create( - options.mapCreateInput?.(input) ?? computeCreateOptions(input), + create(input) { + return createAgentEnvironmentWithIdempotency( + createRecords, + input, + () => createEnvironment(input), ); - return computeSandboxAsEnvironment(options, name, sandbox); }, ...(options.compute.sandbox.getById ? { diff --git a/packages/agent-provider-daytona/src/index.ts b/packages/agent-provider-daytona/src/index.ts index c3c3176..b316ee4 100644 --- a/packages/agent-provider-daytona/src/index.ts +++ b/packages/agent-provider-daytona/src/index.ts @@ -1,7 +1,9 @@ +import { createAgentEnvironmentWithIdempotency } from "@tangle-network/agent-interface/environment-provider"; import type { AgentEnvironment, AgentEnvironmentCapabilities, AgentEnvironmentEvent, + AgentEnvironmentCreateIdempotencyRecord, AgentEnvironmentProvider, AgentEnvironmentQuery, AgentEnvironmentSummary, @@ -47,15 +49,28 @@ export interface DaytonaProviderOptions { export function createDaytonaProvider(options: DaytonaProviderOptions = {}): AgentEnvironmentProvider { const name = options.name ?? "daytona"; + const createRecords = new Map< + string, + AgentEnvironmentCreateIdempotencyRecord + >(); + const createEnvironment = async ( + input: CreateAgentEnvironmentInput, + ): Promise => { + const daytona = await resolveDaytona(options); + const sandbox = await daytona.create( + options.mapCreateInput?.(input) ?? daytonaCreateParams(input), + ); + return daytonaSandboxAsEnvironment(options, name, sandbox); + }; return { name, capabilities: () => options.capabilities ?? defaultDaytonaCapabilities(), - async create(input) { - const daytona = await resolveDaytona(options); - const sandbox = await daytona.create( - options.mapCreateInput?.(input) ?? daytonaCreateParams(input), + create(input) { + return createAgentEnvironmentWithIdempotency( + createRecords, + input, + () => createEnvironment(input), ); - return daytonaSandboxAsEnvironment(options, name, sandbox); }, async get(id) { const daytona = await resolveDaytona(options); diff --git a/packages/agent-provider-e2b/src/index.ts b/packages/agent-provider-e2b/src/index.ts index 2bdeaf7..492fb20 100644 --- a/packages/agent-provider-e2b/src/index.ts +++ b/packages/agent-provider-e2b/src/index.ts @@ -1,7 +1,9 @@ +import { createAgentEnvironmentWithIdempotency } from "@tangle-network/agent-interface/environment-provider"; import type { AgentEnvironment, AgentEnvironmentCapabilities, AgentEnvironmentEvent, + AgentEnvironmentCreateIdempotencyRecord, AgentEnvironmentProvider, AgentTurnInput, CreateAgentEnvironmentInput, @@ -41,16 +43,29 @@ export interface E2BProviderOptions { export function createE2BProvider(options: E2BProviderOptions = {}): AgentEnvironmentProvider { const name = options.name ?? "e2b"; + const createRecords = new Map< + string, + AgentEnvironmentCreateIdempotencyRecord + >(); + const createEnvironment = async ( + input: CreateAgentEnvironmentInput, + ): Promise => { + const Sandbox = options.Sandbox ?? (await loadE2BSandbox()); + const createOptions = + options.mapCreateInput?.(input) ?? + e2bCreateOptions(options, input); + const sandbox = await Sandbox.create(createOptions); + return e2bSandboxAsEnvironment(options, name, sandbox); + }; return { name, capabilities: () => options.capabilities ?? defaultE2BCapabilities(), - async create(input) { - const Sandbox = options.Sandbox ?? (await loadE2BSandbox()); - const createOptions = - options.mapCreateInput?.(input) ?? - e2bCreateOptions(options, input); - const sandbox = await Sandbox.create(createOptions); - return e2bSandboxAsEnvironment(options, name, sandbox); + create(input) { + return createAgentEnvironmentWithIdempotency( + createRecords, + input, + () => createEnvironment(input), + ); }, async get(id) { const Sandbox = options.Sandbox ?? (await loadE2BSandbox()); diff --git a/packages/agent-provider-tangle/src/index.test.ts b/packages/agent-provider-tangle/src/index.test.ts index c97133d..b469ff9 100644 --- a/packages/agent-provider-tangle/src/index.test.ts +++ b/packages/agent-provider-tangle/src/index.test.ts @@ -112,6 +112,57 @@ describe("createTangleProvider", () => { }); }); + it("reuses a keyed generic create and rejects changed input", async () => { + const box: SandboxInstanceLike = { + id: "sbx-generic-idempotency", + async *streamPrompt() {}, + }; + const create = vi.fn(async (_options?: CreateSandboxOptions) => box); + const provider = createTangleProvider({ client: { create } }); + const input = { + profile: { name: "worker" }, + metadata: { z: "last", a: "first" }, + name: "environment", + idempotencyKey: "environment-create-1", + }; + + const first = await provider.create(input); + const replay = await provider.create({ + idempotencyKey: input.idempotencyKey, + name: input.name, + metadata: { a: "first", z: "last" }, + profile: { name: "worker" }, + }); + + expect(replay).toBe(first); + expect(create).toHaveBeenCalledOnce(); + expect(create.mock.calls[0]?.[0]).toMatchObject({ + idempotencyKey: input.idempotencyKey, + }); + await expect( + provider.create({ ...input, name: "different-environment" }), + ).rejects.toThrow(/conflicts with a different create input/); + expect(create).toHaveBeenCalledOnce(); + }); + + it("does not let a custom mapper drop the generic create key", async () => { + const create = vi.fn(async () => { + throw new Error("not called"); + }); + const provider = createTangleProvider({ + client: { create }, + mapCreateInput: () => ({}), + }); + + await expect( + provider.create({ + profile: { name: "worker" }, + idempotencyKey: "environment-create-1", + }), + ).rejects.toThrow("must preserve input idempotencyKey"); + expect(create).not.toHaveBeenCalled(); + }); + it("rejects malformed configured capabilities at the provider boundary", async () => { const provider = createTangleProvider({ client: { diff --git a/packages/agent-provider-tangle/src/tangle-provider.ts b/packages/agent-provider-tangle/src/tangle-provider.ts index 6bfbd9f..7fc1495 100644 --- a/packages/agent-provider-tangle/src/tangle-provider.ts +++ b/packages/agent-provider-tangle/src/tangle-provider.ts @@ -1,10 +1,15 @@ -import { AgentEnvironmentCapabilitiesSchema } from "@tangle-network/agent-interface/environment-provider"; +import { + AgentEnvironmentCapabilitiesSchema, + createAgentEnvironmentWithIdempotency, +} from "@tangle-network/agent-interface/environment-provider"; import type { AgentEnvironment, AgentEnvironmentCapabilities, + AgentEnvironmentCreateIdempotencyRecord, AgentEnvironmentProvider, AgentEnvironmentQuery, AgentEnvironmentSummary, + CreateAgentEnvironmentInput, } from "@tangle-network/agent-interface/environment-provider"; import { createTangleExactProcessProvider, @@ -67,81 +72,102 @@ export function createTangleProvider( ); const resolveCapabilities = async (): Promise => narrowedProviderCapabilities(await resolveDeclaredCapabilities()); - return { - name: providerName, - ...(exactProcess ? { exactProcess } : {}), - capabilities: resolveCapabilities, - ...(options.validateProfile ? { validateProfile: options.validateProfile } : {}), - async create(input) { - assertCreateInputShape(input); - input.signal?.throwIfAborted(); - assertNoInlineSecretValues(input); - if (input.providerOptions && Object.keys(input.providerOptions).length > 0) { - throw new Error("Tangle create providerOptions are not supported"); + const createRecords = new Map< + string, + AgentEnvironmentCreateIdempotencyRecord + >(); + const createEnvironment = async ( + input: CreateAgentEnvironmentInput, + ): Promise => { + assertCreateInputShape(input); + input.signal?.throwIfAborted(); + assertNoInlineSecretValues(input); + if (input.providerOptions && Object.keys(input.providerOptions).length > 0) { + throw new Error("Tangle create providerOptions are not supported"); + } + // The sandbox stage narrows from the declared document, not the + // provider-boundary one: the client stage cannot observe box-scoped + // facts, so measured instance facts must decide them per sandbox. + const declaredCapabilities = await resolveDeclaredCapabilities(); + narrowedProviderCapabilities(declaredCapabilities); + const createOptions = + options.mapCreateInput?.(input) ?? + sandboxOptionsFromCreateInput(input, options.defaultBackend ?? "opencode"); + assertMappedCreateOptions(createOptions); + if ( + input.idempotencyKey !== undefined && + createOptions.idempotencyKey !== input.idempotencyKey + ) { + throw new Error( + "Tangle mapped create options must preserve input idempotencyKey", + ); + } + assertMappedSecretNames(createOptions); + input.signal?.throwIfAborted(); + const createPromise = options.client.create( + createOptions, + input.signal ? { signal: input.signal } : undefined, + ); + let box: Awaited; + try { + box = await awaitWithSignal(createPromise, input.signal); + } catch (error) { + if (input.signal?.aborted) { + void createPromise + .then(async (lateBox) => { + if (!lateBox.delete) { + attachCleanupHandle(error, lateBox); + return; + } + try { + await lateBox.delete(); + } catch (cleanupError) { + attachCleanupHandle(error, lateBox, cleanupError); + } + }) + .catch((lateError) => attachCleanupHandle(error, undefined, lateError)); } - // The sandbox stage narrows from the declared document, not the - // provider-boundary one: the client stage cannot observe box-scoped - // facts, so measured instance facts must decide them per sandbox. - const declaredCapabilities = await resolveDeclaredCapabilities(); - narrowedProviderCapabilities(declaredCapabilities); - const createOptions = - options.mapCreateInput?.(input) ?? - sandboxOptionsFromCreateInput(input, options.defaultBackend ?? "opencode"); - assertMappedCreateOptions(createOptions); - assertMappedSecretNames(createOptions); + throw error; + } + try { input.signal?.throwIfAborted(); - const createPromise = options.client.create( - createOptions, + const requestedResources = requestedResourceProfile(input.resources); + const environment = await sandboxInstanceAsEnvironment( + box, + providerName, + options.client, + declaredCapabilities, input.signal ? { signal: input.signal } : undefined, + requestedResources === undefined ? undefined : { resources: requestedResources }, ); - let box: Awaited; - try { - box = await awaitWithSignal(createPromise, input.signal); - } catch (error) { - if (input.signal?.aborted) { - void createPromise - .then(async (lateBox) => { - if (!lateBox.delete) { - attachCleanupHandle(error, lateBox); - return; - } - try { - await lateBox.delete(); - } catch (cleanupError) { - attachCleanupHandle(error, lateBox, cleanupError); - } - }) - .catch((lateError) => attachCleanupHandle(error, undefined, lateError)); - } - throw error; + input.signal?.throwIfAborted(); + return environment; + } catch (error) { + if (!box.delete) { + const baseError = error instanceof Error ? error : new Error(String(error)); + throw Object.assign(baseError, { cleanupHandle: box }); } try { - input.signal?.throwIfAborted(); - const requestedResources = requestedResourceProfile(input.resources); - const environment = await sandboxInstanceAsEnvironment( - box, - providerName, - options.client, - declaredCapabilities, - input.signal ? { signal: input.signal } : undefined, - requestedResources === undefined ? undefined : { resources: requestedResources }, - ); - input.signal?.throwIfAborted(); - return environment; - } catch (error) { - if (!box.delete) { - const baseError = error instanceof Error ? error : new Error(String(error)); - throw Object.assign(baseError, { cleanupHandle: box }); - } - try { - await box.delete(); - } catch (cleanupError) { - const combined = new AggregateError([error, cleanupError], "Tangle environment validation and cleanup both failed"); - attachCleanupHandle(combined, box, cleanupError); - throw combined; - } - throw error; + await box.delete(); + } catch (cleanupError) { + const combined = new AggregateError([error, cleanupError], "Tangle environment validation and cleanup both failed"); + attachCleanupHandle(combined, box, cleanupError); + throw combined; } + throw error; + } + }; + return { + name: providerName, + ...(exactProcess ? { exactProcess } : {}), + capabilities: resolveCapabilities, + ...(options.validateProfile ? { validateProfile: options.validateProfile } : {}), + create(input) { + return createAgentEnvironmentWithIdempotency( + createRecords, + input, + () => createEnvironment(input), + ); }, ...(options.client.get ? { diff --git a/packages/agent-provider-testkit/README.md b/packages/agent-provider-testkit/README.md index 5ce9f4e..2085947 100644 --- a/packages/agent-provider-testkit/README.md +++ b/packages/agent-provider-testkit/README.md @@ -18,8 +18,7 @@ await runAgentEnvironmentProviderConformance({ }) ``` -The checks create an environment, stream one turn, verify terminal completion, -exercise declared workspace methods, and destroy the environment. +The checks create an environment, repeat a keyed create with reordered fields, reject changed keyed input, stream one turn, verify terminal completion, exercise declared workspace methods, and destroy the environment. `runSessionReplayConformance()` dispatches a detached turn, rejects a competing run reference, requires stable event identifiers, replays after a cursor, and repeats the replay through a reconstructed session client. diff --git a/packages/agent-provider-testkit/src/control-conformance.test.ts b/packages/agent-provider-testkit/src/control-conformance.test.ts index 7e7265c..f42ba51 100644 --- a/packages/agent-provider-testkit/src/control-conformance.test.ts +++ b/packages/agent-provider-testkit/src/control-conformance.test.ts @@ -27,6 +27,12 @@ import { type WorkspaceCheckpointRef, type WorkspaceForkRequest, } from "@tangle-network/agent-interface"; +import { createAgentEnvironmentWithIdempotency } from "@tangle-network/agent-interface/environment-provider"; +import type { + AgentEnvironment, + AgentEnvironmentCreateIdempotencyRecord, + CreateAgentEnvironmentInput, +} from "@tangle-network/agent-interface/environment-provider"; import { runAgentEnvironmentProviderConformance, runInteractionResponseConformance, @@ -909,17 +915,15 @@ describe("capability denial", () => { const provider: AgentEnvironmentProvider = { name: "denial-fake", capabilities: () => disabledCapabilities(), - async create() { - return { - id: "environment-1", - provider: "denial-fake", - status: async () => "running", - async *stream() { - yield { type: "result", data: { finalText: "ok" } }; - }, - destroy: async () => {}, - }; - }, + create: conformanceCreate(async () => ({ + id: "environment-1", + provider: "denial-fake", + status: async () => "running", + async *stream() { + yield { type: "result", data: { finalText: "ok" } }; + }, + destroy: async () => {}, + })), }; const report = await runAgentEnvironmentProviderConformance({ @@ -967,26 +971,24 @@ describe("capability denial", () => { requestIdempotency: true, }, }), - async create() { - return { - id: "environment-1", - provider: "missing-native-continuation", - status: async () => "running", - async *stream() { - yield { type: "result", data: { finalText: "ok" } }; - }, - session: (id) => ({ - id, - status: async () => null, - async *events() {}, - result: async () => ({ text: "ok", success: true }), - prompt: async () => ({ text: "ok", success: true }), - contextBoundary: async () => null, - cancel: async () => {}, - }), - destroy: async () => {}, - }; - }, + create: conformanceCreate(async () => ({ + id: "environment-1", + provider: "missing-native-continuation", + status: async () => "running", + async *stream() { + yield { type: "result", data: { finalText: "ok" } }; + }, + session: (id) => ({ + id, + status: async () => null, + async *events() {}, + result: async () => ({ text: "ok", success: true }), + prompt: async () => ({ text: "ok", success: true }), + contextBoundary: async () => null, + cancel: async () => {}, + }), + destroy: async () => {}, + })), }; await expect( @@ -1049,19 +1051,17 @@ describe("capability denial", () => { const provider: AgentEnvironmentProvider = { name: "failing-stream", capabilities: () => disabledCapabilities(), - async create() { - return { - id: "environment-1", - provider: "failing-stream", - status: async () => "running", - async *stream() { - yield { type: "status", data: { status: "processing" } }; - }, - destroy: async () => { - destroyed += 1; - }, - }; - }, + create: conformanceCreate(async () => ({ + id: "environment-1", + provider: "failing-stream", + status: async () => "running", + async *stream() { + yield { type: "status", data: { status: "processing" } }; + }, + destroy: async () => { + destroyed += 1; + }, + })), }; await expect( @@ -1538,6 +1538,17 @@ function inMemoryWorkspaceBranching(): AgentWorkspaceBranching { }; } +function conformanceCreate( + create: () => Promise, +): AgentEnvironmentProvider["create"] { + const records = new Map< + string, + AgentEnvironmentCreateIdempotencyRecord + >(); + return (input: CreateAgentEnvironmentInput) => + createAgentEnvironmentWithIdempotency(records, input, create); +} + function disabledCapabilities() { return { profile: { diff --git a/packages/agent-provider-testkit/src/index.test.ts b/packages/agent-provider-testkit/src/index.test.ts index 96c6424..4419189 100644 --- a/packages/agent-provider-testkit/src/index.test.ts +++ b/packages/agent-provider-testkit/src/index.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "vitest"; import type { + AgentEnvironment, + AgentEnvironmentCreateIdempotencyRecord, AgentEnvironmentProvider, AgentExactProcessEnvironment, AgentTurnInput, } from "@tangle-network/agent-interface/environment-provider"; +import { createAgentEnvironmentWithIdempotency } from "@tangle-network/agent-interface/environment-provider"; import { runAgentEnvironmentProviderConformance, runAgentExactProcessProviderLifecycleChecks, @@ -17,6 +20,8 @@ describe("runAgentEnvironmentProviderConformance", () => { }); expect(report.provider).toBe("fake"); + expect(report.checked).toContain("create-idempotency"); + expect(report.checked).toContain("create-idempotency-collision"); expect(report.checked).toContain("stream"); expect(report.checked).toContain("workspace-exec"); }); @@ -61,6 +66,10 @@ describe("runAgentExactProcessProviderLifecycleChecks", () => { function fakeProvider(): AgentEnvironmentProvider { const files = new Map(); + const createRecords = new Map< + string, + AgentEnvironmentCreateIdempotencyRecord + >(); return { name: "fake", capabilities: () => ({ @@ -93,25 +102,29 @@ function fakeProvider(): AgentEnvironmentProvider { usage: true, confidential: false, }), - async create() { - return { - id: "env-1", - provider: "fake", - status: async () => "running", - async *stream(input: AgentTurnInput) { - yield { - type: "result", - data: { finalText: input.prompt ?? "ok" }, - usage: { inputTokens: 1, outputTokens: 1 }, - }; - }, - read: async (path: string) => files.get(path) ?? "", - write: async (path: string, content: string) => { - files.set(path, content); - }, - exec: async () => ({ exitCode: 0, stdout: "ok\n", stderr: "" }), - destroy: async () => {}, - }; + create(input) { + return createAgentEnvironmentWithIdempotency( + createRecords, + input, + async () => ({ + id: "env-1", + provider: "fake", + status: async () => "running", + async *stream(input: AgentTurnInput) { + yield { + type: "result", + data: { finalText: input.prompt ?? "ok" }, + usage: { inputTokens: 1, outputTokens: 1 }, + }; + }, + read: async (path: string) => files.get(path) ?? "", + write: async (path: string, content: string) => { + files.set(path, content); + }, + exec: async () => ({ exitCode: 0, stdout: "ok\n", stderr: "" }), + destroy: async () => {}, + }), + ); }, }; } diff --git a/packages/agent-provider-testkit/src/provider-conformance.ts b/packages/agent-provider-testkit/src/provider-conformance.ts index 33c4f94..007972e 100644 --- a/packages/agent-provider-testkit/src/provider-conformance.ts +++ b/packages/agent-provider-testkit/src/provider-conformance.ts @@ -1,5 +1,11 @@ import { AgentEnvironmentCapabilitiesSchema } from "@tangle-network/agent-interface/environment-provider"; -import type { ProviderConformanceOptions, ProviderConformanceReport } from "./conformance-types.js"; +import type { + CreateAgentEnvironmentInput, +} from "@tangle-network/agent-interface/environment-provider"; +import type { + ProviderConformanceOptions, + ProviderConformanceReport, +} from "./conformance-types.js"; import { assert, checkCapabilityExposure, checkWorkspace, collect, environmentCapabilityDocument, isTerminalEvent, withEnvironmentCleanup } from "./conformance-helpers.js"; export async function runAgentEnvironmentProviderConformance( @@ -19,12 +25,16 @@ export async function runAgentEnvironmentProviderConformance( assert(capabilities.workspace !== undefined, "capabilities.workspace is required", checked); checked.push("capabilities"); - const environment = await provider.create({ + const createInput: CreateAgentEnvironmentInput = { profile: { name: `${options.name}-profile` }, backend: "test", name: `${options.name}-environment`, ...(options.createInput ?? {}), - }); + }; + if (createInput.idempotencyKey === undefined) { + createInput.idempotencyKey = `${options.name}-environment-create`; + } + const environment = await provider.create(createInput); return withEnvironmentCleanup(environment, checked, async () => { assert(environment.id, "environment.id must be non-empty", checked); assert(environment.provider, "environment.provider must be non-empty", checked); @@ -84,6 +94,41 @@ export async function runAgentEnvironmentProviderConformance( } checked.push("create"); + const replayInput = Object.fromEntries( + Object.entries(createInput).reverse(), + ) as CreateAgentEnvironmentInput; + const replay = await provider.create(replayInput); + assert( + replay.id === environment.id && replay.provider === environment.provider, + "same create key and canonical input must return the same environment", + checked, + ); + checked.push("create-idempotency"); + + let collisionRejected = false; + let changedEnvironment: typeof environment | undefined; + try { + changedEnvironment = await provider.create({ + ...createInput, + name: `${createInput.name ?? options.name}-changed`, + }); + } catch { + collisionRejected = true; + } + if ( + changedEnvironment !== undefined && + (changedEnvironment.id !== environment.id || + changedEnvironment.provider !== environment.provider) + ) { + await changedEnvironment.destroy?.(); + } + assert( + collisionRejected, + "reusing a create key with changed input must reject", + checked, + ); + checked.push("create-idempotency-collision"); + const events = await collect( environment.stream({ prompt: options.prompt ?? "Return the word ok.",