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
11 changes: 11 additions & 0 deletions .changeset/generic-environment-create-idempotency.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions packages/agent-interface/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
75 changes: 74 additions & 1 deletion packages/agent-interface/src/environment-provider.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(
Expand Down
86 changes: 86 additions & 0 deletions packages/agent-interface/src/environment-runtime.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -622,11 +624,88 @@ export interface CreateAgentEnvironmentInput {
secrets?: string[] | Record<string, string>;
metadata?: Record<string, unknown>;
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<string, unknown>;
}

/**
* 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<T> {
readonly digest: Sha256Digest;
readonly pending: Promise<T>;
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<T>(
records: Map<string, AgentEnvironmentCreateIdempotencyRecord<T>>,
input: CreateAgentEnvironmentInput,
create: () => Promise<T>,
): Promise<T> {
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<T> = {
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;
Expand All @@ -636,6 +715,13 @@ export interface AgentEnvironmentProvider {
validateProfile?(
profile: AgentProfileRef,
): AgentProfileValidationResult | Promise<AgentProfileValidationResult>;
/**
* 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<AgentEnvironment>;
get?(id: string, options?: { signal?: AbortSignal }): Promise<AgentEnvironment | null>;
list?(query?: AgentEnvironmentQuery, options?: { signal?: AbortSignal }): Promise<AgentEnvironmentSummary[]>;
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-interface/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {
AgentNativeContextContinuationResultSchema,
AgentTurnInputSchema,
AgentTurnResultSchema,
agentEnvironmentCreateInputDigest,
agentNativeContextContinuationResultMatchesRequest,
AccountUsageSchema,
AgentEnvironmentObservationSchema,
Expand Down Expand Up @@ -57,6 +58,7 @@ export {
agentInteractiveSessionRunRef,
agentInteractiveSessionStatusMatchesRef,
exactAgentInteractiveSessionStart,
createAgentEnvironmentWithIdempotency,
TerminalAttachRequestSchema,
TerminalAttachResultSchema,
TerminalDetachAckSchema,
Expand Down
27 changes: 27 additions & 0 deletions packages/agent-provider-cli-bridge/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | undefined;
const profile: AgentProfile = {
Expand Down
56 changes: 36 additions & 20 deletions packages/agent-provider-cli-bridge/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -23,6 +26,10 @@ export function createCliBridgeProvider(
): AgentEnvironmentProvider {
assertCliBridgeProviderOptions(options);
const name = options.name ?? "cli-bridge";
const createRecords = new Map<
string,
AgentEnvironmentCreateIdempotencyRecord<AgentEnvironment>
>();
// 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.
Expand All @@ -35,29 +42,38 @@ export function createCliBridgeProvider(
observation: narrowedCliBridgeObservation(declared.observation, options),
};
};
const createEnvironment = async (
input: CreateAgentEnvironmentInput,
): Promise<AgentEnvironment> => {
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) {
Expand Down
23 changes: 19 additions & 4 deletions packages/agent-provider-computesdk/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { createAgentEnvironmentWithIdempotency } from "@tangle-network/agent-interface/environment-provider";
import type {
AgentEnvironment,
AgentEnvironmentCapabilities,
AgentEnvironmentEvent,
AgentEnvironmentCreateIdempotencyRecord,
AgentEnvironmentProvider,
AgentEnvironmentQuery,
AgentEnvironmentSummary,
Expand Down Expand Up @@ -49,14 +51,27 @@ export interface ComputeSdkProviderOptions {

export function createComputeSdkProvider(options: ComputeSdkProviderOptions): AgentEnvironmentProvider {
const name = options.name ?? "computesdk";
const createRecords = new Map<
string,
AgentEnvironmentCreateIdempotencyRecord<AgentEnvironment>
>();
const createEnvironment = async (
input: CreateAgentEnvironmentInput,
): Promise<AgentEnvironment> => {
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
? {
Expand Down
Loading
Loading