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
9 changes: 9 additions & 0 deletions .changeset/child-task-event.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/environment-creation-verdict.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions packages/agent-interface/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
104 changes: 99 additions & 5 deletions packages/agent-interface/src/environment-provider.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string>;
};

it("coalesces same-key retries into replayed views and rejects changed input", async () => {
const records = new Map<
string,
AgentEnvironmentCreateIdempotencyRecord<{ id: string }>
AgentEnvironmentCreateIdempotencyRecord<FakeEnvironment>
>();
const create = vi.fn(async () => ({ id: "environment-1" }));
const status = async () => "running";
const create = vi.fn(
async (): Promise<FakeEnvironment> => ({
id: "environment-1",
creation: "created",
status,
}),
);

const first = await createAgentEnvironmentWithIdempotency(
records,
Expand All @@ -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(
Expand All @@ -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<FakeEnvironment>
>();
let release: (environment: FakeEnvironment) => void = () => {};
const create = vi.fn(
() =>
new Promise<FakeEnvironment>((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<FakeEnvironment>
>();
const { idempotencyKey: _key, ...unkeyed } = input;
const environment = await createAgentEnvironmentWithIdempotency(
records,
unkeyed,
async (): Promise<FakeEnvironment> => ({
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<string> {
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", () => {
Expand Down
59 changes: 57 additions & 2 deletions packages/agent-interface/src/environment-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,36 @@ export interface AgentSession {
cancel(options?: { signal?: AbortSignal }): Promise<void>;
}

/**
* 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<AgentEnvironmentCreation>;

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.
Expand Down Expand Up @@ -662,15 +688,42 @@ export interface AgentEnvironmentCreateIdempotencyRecord<T> {
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<T extends object>(
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<T>(
export async function createAgentEnvironmentWithIdempotency<T extends object>(
records: Map<string, AgentEnvironmentCreateIdempotencyRecord<T>>,
input: CreateAgentEnvironmentInput,
create: () => Promise<T>,
Expand All @@ -687,7 +740,9 @@ export async function createAgentEnvironmentWithIdempotency<T>(
"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);
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 @@ -58,7 +58,9 @@ export {
agentInteractiveSessionRunRef,
agentInteractiveSessionStatusMatchesRef,
exactAgentInteractiveSessionStart,
AgentEnvironmentCreationSchema,
createAgentEnvironmentWithIdempotency,
replayedAgentEnvironmentView,
TerminalAttachRequestSchema,
TerminalAttachResultSchema,
TerminalDetachAckSchema,
Expand Down
Loading
Loading