diff --git a/.changeset/child-task-event.md b/.changeset/child-task-event.md new file mode 100644 index 0000000..61530d1 --- /dev/null +++ b/.changeset/child-task-event.md @@ -0,0 +1,9 @@ +--- +"@tangle-network/agent-interface": minor +--- + +Add the `child-task` stream event that reports a provider-native child task lifecycle with a stable `childId`, an optional `parentChildId`, a status, start and update times, optional runner, model, usage, and terminal reason, and a `sourceEventId` for replay deduplication. +The runtime schema validates it as a member of `CanonicalStreamEventSchema` and `RuntimeEventEnvelope`. + +Add the optional `AgentEnvironment.creation` verdict (`created` | `replayed`) reporting what the create call that returned the object did. +The shared idempotency helper now returns a `replayed` view for every same-key call after the first, so a caller can decide whether a failed follow-up may destroy the environment. diff --git a/.changeset/environment-creation-verdict.md b/.changeset/environment-creation-verdict.md new file mode 100644 index 0000000..c6b26a8 --- /dev/null +++ b/.changeset/environment-creation-verdict.md @@ -0,0 +1,7 @@ +--- +"@tangle-network/agent-provider-tangle": patch +"@tangle-network/agent-provider-cli-bridge": patch +"@tangle-network/agent-provider-testkit": patch +--- + +Report the `AgentEnvironment.creation` verdict: the Tangle provider maps the platform create receipt, the CLI Bridge provider states `created` for the call that builds the environment handle, and the provider conformance rejects a same-key replay that claims it created the environment. diff --git a/packages/agent-interface/README.md b/packages/agent-interface/README.md index 530725b..a70d53e 100644 --- a/packages/agent-interface/README.md +++ b/packages/agent-interface/README.md @@ -33,9 +33,15 @@ The public `AgentInstanceRecord` contains a credential-free profile identity, no `AgentRunControlRef` identifies a retained run without depending on a live JavaScript object and may carry the provider's admission digest so reconstruction can reject changed-input reuse. `RuntimeEventEnvelope` adds stable run, event, sequence, cursor, and timestamp fields around the existing `StreamEvent` union, and its runtime schema validates every canonical event variant. +The `child-task` event reports one update of a provider-native child task (a subagent, worker, or delegated task) with a stable `childId`, an optional `parentChildId`, a lifecycle status, start and update times, and the runner, model, usage, and terminal reason when the provider reports them. +Its `sourceEventId` identifies the exact update, so a consumer applies the first event with a given `sourceEventId` and ignores later copies during replay or reconnect. +Identity never depends on the bounded `raw` payload, and a provider that cannot report a stable `childId` emits no `child-task` event. The canonical `cancelled` status identifies caller cancellation and remains distinct from `failed`. Providers advertise `retainedControl` only when exact run, result, event, cancellation, replay, detach, turn, and session identity are all implemented together. `AgentEnvironment.metadata` is the detached snapshot returned by create or get, so recovery can check persisted annotations without listing environments. +`AgentEnvironment.creation` reports what the create call that returned the object did: `created` when the call provisioned the environment, `replayed` when an existing environment matched the idempotency key. +It is a per-call fact, so a same-key replay returns a view of the same environment with `creation: "replayed"`, and the value is absent when the provider cannot prove either outcome. +A consumer never destroys an environment whose creation it cannot prove, because another caller can hold it. Metadata can include caller-authored values and does not prove authorization or authorship. `AgentSession.cancelRun()` accepts a canonical request digest bound to one operation and `AgentExactRunControlRef`, so a caller can safely repeat the same cancellation after losing the first acknowledgement. Its acknowledgement repeats the operation, digest, and run coordinates and distinguishes a known cancellation effect from conflict or unknown state. diff --git a/packages/agent-interface/src/environment-provider.test.ts b/packages/agent-interface/src/environment-provider.test.ts index 696b8b0..8b271ce 100644 --- a/packages/agent-interface/src/environment-provider.test.ts +++ b/packages/agent-interface/src/environment-provider.test.ts @@ -1,12 +1,17 @@ import { describe, expect, it, vi } from "vitest"; import { AgentEnvironmentCapabilitiesSchema, + AgentEnvironmentCreationSchema, AgentNativeContextContinuationResultSchema, agentNativeContextContinuationResultMatchesRequest, agentEnvironmentCreateInputDigest, createAgentEnvironmentWithIdempotency, + replayedAgentEnvironmentView, +} from "./environment-provider.js"; +import type { + AgentEnvironmentCreateIdempotencyRecord, + AgentEnvironmentCreation, } from "./environment-provider.js"; -import type { AgentEnvironmentCreateIdempotencyRecord } from "./environment-provider.js"; import { nativeContextContinuationRequestDigest, nativeContextContinuationTurnDigest, @@ -95,12 +100,25 @@ describe("generic environment create idempotency", () => { ).not.toBe(agentEnvironmentCreateInputDigest(input)); }); - it("coalesces same-key retries and rejects changed input", async () => { + type FakeEnvironment = { + id: string; + creation?: AgentEnvironmentCreation; + status: () => Promise; + }; + + it("coalesces same-key retries into replayed views and rejects changed input", async () => { const records = new Map< string, - AgentEnvironmentCreateIdempotencyRecord<{ id: string }> + AgentEnvironmentCreateIdempotencyRecord >(); - const create = vi.fn(async () => ({ id: "environment-1" })); + const status = async () => "running"; + const create = vi.fn( + async (): Promise => ({ + id: "environment-1", + creation: "created", + status, + }), + ); const first = await createAgentEnvironmentWithIdempotency( records, @@ -118,7 +136,11 @@ describe("generic environment create idempotency", () => { create, ); - expect(replay).toBe(first); + expect(first.creation).toBe("created"); + expect(replay).not.toBe(first); + expect(replay).toEqual({ id: "environment-1", creation: "replayed", status }); + expect(replay.status).toBe(first.status); + expect(first.creation).toBe("created"); expect(create).toHaveBeenCalledOnce(); await expect( createAgentEnvironmentWithIdempotency( @@ -139,6 +161,78 @@ describe("generic environment create idempotency", () => { ), ).rejects.toThrow("retry cancelled"); }); + + it("gives every caller that awaited one pending create a replayed view", async () => { + const records = new Map< + string, + AgentEnvironmentCreateIdempotencyRecord + >(); + let release: (environment: FakeEnvironment) => void = () => {}; + const create = vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + + const firstCall = createAgentEnvironmentWithIdempotency(records, input, create); + const secondCall = createAgentEnvironmentWithIdempotency(records, input, create); + await Promise.resolve(); + release({ id: "environment-1", status: async () => "running" }); + + const [first, second] = await Promise.all([firstCall, secondCall]); + expect(create).toHaveBeenCalledOnce(); + expect(first.creation).toBeUndefined(); + expect(second.creation).toBe("replayed"); + expect(second.id).toBe(first.id); + expect(second.status).toBe(first.status); + }); + + it("keeps an unkeyed create verdict as the provider stated it", async () => { + const records = new Map< + string, + AgentEnvironmentCreateIdempotencyRecord + >(); + const { idempotencyKey: _key, ...unkeyed } = input; + const environment = await createAgentEnvironmentWithIdempotency( + records, + unkeyed, + async (): Promise => ({ + id: "environment-2", + status: async () => "running", + }), + ); + expect(environment.creation).toBeUndefined(); + expect(records.size).toBe(0); + }); + + it("refuses a replayed view of a class instance", () => { + class Environment { + readonly id = "environment-1"; + readonly creation = "created" as const; + status(): Promise { + return Promise.resolve(this.id); + } + } + expect(() => replayedAgentEnvironmentView(new Environment())).toThrow( + /plain object environment/, + ); + expect( + replayedAgentEnvironmentView( + Object.assign(Object.create(null) as object, { id: "environment-1" }), + ), + ).toEqual({ id: "environment-1", creation: "replayed" }); + }); +}); + +describe("AgentEnvironmentCreationSchema", () => { + it("accepts the two provable verdicts and rejects every other value", () => { + expect(AgentEnvironmentCreationSchema.parse("created")).toBe("created"); + expect(AgentEnvironmentCreationSchema.parse("replayed")).toBe("replayed"); + for (const invalid of ["unknown", "", "CREATED", undefined, null, true]) { + expect(() => AgentEnvironmentCreationSchema.parse(invalid)).toThrow(); + } + }); }); describe("AgentEnvironmentCapabilitiesSchema", () => { diff --git a/packages/agent-interface/src/environment-runtime.ts b/packages/agent-interface/src/environment-runtime.ts index 55e3a12..de2538f 100644 --- a/packages/agent-interface/src/environment-runtime.ts +++ b/packages/agent-interface/src/environment-runtime.ts @@ -239,10 +239,36 @@ export interface AgentSession { cancel(options?: { signal?: AbortSignal }): Promise; } +/** + * What one {@link AgentEnvironmentProvider.create} call did for the + * environment it returned. + * + * - `created`: this call provisioned the environment. + * - `replayed`: an existing environment that matched the idempotency key was + * returned. This call provisioned nothing. + * + * Absent when the provider cannot distinguish the two. A consumer treats an + * absent value as unknown and fails closed: it never destroys an environment + * whose creation it cannot prove, because another caller can hold it. + */ +export type AgentEnvironmentCreation = "created" | "replayed"; + +export const AgentEnvironmentCreationSchema = z.enum([ + "created", + "replayed", +]) satisfies z.ZodType; + export interface AgentEnvironment { readonly id: string; readonly provider: string; readonly name?: string; + /** + * The verdict of the create call that returned this object. It is a + * per-call fact: a same-key replay returns a view of the same environment + * with `creation: "replayed"`. Absent on `get()` results and when the + * provider cannot prove which outcome happened. + */ + readonly creation?: AgentEnvironmentCreation; /** * Detached metadata returned by the provider. * It can contain caller-authored annotations and is not authorization evidence. @@ -662,15 +688,42 @@ export interface AgentEnvironmentCreateIdempotencyRecord { environment?: T; } +/** + * Return the per-call view of an environment that a same-key create replayed. + * + * The view shares every member of the environment, so operations act on the + * one environment, and it states `creation: "replayed"` because this call + * provisioned nothing. The copy is shallow, so the environment must be a plain + * object whose members do not read `this`; a class instance loses its + * prototype members in a copy and is rejected. + * @internal + */ +export function replayedAgentEnvironmentView( + environment: T, +): T { + const prototype = Object.getPrototypeOf(environment) as unknown; + if (prototype !== Object.prototype && prototype !== null) { + throw new Error( + "a replayed agent environment view requires a plain object environment", + ); + } + return { ...environment, creation: "replayed" }; +} + /** * 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. + * + * The call that runs `create` receives the environment the provider built, + * with the creation verdict the provider could prove. Every same-key call + * after it, including one that awaited the same pending create, receives + * {@link replayedAgentEnvironmentView} of that environment. * @internal */ -export async function createAgentEnvironmentWithIdempotency( +export async function createAgentEnvironmentWithIdempotency( records: Map>, input: CreateAgentEnvironmentInput, create: () => Promise, @@ -687,7 +740,9 @@ export async function createAgentEnvironmentWithIdempotency( "agent environment create idempotency key conflicts with a different create input", ); } - return existing.environment ?? existing.pending; + return replayedAgentEnvironmentView( + existing.environment ?? (await existing.pending), + ); } const pending = Promise.resolve().then(create); diff --git a/packages/agent-interface/src/index.ts b/packages/agent-interface/src/index.ts index 10a954e..4dd8d42 100644 --- a/packages/agent-interface/src/index.ts +++ b/packages/agent-interface/src/index.ts @@ -58,7 +58,9 @@ export { agentInteractiveSessionRunRef, agentInteractiveSessionStatusMatchesRef, exactAgentInteractiveSessionStart, + AgentEnvironmentCreationSchema, createAgentEnvironmentWithIdempotency, + replayedAgentEnvironmentView, TerminalAttachRequestSchema, TerminalAttachResultSchema, TerminalDetachAckSchema, diff --git a/packages/agent-interface/src/runtime-control.test.ts b/packages/agent-interface/src/runtime-control.test.ts index 280a7f4..8009e1f 100644 --- a/packages/agent-interface/src/runtime-control.test.ts +++ b/packages/agent-interface/src/runtime-control.test.ts @@ -173,6 +173,28 @@ describe("runtime event envelope", () => { submittedAt: "2026-08-01T20:00:00.000Z", }, }, + { + type: "child-task", + childId: "child-1", + status: "started", + title: "Review tests", + time: { started: 1_000, updated: 1_000 }, + runner: "claude-code", + sourceEventId: "event-1", + }, + { + type: "child-task", + childId: "child-2", + parentChildId: "child-1", + status: "completed", + time: { started: 1_100, updated: 1_900, ended: 1_900 }, + runner: "claude-code", + model: "claude-sonnet-4-5", + usage: { inputTokens: 10, outputTokens: 4, cost: 0.01 }, + terminalReason: "end_turn", + sourceEventId: "event-2", + raw: { vendor: { agentType: "reviewer" } }, + }, ]; for (const event of events) { expect(CanonicalStreamEventSchema.parse(event)).toEqual(event); @@ -206,3 +228,124 @@ describe("runtime event envelope", () => { ).toEqual(event); }); }); + +describe("child-task lifecycle event", () => { + const started = { + type: "child-task", + childId: "child-1", + status: "started", + time: { started: 1_000, updated: 1_000 }, + sourceEventId: "event-1", + } as const; + const completed = { + type: "child-task", + childId: "child-2", + parentChildId: "child-1", + status: "completed", + title: "Write the failing test", + time: { started: 1_100, updated: 1_900, ended: 1_900 }, + runner: "opencode", + model: "gpt-5", + usage: { inputTokens: 10, outputTokens: 4, totalTokens: 14 }, + terminalReason: "end_turn", + sourceEventId: "event-2", + raw: { vendor: { agentType: "tester" } }, + } as const; + + it("accepts a root child, a nested terminal child, and a run-parented update", () => { + expect(CanonicalStreamEventSchema.parse(started)).toEqual(started); + expect(CanonicalStreamEventSchema.parse(completed)).toEqual(completed); + const running = { + ...started, + status: "running", + time: { started: 1_000, updated: 1_500 }, + sourceEventId: "event-3", + }; + expect(CanonicalStreamEventSchema.parse(running)).toEqual(running); + }); + + it("rejects a child task without stable identity, with contradictory certainty, or with unknown fields", () => { + const { childId: _childId, ...withoutChildId } = started; + const { sourceEventId: _sourceEventId, ...withoutSourceEventId } = started; + for (const [invalid, reason] of [ + [withoutChildId, /childId/], + [withoutSourceEventId, /sourceEventId/], + [{ ...started, childId: "" }, /childId/], + [{ ...started, childId: " child-1" }, /outer whitespace/], + [{ ...started, parentChildId: "child-1" }, /own parent/], + [{ ...started, time: { started: 1_000, updated: 1_000, ended: 1_000 } }, /end time/], + [{ ...started, terminalReason: "end_turn" }, /terminal reason/], + [{ ...started, time: { started: 1_000, updated: 900 } }, /precede its start/], + [ + { ...completed, time: { started: 1_100, updated: 1_900, ended: 1_000 } }, + /precede its start/, + ], + [{ ...started, time: { started: -1, updated: 1_000 } }, /time/], + [{ ...started, time: { started: 1_000 } }, /updated/], + [{ ...completed, usage: { inputTokens: -1, outputTokens: 0 } }, /usage/], + [{ ...completed, raw: ["not", "a", "record"] }, /raw/], + [{ ...started, status: "paused" }, /status/], + [{ ...started, agentType: "tester" }, /agentType/], + ] as const) { + expect(() => CanonicalStreamEventSchema.parse(invalid), JSON.stringify(invalid)).toThrow( + reason, + ); + } + }); + + it("round-trips through the runtime event envelope", () => { + const envelope = { + runId: "run-1", + eventId: "event-12", + sequence: 12, + cursor: "12:0", + occurredAt: "2026-08-20T10:00:00.000Z", + receivedAt: "2026-08-20T10:00:00.010Z", + event: completed, + }; + expect(RuntimeEventEnvelopeSchema.parse(envelope)).toEqual(envelope); + expect(() => + RuntimeEventEnvelopeSchema.parse({ + ...envelope, + event: { ...completed, sourceEventId: "" }, + }), + ).toThrow(/sourceEventId/); + }); + + it("dedupes by sourceEventId so live and replayed streams build the same tree", () => { + const live = [ + started, + { ...started, status: "running", time: { started: 1_000, updated: 1_200 }, sourceEventId: "event-2" }, + { ...completed, sourceEventId: "event-3" }, + { ...started, status: "completed", time: { started: 1_000, updated: 2_000, ended: 2_000 }, sourceEventId: "event-4" }, + ] as const; + const replayed = [live[0], live[1], live[1], live[2], live[2], live[3], live[0]]; + const tree = (events: ReadonlyArray<(typeof live)[number]>) => { + const applied = new Set(); + const children = new Map(); + for (const event of events) { + const parsed = CanonicalStreamEventSchema.parse(event); + if (parsed.type !== "child-task") continue; + if (applied.has(parsed.sourceEventId)) continue; + applied.add(parsed.sourceEventId); + children.set(parsed.childId, { + ...(parsed.parentChildId !== undefined + ? { parentChildId: parsed.parentChildId } + : {}), + status: parsed.status, + }); + } + return { applied: applied.size, children: [...children.entries()] }; + }; + const fromLive = tree(live); + const fromReplay = tree(replayed); + expect(fromReplay).toEqual(fromLive); + expect(fromLive).toEqual({ + applied: 4, + children: [ + ["child-1", { status: "completed" }], + ["child-2", { parentChildId: "child-1", status: "completed" }], + ], + }); + }); +}); diff --git a/packages/agent-interface/src/runtime-control.ts b/packages/agent-interface/src/runtime-control.ts index 30f82e9..541ae52 100644 --- a/packages/agent-interface/src/runtime-control.ts +++ b/packages/agent-interface/src/runtime-control.ts @@ -11,6 +11,7 @@ import { boundedJsonSchema, boundedStringSchema, } from "./contract-limits.js"; +import { ModelUsageSchema } from "./environment-observation.js"; import { InteractionRequestSchema } from "./interaction.js"; import { DurablePlanSchema } from "./plan.js"; @@ -388,6 +389,72 @@ const partSchema = z.discriminatedUnion("type", [ }), ]); +const TERMINAL_CHILD_TASK_STATUSES = new Set(["completed", "failed", "cancelled"]); +const epochMillisecondsSchema = z.number().finite().nonnegative(); + +/** + * Provider-native child task lifecycle. Identity comes only from `childId`, + * `parentChildId`, and `sourceEventId`; `raw` is opaque and bounded. A provider + * without a stable `childId` emits no `child-task` event. + */ +const ChildTaskEventSchema = z + .strictObject({ + type: z.literal("child-task"), + childId: stableIdSchema, + parentChildId: stableIdSchema.optional(), + status: z.enum(["started", "running", "completed", "failed", "cancelled"]), + title: boundedStringSchema.optional(), + time: z.strictObject({ + started: epochMillisecondsSchema, + updated: epochMillisecondsSchema, + ended: epochMillisecondsSchema.optional(), + }), + runner: stableIdSchema.optional(), + model: stableIdSchema.optional(), + usage: ModelUsageSchema.optional(), + terminalReason: boundedStringSchema.optional(), + sourceEventId: stableIdSchema, + raw: boundedJsonRecordSchema.optional(), + }) + .superRefine((event, refinement) => { + const terminal = TERMINAL_CHILD_TASK_STATUSES.has(event.status); + if (!terminal && event.time.ended !== undefined) { + refinement.addIssue({ + code: "custom", + path: ["time", "ended"], + message: "only a terminal child task status may carry an end time", + }); + } + if (!terminal && event.terminalReason !== undefined) { + refinement.addIssue({ + code: "custom", + path: ["terminalReason"], + message: "only a terminal child task status may carry a terminal reason", + }); + } + if (event.time.updated < event.time.started) { + refinement.addIssue({ + code: "custom", + path: ["time", "updated"], + message: "a child task update time cannot precede its start time", + }); + } + if (event.time.ended !== undefined && event.time.ended < event.time.started) { + refinement.addIssue({ + code: "custom", + path: ["time", "ended"], + message: "a child task end time cannot precede its start time", + }); + } + if (event.parentChildId === event.childId) { + refinement.addIssue({ + code: "custom", + path: ["parentChildId"], + message: "a child task cannot be its own parent", + }); + } + }); + /** Runtime validator for every member of the existing canonical event union. */ export const CanonicalStreamEventSchema: z.ZodType = z.discriminatedUnion("type", [ @@ -458,6 +525,7 @@ export const CanonicalStreamEventSchema: z.ZodType = type: z.literal("plan.submitted"), plan: DurablePlanSchema, }), + ChildTaskEventSchema, ]); /** Ordered, replayable envelope around the existing canonical event union. */ diff --git a/packages/agent-interface/src/stream-events.ts b/packages/agent-interface/src/stream-events.ts index 019a90e..040dfb2 100644 --- a/packages/agent-interface/src/stream-events.ts +++ b/packages/agent-interface/src/stream-events.ts @@ -1,3 +1,4 @@ +import type { TokenUsage } from "./execution-types.js"; import type { InteractionRequest } from "./interaction.js"; import type { DurablePlan } from "./plan.js"; import type { Part } from "./parts.js"; @@ -15,6 +16,66 @@ export type StreamStatus = | "failed" | "cancelled"; +export type ChildTaskStatus = + | "started" + | "running" + | "completed" + | "failed" + | "cancelled"; + +/** + * One observed update of a provider-native child task: a subagent, worker, or + * delegated task that the runner started inside the same run. + * + * Identity rules: + * - `childId` is the provider's stable identifier for the child task. Every + * update of one child repeats the same `childId`. A provider that cannot + * report a stable `childId` emits no `child-task` event. + * - `parentChildId` names the parent child task. It is absent when the parent + * is the run itself. + * - `sourceEventId` is the provider's identifier for this exact update. Two + * events with the same `sourceEventId` are the same update, so a consumer + * applies the first and ignores the rest during replay or reconnect. + * - Identity never depends on `raw`. `raw` is an opaque, bounded copy of + * provider fields that have no canonical position. + * + * Certainty rules: + * - `time.ended` and `terminalReason` are present only with a terminal status + * (`completed`, `failed`, `cancelled`). + * - `time.updated` and `time.ended` are never earlier than `time.started`. + * + * Dedupe example for a consumer that rebuilds the child tree from a replayed + * stream. Live and replayed streams produce the same tree because identity + * comes only from `childId`, `parentChildId`, and `sourceEventId`: + * + * ```ts + * const applied = new Set(); + * const children = new Map(); + * for (const event of events) { + * if (event.type !== "child-task") continue; + * if (applied.has(event.sourceEventId)) continue; + * applied.add(event.sourceEventId); + * children.set(event.childId, event); + * } + * ``` + */ +export type ChildTaskEvent = { + type: "child-task"; + childId: string; + parentChildId?: string; + status: ChildTaskStatus; + title?: string; + /** Epoch milliseconds reported by the provider. */ + time: { started: number; updated: number; ended?: number }; + /** Runner that executes the child, for example `claude-code`. */ + runner?: string; + model?: string; + usage?: TokenUsage; + terminalReason?: string; + sourceEventId: string; + raw?: Record; +}; + export type StreamEvent = | MessagePartUpdatedEvent | { @@ -69,4 +130,5 @@ export type StreamEvent = | { type: "plan.submitted"; plan: DurablePlan; - }; + } + | ChildTaskEvent; diff --git a/packages/agent-provider-cli-bridge/src/index.test.ts b/packages/agent-provider-cli-bridge/src/index.test.ts index b3af1ea..51c34aa 100644 --- a/packages/agent-provider-cli-bridge/src/index.test.ts +++ b/packages/agent-provider-cli-bridge/src/index.test.ts @@ -53,7 +53,10 @@ describe("createCliBridgeProvider", () => { profile: { harness: "pi", name: "worker" }, }); - expect(replay).toBe(first); + expect(first.creation).toBe("created"); + expect(replay.creation).toBe("replayed"); + expect(replay.id).toBe(first.id); + expect(replay.stream).toBe(first.stream); await expect( provider.create({ ...input, diff --git a/packages/agent-provider-cli-bridge/src/index.ts b/packages/agent-provider-cli-bridge/src/index.ts index 541be79..b6d887a 100644 --- a/packages/agent-provider-cli-bridge/src/index.ts +++ b/packages/agent-provider-cli-bridge/src/index.ts @@ -143,6 +143,7 @@ export function createCliBridgeProvider( environmentId, allowDispatch: true, cancelRunsOnDestroy: true, + creation: "created", capabilities: await resolveCapabilities( selectedBackend, model, diff --git a/packages/agent-provider-cli-bridge/src/native-continuation.test.ts b/packages/agent-provider-cli-bridge/src/native-continuation.test.ts index ad1c5ab..6406bc6 100644 --- a/packages/agent-provider-cli-bridge/src/native-continuation.test.ts +++ b/packages/agent-provider-cli-bridge/src/native-continuation.test.ts @@ -257,6 +257,8 @@ describe("cli-bridge native continuation", () => { const restarted = createProvider(fixture.fetch); const reconstructedEnvironment = await restarted.get!(environment.id); if (!reconstructedEnvironment) throw new Error("the environment was not reconstructed"); + expect(environment.creation).toBe("created"); + expect(reconstructedEnvironment.creation).toBeUndefined(); await expect(reconstructedEnvironment.dispatch!({ prompt: "must not dispatch" })).rejects.toThrow( /cannot dispatch new work/, ); diff --git a/packages/agent-provider-cli-bridge/src/retained-environment.ts b/packages/agent-provider-cli-bridge/src/retained-environment.ts index ebf8b2d..aa93422 100644 --- a/packages/agent-provider-cli-bridge/src/retained-environment.ts +++ b/packages/agent-provider-cli-bridge/src/retained-environment.ts @@ -1,6 +1,7 @@ import type { AgentEnvironment, AgentEnvironmentCapabilities, + AgentEnvironmentCreation, AgentEnvironmentEvent, AgentEnvironmentObservation, AgentSession, @@ -66,6 +67,13 @@ export interface CreateCliBridgeEnvironmentArgs { readonly environmentId: string; readonly allowDispatch: boolean; readonly cancelRunsOnDestroy: boolean; + /** + * The verdict of the create call that builds this environment. The create + * path states `created` because it provisions the handle that tracks every + * run it starts; the in-process idempotency helper supplies `replayed`; a + * reconstruction through `get()` states nothing. + */ + readonly creation?: AgentEnvironmentCreation; /** * The document this provider publishes. The environment offers an optional * operation only where the document claims it, so a caller never selects an @@ -150,6 +158,7 @@ export function createCliBridgeEnvironment( return { id: environmentId, provider: providerName, + ...(args.creation === undefined ? {} : { creation: args.creation }), capabilities: args.capabilities, ...(environmentInput.name ? { name: environmentInput.name } : {}), status: async (statusOptions) => { diff --git a/packages/agent-provider-tangle/src/index.test.ts b/packages/agent-provider-tangle/src/index.test.ts index b469ff9..5e5a8ac 100644 --- a/packages/agent-provider-tangle/src/index.test.ts +++ b/packages/agent-provider-tangle/src/index.test.ts @@ -134,7 +134,10 @@ describe("createTangleProvider", () => { profile: { name: "worker" }, }); - expect(replay).toBe(first); + expect(first.creation).toBeUndefined(); + expect(replay.creation).toBe("replayed"); + expect(replay.id).toBe(first.id); + expect(replay.stream).toBe(first.stream); expect(create).toHaveBeenCalledOnce(); expect(create.mock.calls[0]?.[0]).toMatchObject({ idempotencyKey: input.idempotencyKey, @@ -145,6 +148,47 @@ describe("createTangleProvider", () => { expect(create).toHaveBeenCalledOnce(); }); + it("maps the platform create receipt to the environment creation verdict", async () => { + const boxWithReceipt = ( + id: string, + receipt: { outcome: "created" | "idempotent_replay" | "unknown"; idempotencyKeyApplied: boolean } | null, + ): SandboxInstanceLike => ({ + id, + async *streamPrompt() {}, + createReceipt: () => receipt, + }); + const environmentFor = async (box: SandboxInstanceLike) => { + const provider = createTangleProvider({ client: { create: async () => box } }); + return provider.create({ profile: { name: "worker" } }); + }; + + const created = await environmentFor( + boxWithReceipt("sbx-created", { outcome: "created", idempotencyKeyApplied: true }), + ); + expect(created.creation).toBe("created"); + + const replayed = await environmentFor( + boxWithReceipt("sbx-replayed", { outcome: "idempotent_replay", idempotencyKeyApplied: true }), + ); + expect(replayed.creation).toBe("replayed"); + + const unknown = await environmentFor( + boxWithReceipt("sbx-unknown", { outcome: "unknown", idempotencyKeyApplied: false }), + ); + expect(unknown.creation).toBeUndefined(); + + const noReceipt = await environmentFor( + boxWithReceipt("sbx-no-receipt", null), + ); + expect(noReceipt.creation).toBeUndefined(); + + const oldSdk = await environmentFor({ + id: "sbx-old-sdk", + async *streamPrompt() {}, + }); + expect(oldSdk.creation).toBeUndefined(); + }); + it("does not let a custom mapper drop the generic create key", async () => { const create = vi.fn(async () => { throw new Error("not called"); diff --git a/packages/agent-provider-tangle/src/tangle-environment-values.ts b/packages/agent-provider-tangle/src/tangle-environment-values.ts index 910bc4a..f075bb7 100644 --- a/packages/agent-provider-tangle/src/tangle-environment-values.ts +++ b/packages/agent-provider-tangle/src/tangle-environment-values.ts @@ -1,5 +1,5 @@ -import type { AgentEnvironmentStatus, AgentSessionStatus, PlacementInfo } from "@tangle-network/agent-interface/environment-provider"; -import type { SandboxInstanceLike } from "./tangle-types.js"; +import type { AgentEnvironmentCreation, AgentEnvironmentStatus, AgentSessionStatus, PlacementInfo } from "@tangle-network/agent-interface/environment-provider"; +import type { SandboxCreateReceiptLike, SandboxInstanceLike } from "./tangle-types.js"; const MAX_IDENTIFIER_LENGTH = 512; @@ -141,3 +141,28 @@ export function executionBoundSessionStatus( if (latest === undefined && admitted === executionId) return sessionStatus; return "unknown"; } + +/** + * Map the platform create receipt to the environment creation verdict. An + * `unknown` outcome, a null receipt, and an SDK without receipts all leave the + * verdict absent, so a consumer cannot read them as proof of creation. + */ +export function creationFromSandboxCreateReceipt( + receipt: SandboxCreateReceiptLike | null | undefined, +): AgentEnvironmentCreation | undefined { + if (receipt === null || receipt === undefined) return undefined; + switch (receipt.outcome) { + case "created": + return "created"; + case "idempotent_replay": + return "replayed"; + case "unknown": + return undefined; + default: { + const outcome: never = receipt.outcome; + throw new Error( + `Tangle create receipt reported an unknown outcome: ${String(outcome)}`, + ); + } + } +} diff --git a/packages/agent-provider-tangle/src/tangle-environment.ts b/packages/agent-provider-tangle/src/tangle-environment.ts index 08380d6..e74f4a3 100644 --- a/packages/agent-provider-tangle/src/tangle-environment.ts +++ b/packages/agent-provider-tangle/src/tangle-environment.ts @@ -37,6 +37,7 @@ import { } from "./tangle-prompt.js"; import { resolveRetainedSessionControlRef } from "./tangle-session-control.js"; import { + creationFromSandboxCreateReceipt, placementInfoFromLoopPlacement, statusFromUnknown, } from "./tangle-environment-values.js"; @@ -148,9 +149,11 @@ export async function sandboxInstanceAsEnvironment( ...(options.signal ? { signal: options.signal } : {}), ...(options.controlRef ? { runControlRef: options.controlRef } : {}), }); + const creation = creationFromSandboxCreateReceipt(box.createReceipt?.()); return { id: environmentId, provider: providerName, + ...(creation === undefined ? {} : { creation }), ...(box.name ? { name: boundedString(box.name, "Tangle environment name") } : {}), ...(box.metadata ? { metadata: snapshotMetadata(box.metadata) } : {}), capabilities, diff --git a/packages/agent-provider-tangle/src/tangle-types.ts b/packages/agent-provider-tangle/src/tangle-types.ts index 955e502..c321ee0 100644 --- a/packages/agent-provider-tangle/src/tangle-types.ts +++ b/packages/agent-provider-tangle/src/tangle-types.ts @@ -30,6 +30,17 @@ import type { CreateAgentEnvironmentInput, } from "@tangle-network/agent-interface/environment-provider"; +/** + * The platform verdict for the create call that returned a sandbox. + * `created` means the call allocated the sandbox; `idempotent_replay` means an + * earlier call with the same idempotency key allocated it; `unknown` means the + * platform cannot prove either outcome. + */ +export interface SandboxCreateReceiptLike { + outcome: "created" | "idempotent_replay" | "unknown"; + idempotencyKeyApplied: boolean; +} + export interface TangleExactProcessOptions { teamId?: string; } @@ -406,6 +417,12 @@ export interface SandboxInstanceLike { resourceUsage?(): Promise; /** Interactive terminal transport. Absent on a client that cannot serve a PTY. */ terminals?: SandboxTerminalsLike; + /** + * The platform verdict for the create call that returned this instance. + * Absent on a Sandbox SDK older than 0.30.1; null for an instance resolved + * by id or when the platform reported no receipt. + */ + createReceipt?(): SandboxCreateReceiptLike | null; refresh?(options?: { signal?: AbortSignal }): Promise; delete?(options?: { signal?: AbortSignal }): Promise; } diff --git a/packages/agent-provider-testkit/src/index.test.ts b/packages/agent-provider-testkit/src/index.test.ts index 4419189..504dce9 100644 --- a/packages/agent-provider-testkit/src/index.test.ts +++ b/packages/agent-provider-testkit/src/index.test.ts @@ -25,6 +25,36 @@ describe("runAgentEnvironmentProviderConformance", () => { expect(report.checked).toContain("stream"); expect(report.checked).toContain("workspace-exec"); }); + + it("reports the creation verdict per call through the shared idempotency helper", async () => { + const provider = fakeProvider(); + const input = { + profile: { name: "fake-profile" }, + idempotencyKey: "creation-verdict-1", + }; + const first = await provider.create(input); + const replay = await provider.create(input); + expect(first.creation).toBe("created"); + expect(replay.creation).toBe("replayed"); + expect(replay.id).toBe(first.id); + }); + + it("rejects a provider whose same-key replay claims it created the environment", async () => { + const base = fakeProvider(); + const provider: AgentEnvironmentProvider = { + ...base, + async create(input) { + const environment = await base.create(input); + return { ...environment, creation: "created" }; + }, + }; + await expect( + runAgentEnvironmentProviderConformance({ + name: "fake", + createProvider: () => provider, + }), + ).rejects.toThrow(/must not claim it created the environment/); + }); }); describe("runAgentExactProcessProviderLifecycleChecks", () => { @@ -109,6 +139,7 @@ function fakeProvider(): AgentEnvironmentProvider { async () => ({ id: "env-1", provider: "fake", + creation: "created", status: async () => "running", async *stream(input: AgentTurnInput) { yield { diff --git a/packages/agent-provider-testkit/src/provider-conformance.ts b/packages/agent-provider-testkit/src/provider-conformance.ts index 007972e..c0f16f4 100644 --- a/packages/agent-provider-testkit/src/provider-conformance.ts +++ b/packages/agent-provider-testkit/src/provider-conformance.ts @@ -103,6 +103,18 @@ export async function runAgentEnvironmentProviderConformance( "same create key and canonical input must return the same environment", checked, ); + // The first call already holds this environment, so the replay call + // provisioned nothing and may state only "replayed" or nothing at all. + assert( + replay.creation === undefined || replay.creation === "replayed", + "a same-key create replay must not claim it created the environment", + checked, + ); + assert( + environment.creation === undefined || replay.creation === "replayed", + "a provider that states a creation verdict must state 'replayed' on a same-key replay", + checked, + ); checked.push("create-idempotency"); let collisionRejected = false;