diff --git a/.changeset/quiet-runs-bind.md b/.changeset/quiet-runs-bind.md new file mode 100644 index 0000000..4d801bd --- /dev/null +++ b/.changeset/quiet-runs-bind.md @@ -0,0 +1,6 @@ +--- +"@tangle-network/agent-interface": minor +"@tangle-network/agent-provider-tangle": minor +--- + +Expose detached environment metadata and preserve a recursively frozen Sandbox metadata snapshot in the Tangle provider. diff --git a/packages/agent-interface/README.md b/packages/agent-interface/README.md index 745d0a5..fe61618 100644 --- a/packages/agent-interface/README.md +++ b/packages/agent-interface/README.md @@ -10,6 +10,8 @@ shapes; higher-level packages import from here rather than redefining them. `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. 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. +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-runtime.ts b/packages/agent-interface/src/environment-runtime.ts index 0174fa1..5ca12e4 100644 --- a/packages/agent-interface/src/environment-runtime.ts +++ b/packages/agent-interface/src/environment-runtime.ts @@ -233,6 +233,11 @@ export interface AgentEnvironment { readonly id: string; readonly provider: string; readonly name?: string; + /** + * Detached metadata returned by the provider. + * It can contain caller-authored annotations and is not authorization evidence. + */ + readonly metadata?: Readonly>; /** * The capability document for THIS environment, and the document a caller * reads to decide which operation to offer against it. diff --git a/packages/agent-provider-tangle/README.md b/packages/agent-provider-tangle/README.md index c24e69d..78ad501 100644 --- a/packages/agent-provider-tangle/README.md +++ b/packages/agent-provider-tangle/README.md @@ -15,6 +15,8 @@ const provider = createTangleProvider({ Detached dispatch returns the immutable Sandbox execution receipt in `controlRef`. The adapter validates its complete capability document and omits optional environment methods whose capabilities are disabled. +Created and reconstructed environments expose a recursively frozen Sandbox metadata snapshot for constant-time annotation checks. +Sandbox metadata can include caller-authored values and does not authenticate its author. Reconstruct an exact session with `environment.session(reference.id, { controlRef: reference.controlRef })`; replay cursors are exclusive at both the agent interface and Sandbox session stream. Result, replay, and cancel operations select that exact execution instead of whichever execution most recently changed the shared session. Session status with an exact control reference reports a state only when the payload names that execution; a payload bound to a different or unnamed execution reports `unknown`. diff --git a/packages/agent-provider-tangle/src/index.test.ts b/packages/agent-provider-tangle/src/index.test.ts index c482032..c97133d 100644 --- a/packages/agent-provider-tangle/src/index.test.ts +++ b/packages/agent-provider-tangle/src/index.test.ts @@ -346,6 +346,10 @@ describe("createTangleProvider", () => { id: "sbx-1", name: "sandbox-one", status: "running", + metadata: { + retainedIdempotencyKey: "environment-key", + labels: { tenant: "acme" }, + }, async *streamPrompt(prompt: string): AsyncIterable { yield { type: "result", @@ -399,6 +403,9 @@ describe("createTangleProvider", () => { createRequestOptions = requestOptions; return box; }, + async get(id) { + return id === box.id ? box : null; + }, describePlacement: () => ({ kind: "sibling", sandboxId: "sbx-1" }), }; const provider = createTangleProvider({ client }); @@ -421,6 +428,21 @@ describe("createTangleProvider", () => { backend: { type: "codex", profile: { name: "worker" } }, }); expect(createRequestOptions?.signal).toBe(controller.signal); + const environment = await provider.create({ profile: { name: "worker" } }); + expect(environment.metadata).toEqual({ + retainedIdempotencyKey: "environment-key", + labels: { tenant: "acme" }, + }); + expect(Object.isFrozen(environment.metadata)).toBe(true); + expect(Object.isFrozen(environment.metadata?.labels)).toBe(true); + (box.metadata?.labels as { tenant: string }).tenant = "changed-at-source"; + expect(environment.metadata?.labels).toEqual({ tenant: "acme" }); + + const reconstructed = await provider.get?.("sbx-1"); + expect(reconstructed?.metadata?.labels).toEqual({ tenant: "changed-at-source" }); + expect(Object.isFrozen(reconstructed?.metadata?.labels)).toBe(true); + (box.metadata?.labels as { tenant: string }).tenant = "changed-after-reconstruction"; + expect(reconstructed?.metadata?.labels).toEqual({ tenant: "changed-at-source" }); }); it("rejects malformed Sandbox events and exec results", async () => { diff --git a/packages/agent-provider-tangle/src/tangle-environment.ts b/packages/agent-provider-tangle/src/tangle-environment.ts index b306b44..da3a5fe 100644 --- a/packages/agent-provider-tangle/src/tangle-environment.ts +++ b/packages/agent-provider-tangle/src/tangle-environment.ts @@ -119,6 +119,7 @@ export async function sandboxInstanceAsEnvironment( id: environmentId, provider: providerName, ...(box.name ? { name: boundedString(box.name, "Tangle environment name") } : {}), + ...(box.metadata ? { metadata: snapshotMetadata(box.metadata) } : {}), capabilities, async status(options?: { signal?: AbortSignal }): Promise { assertOptionKeys(options, ["signal"], "Tangle environment status"); @@ -292,3 +293,16 @@ export async function sandboxInstanceAsEnvironment( : {}), }; } + +function snapshotMetadata( + metadata: Record, +): Readonly> { + return deepFreeze(structuredClone(metadata)); +} + +function deepFreeze(value: T, seen = new Set()): T { + if (value === null || typeof value !== "object" || seen.has(value)) return value; + seen.add(value); + for (const child of Object.values(value)) deepFreeze(child, seen); + return Object.freeze(value); +}