From 01857777cc21c684475032a24d5fc45445b4eaf5 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 13 Aug 2026 14:01:48 -0600 Subject: [PATCH 1/3] feat(provider-tangle): derive retained control from deployment capability discovery Composing an environment reads the deployment's capability document once and derives the retained-control claim from it, instead of from the linked Sandbox SDK's method surface. The SDK probe measured the SDK class, which says nothing about what the connected service honors. retainedControl needs dispatch.runControlRef with cancel.canonicalRunCancellation, cancel.digestBound and cancel.idempotent. streaming.detach needs dispatch.runControlRef alone, because detached dispatch carries the caller's exact reference and refuses a receipt that does not echo it. A missing flag is unknown, and unknown is never a claim. Four inputs claim nothing: an SDK older than 0.22.0, a sandbox that is not running, a null document, and a document that leaves a required flag unset. Those environments omit dispatch and their sessions omit cancelRun. A malformed document throws, and create() deletes the sandbox it just made. The client stage keeps its adapter-surface probe as an upper bound, because no deployment exists to ask before a sandbox is created. The adapter surface stays the ceiling: a deployment claim can only narrow it. The peer floor stays at 0.19.6. Capability discovery is feature-detected, so a consumer on an older SDK degrades to no retained control instead of failing to load. --- .changeset/lucky-pans-clap.md | 11 + packages/agent-provider-tangle/README.md | 20 +- packages/agent-provider-tangle/package.json | 4 +- .../src/deployment-capabilities.test.ts | 223 ++++++++++++++++++ .../agent-provider-tangle/src/index.test.ts | 29 +-- .../src/leaf-modules.test.ts | 42 +++- .../src/retained-control-test-helpers.ts | 36 +++ .../src/retained-control.test.ts | 53 +++-- .../src/tangle-capabilities.ts | 121 +++++++--- .../src/tangle-deployment-capabilities.ts | 102 ++++++++ .../src/tangle-environment-session.ts | 13 +- .../src/tangle-environment.ts | 43 +++- .../src/tangle-provider.ts | 6 +- .../agent-provider-tangle/src/tangle-types.ts | 43 ++++ pnpm-lock.yaml | 16 +- .../fixtures/tangle-control-consumer.test.ts | 50 +++- scripts/lib/control-cohort.mjs | 2 +- 17 files changed, 696 insertions(+), 118 deletions(-) create mode 100644 .changeset/lucky-pans-clap.md create mode 100644 packages/agent-provider-tangle/src/deployment-capabilities.test.ts create mode 100644 packages/agent-provider-tangle/src/tangle-deployment-capabilities.ts diff --git a/.changeset/lucky-pans-clap.md b/.changeset/lucky-pans-clap.md new file mode 100644 index 0000000..69223aa --- /dev/null +++ b/.changeset/lucky-pans-clap.md @@ -0,0 +1,11 @@ +--- +"@tangle-network/agent-provider-tangle": minor +--- + +Derive retained control from deployment capability discovery. + +Composing an environment now calls `box.capabilities()` once and takes the retained-control claim from that document instead of from the linked Sandbox SDK's method surface. +`retainedControl` needs `dispatch.runControlRef` with `cancel.canonicalRunCancellation`, `cancel.digestBound`, and `cancel.idempotent`; `streaming.detach` needs `dispatch.runControlRef`. +An SDK older than 0.22.0, a sandbox that is not running, a `null` document, or a missing flag claims nothing: the environment omits `dispatch` and its sessions omit `cancelRun`. +A malformed document throws and `create()` deletes the sandbox. +The client stage keeps its adapter-surface probe as an upper bound, because no deployment exists to ask before a sandbox is created. diff --git a/packages/agent-provider-tangle/README.md b/packages/agent-provider-tangle/README.md index a998c61..f53bdb6 100644 --- a/packages/agent-provider-tangle/README.md +++ b/packages/agent-provider-tangle/README.md @@ -1,7 +1,8 @@ # @tangle-network/agent-provider-tangle Wraps `@tangle-network/sandbox` as an `AgentEnvironmentProvider`. -The peer range is `>=0.19.6 <1.0.0`; retained-run cancellation (`session.cancelRun`) first shipped in 0.19.6, and this package is developed and tested against 0.21.1. +The peer range is `>=0.19.6 <1.0.0`; retained-run cancellation (`session.cancelRun`) first shipped in 0.19.6, and this package is developed and tested against 0.22.0. +The floor stays at 0.19.6 although deployment capability discovery (`box.capabilities()`) needs 0.22.0: the adapter feature-detects that method, so a consumer on an older SDK keeps working and claims no retained control instead of failing to load. ```ts import { Sandbox } from '@tangle-network/sandbox' @@ -18,11 +19,22 @@ Reconstruct an exact session with `environment.session(reference.id, { controlRe 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`. -The provider claims `retainedControl` only from probed facts. +Capabilities are derived in two stages, and each stage claims only what it can establish. + +The client stage runs before any sandbox exists, so it can only measure the adapter surface. A lazy instance handle minted from the linked Sandbox SDK over the client's `fetch` transport must prove `dispatchPrompt`, `session`, and `cancelRun`, and the client must expose `get` for reconstruction; the probe sends no request and creates no resource. -The probe measures the linked SDK's method surface, not the connected service; service-side truth needs the sidecar capability endpoint and is a follow-up. +That handle measures the linked SDK's method surface, which is an upper bound and never a statement about the connected service. A client that cannot prove those facts gets no claim, so the runtime rejects retained dispatch before any sandbox is created. -Each concrete sandbox narrows the declared document independently against its own measured method surface, so a capable sandbox keeps retained control even when the provider-level claim failed closed. + +The sandbox stage reads deployment truth. +Composing an environment calls `box.capabilities()` once and derives the retained-control claim from that document, not from the linked SDK. +`retainedControl` needs `dispatch.runControlRef` together with `cancel.canonicalRunCancellation`, `cancel.digestBound`, and `cancel.idempotent`; `streaming.detach` needs `dispatch.runControlRef` alone, because detached dispatch carries the caller's exact reference and refuses a receipt that does not echo it. +A missing flag means unknown, and unknown is never a claim. +The adapter surface stays the ceiling: a deployment claim can only narrow what the client can execute, never widen it. + +Four inputs claim nothing at all: a Sandbox SDK older than 0.22.0, a sandbox that is not running, a `null` document (a deployment predating capability discovery, or one serving a newer schema this SDK cannot read), and a document that leaves any required flag unset. +In each case the environment omits `dispatch` and its sessions omit `cancelRun`, so a caller never selects an action the deployment will reject. +A malformed document is different: capability discovery throws, `create()` deletes the sandbox it just made, and the error propagates. Pass the SDK client itself when retained control matters. An object-spread wrapper (`{ ...client }`) drops class prototype methods, including `fetch`, so the provider treats the wrapper as a non-SDK client and claims no retained control. diff --git a/packages/agent-provider-tangle/package.json b/packages/agent-provider-tangle/package.json index f540ca9..0afd9cb 100644 --- a/packages/agent-provider-tangle/package.json +++ b/packages/agent-provider-tangle/package.json @@ -45,6 +45,8 @@ "dist/tangle-exact-process-validation.js", "dist/tangle-capabilities.d.ts", "dist/tangle-capabilities.js", + "dist/tangle-deployment-capabilities.d.ts", + "dist/tangle-deployment-capabilities.js", "dist/tangle-create-options.d.ts", "dist/tangle-create-options.js", "dist/tangle-environment-values.d.ts", @@ -96,7 +98,7 @@ "@tangle-network/agent-eval": "0.145.3", "@tangle-network/agent-provider-testkit": "workspace:*", "@tangle-network/agent-runtime": "0.132.13", - "@tangle-network/sandbox": "0.21.1", + "@tangle-network/sandbox": "0.22.0", "@types/node": "catalog:", "typescript": "^6.0.3", "vitest": "catalog:" diff --git a/packages/agent-provider-tangle/src/deployment-capabilities.test.ts b/packages/agent-provider-tangle/src/deployment-capabilities.test.ts new file mode 100644 index 0000000..260563b --- /dev/null +++ b/packages/agent-provider-tangle/src/deployment-capabilities.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it, vi } from "vitest"; +import type { SandboxRuntimeCapabilities } from "@tangle-network/sandbox"; +import { agentRunCancellationRequestDigest } from "@tangle-network/agent-interface"; +import type { AgentExactRunControlRef } from "@tangle-network/agent-interface"; +import type { PromptOptions } from "@tangle-network/sandbox"; +import { + createTangleProvider, + type SandboxInstanceLike, + type SandboxRuntimeCapabilityDocument, + type SandboxSessionLike, +} from "./index.js"; +import { deploymentCapabilitySupport } from "./tangle-deployment-capabilities.js"; +import { RETAINED_DEPLOYMENT_DOCUMENT } from "./retained-control-test-helpers.js"; + +/** + * The published SDK document is the wire fact this adapter reads. Assigning + * it to the adapter's own shape holds the two together: a field the SDK + * renames or retypes fails here instead of silently reading as unknown. + */ +const PUBLISHED_DOCUMENT: SandboxRuntimeCapabilities = { + schema: 1, + agentInterface: "0.49.0", + sidecarVersion: "1.2.3", + image: `example/sidecar@sha256:${"b".repeat(64)}`, + dispatch: { runControlRef: true, executionIdOnAdmission: true }, + cancel: { canonicalRunCancellation: true, digestBound: true, idempotent: true }, + runs: { executionScopedStatus: true, eventReplay: true }, + interactions: {}, +}; +const PUBLISHED_DOCUMENT_AS_READ: SandboxRuntimeCapabilityDocument = + PUBLISHED_DOCUMENT; + +function echoedExecution(options: PromptOptions | undefined) { + return options?.executionId; +} + +function capableSession(id: string): SandboxSessionLike { + return { + id, + status: async () => ({ status: "running" }), + async *events() {}, + result: async (options) => ({ + success: true, + status: "success", + executionId: echoedExecution(options), + durationMs: 1, + }), + prompt: async (_message, options) => ({ + success: true, + status: "success", + executionId: echoedExecution(options), + durationMs: 1, + }), + interrupt: async () => ({ cancelled: true }), + cancelRun: async (request) => ({ + operationId: request.operationId, + requestDigest: request.requestDigest, + run: request.run, + status: "accepted", + effect: "not_live", + }), + }; +} + +/** + * One capable sandbox behind one deployment. Every local method retained + * control needs is present, so the capability document is the only variable: + * whatever the environment ends up offering, the deployment decided it. + */ +function deployedProvider(options: { + capabilities?: () => Promise; + status?: unknown; +}) { + const sessionId = "session-deployment"; + const deleted = vi.fn(async () => undefined); + const box: SandboxInstanceLike = { + id: "sbx-deployment", + status: options.status ?? "running", + async *streamPrompt() {}, + dispatchPrompt: async (_message, promptOptions) => ({ + sessionId: promptOptions?.sessionId ?? sessionId, + executionId: promptOptions?.executionId, + runControlRef: promptOptions?.runControlRef, + status: "running", + alreadyExisted: false, + dispatched: true, + }), + session: (id) => capableSession(id), + delete: deleted, + ...(options.capabilities ? { capabilities: options.capabilities } : {}), + }; + const provider = createTangleProvider({ + client: { + create: async () => box, + get: async (id) => (id === box.id ? box : null), + }, + }); + return { provider, box, sessionId, deleted }; +} + +describe("Tangle deployment capability discovery", () => { + it("claims retained control when the deployment reports the complete flag set", async () => { + const capabilities = vi.fn(async () => PUBLISHED_DOCUMENT_AS_READ); + const { provider, sessionId } = deployedProvider({ capabilities }); + const environment = await provider.create({ profile: { name: "worker" } }); + + expect(capabilities).toHaveBeenCalledTimes(1); + expect(typeof environment.dispatch).toBe("function"); + const reference = await environment.dispatch!({ + prompt: "retained by the deployment", + sessionId, + turnId: "deployment-turn", + }); + const session = environment.session!(sessionId, { + controlRef: reference.controlRef, + }); + expect(typeof session.cancelRun).toBe("function"); + + const run = reference.controlRef as AgentExactRunControlRef; + const material = { operationId: "deployment-cancel", run }; + await expect( + session.cancelRun!({ + ...material, + requestDigest: agentRunCancellationRequestDigest(material), + }), + ).resolves.toMatchObject({ status: "accepted", run }); + }); + + it("claims nothing when the deployment cannot disclose a document", async () => { + // A deployment predating capability discovery, or one serving a schema + // this SDK cannot read, arrives as null. Unknown is not a claim. + const { provider, sessionId } = deployedProvider({ + capabilities: async () => null, + }); + const environment = await provider.create({ profile: { name: "worker" } }); + + expect(environment.dispatch).toBeUndefined(); + expect(environment.session!(sessionId).cancelRun).toBeUndefined(); + }); + + it("claims nothing when the linked SDK predates capability discovery", async () => { + // No `capabilities` method at all: the adapter cannot read deployment + // truth, so it must not fall back to its own method surface. + const { provider, sessionId } = deployedProvider({}); + const environment = await provider.create({ profile: { name: "worker" } }); + + expect(environment.dispatch).toBeUndefined(); + expect(environment.session!(sessionId).cancelRun).toBeUndefined(); + }); + + it("claims nothing when the sandbox is not running to answer", async () => { + const capabilities = vi.fn(async () => PUBLISHED_DOCUMENT_AS_READ); + const { provider, sessionId } = deployedProvider({ + capabilities, + status: "stopped", + }); + const environment = await provider.get!("sbx-deployment"); + + expect(capabilities).not.toHaveBeenCalled(); + expect(environment!.dispatch).toBeUndefined(); + expect(environment!.session!(sessionId).cancelRun).toBeUndefined(); + }); + + it("drops cancellation but keeps dispatch when only the cancel flags are unreported", async () => { + // Absence is unknown, so a document that never mentions idempotent + // cancellation cannot carry retained control. Exact dispatch is a + // separate flag and survives on its own evidence. + const { provider, sessionId } = deployedProvider({ + capabilities: async () => ({ + ...RETAINED_DEPLOYMENT_DOCUMENT, + cancel: { canonicalRunCancellation: true, digestBound: true }, + }), + }); + const environment = await provider.create({ profile: { name: "worker" } }); + + expect(typeof environment.dispatch).toBe("function"); + expect(environment.session!(sessionId).cancelRun).toBeUndefined(); + }); + + it("drops dispatch when the deployment does not accept an exact run reference", async () => { + const { provider, sessionId } = deployedProvider({ + capabilities: async () => ({ + ...RETAINED_DEPLOYMENT_DOCUMENT, + dispatch: { executionIdOnAdmission: true }, + }), + }); + const environment = await provider.create({ profile: { name: "worker" } }); + + expect(environment.dispatch).toBeUndefined(); + expect(environment.session!(sessionId).cancelRun).toBeUndefined(); + }); + + it("fails loud and deletes the sandbox when capability discovery breaks", async () => { + const { provider, deleted } = deployedProvider({ + capabilities: async () => { + throw new Error("Capability discovery returned a non-object document"); + }, + }); + + await expect( + provider.create({ profile: { name: "worker" } }), + ).rejects.toThrow(/Capability discovery returned/); + expect(deleted).toHaveBeenCalledTimes(1); + }); + + it("reads every flag as unknown until the document sets it", () => { + expect(deploymentCapabilitySupport(PUBLISHED_DOCUMENT_AS_READ)).toEqual({ + measured: true, + exactRunControlRef: true, + canonicalCancellation: true, + }); + expect(deploymentCapabilitySupport(null)).toEqual({ + measured: true, + exactRunControlRef: false, + canonicalCancellation: false, + }); + expect(deploymentCapabilitySupport({ schema: 1 })).toEqual({ + measured: true, + exactRunControlRef: false, + canonicalCancellation: false, + }); + }); +}); diff --git a/packages/agent-provider-tangle/src/index.test.ts b/packages/agent-provider-tangle/src/index.test.ts index 5737777..6c3983a 100644 --- a/packages/agent-provider-tangle/src/index.test.ts +++ b/packages/agent-provider-tangle/src/index.test.ts @@ -19,6 +19,7 @@ import { tokenUsageFromData } from "./tangle-result-values.js"; import { controlRefForTurn, executionIdForTurn, + retainedDeployment, TANGLE_PROVIDER, } from "./retained-control-test-helpers.js"; @@ -340,7 +341,7 @@ describe("createTangleProvider", () => { let createRequestOptions: { signal?: AbortSignal; timeoutMs?: number } | undefined; const controller = new AbortController(); const files = new Map(); - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-1", name: "sandbox-one", status: "running", @@ -390,7 +391,7 @@ describe("createTangleProvider", () => { }, exec: async () => ({ exitCode: 0, stdout: "ok\n", stderr: "" }), delete: async () => {}, - }; + }); const client: SandboxClientLike = { async create(options, requestOptions) { createOptions = options; @@ -464,7 +465,7 @@ describe("createTangleProvider", () => { it("maps Sandbox session interruption to agent session cancellation", async () => { const interrupt = vi.fn(async () => ({ cancelled: true })); - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-session", async *streamPrompt(): AsyncIterable {}, dispatchPrompt: async (_prompt, options) => ({ @@ -487,7 +488,7 @@ describe("createTangleProvider", () => { }, interrupt, }), - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -661,7 +662,7 @@ describe("createTangleProvider", () => { it("rejects dispatch without an immutable execution receipt", async () => { let statusCalls = 0; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-delayed-execution", async *streamPrompt(): AsyncIterable {}, dispatchPrompt: async (_prompt, options) => ({ @@ -684,7 +685,7 @@ describe("createTangleProvider", () => { }, interrupt: async () => ({ cancelled: false }), }), - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -708,14 +709,14 @@ describe("createTangleProvider", () => { "sbx-wrong-execution", sessionId, ); - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-wrong-execution", async *streamPrompt(): AsyncIterable {}, dispatchPrompt: async (_prompt, options) => ({ sessionId: options?.sessionId, executionId: "execution-from-server", }), - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -733,7 +734,7 @@ describe("createTangleProvider", () => { it("rejects an unbound dispatch receipt without interrupting unknown work", async () => { const interrupt = vi.fn(async () => ({ cancelled: true })); - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-wrong-session", async *streamPrompt(): AsyncIterable {}, dispatchPrompt: async () => ({ @@ -755,7 +756,7 @@ describe("createTangleProvider", () => { }, interrupt, }), - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -882,7 +883,7 @@ describe("createTangleProvider", () => { }), interrupt: async () => ({ cancelled: true }), }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-replay", async *streamPrompt(_message, options) { capturedOptions = options as Record | undefined; @@ -901,7 +902,7 @@ describe("createTangleProvider", () => { }; }, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -976,7 +977,7 @@ describe("createTangleProvider", () => { }), interrupt: async () => ({ cancelled: true }), }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-unstable-events", async *streamPrompt() { yield { @@ -997,7 +998,7 @@ describe("createTangleProvider", () => { }; }, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); diff --git a/packages/agent-provider-tangle/src/leaf-modules.test.ts b/packages/agent-provider-tangle/src/leaf-modules.test.ts index 0c10165..53b7734 100644 --- a/packages/agent-provider-tangle/src/leaf-modules.test.ts +++ b/packages/agent-provider-tangle/src/leaf-modules.test.ts @@ -8,6 +8,8 @@ import { defaultTangleSandboxCapabilities, sandboxCapabilitySupport, } from "./tangle-capabilities.js"; +import { deploymentCapabilitySupport } from "./tangle-deployment-capabilities.js"; +import { RETAINED_DEPLOYMENT_DOCUMENT } from "./retained-control-test-helpers.js"; import { assertBoundedJson, awaitWithSignal, @@ -114,6 +116,10 @@ const capabilities = defaultTangleSandboxCapabilities(); const minimalClient: SandboxClientLike = { create: async () => ({ id: "client", async *streamPrompt() {} }), }; +const CONFIRMED_DEPLOYMENT = deploymentCapabilitySupport( + RETAINED_DEPLOYMENT_DOCUMENT, +); +const REFUSED_DEPLOYMENT = deploymentCapabilitySupport(null); const exactInput = { image: `sha256:${"a".repeat(64)}`, @@ -169,7 +175,9 @@ describe("Tangle split leaf modules", () => { // pass strips it until deployment facts prove it. expect(capabilities.sessions.continue).toBe(true); expect(capabilities.retainedControl).toBeDefined(); - expect(capabilitiesForSandbox(capabilities, support).workspace.read).toBe(true); + expect( + capabilitiesForSandbox(capabilities, support, CONFIRMED_DEPLOYMENT).workspace.read, + ).toBe(true); const retainedSupport = { ...support, reconstruct: true, @@ -184,19 +192,32 @@ describe("Tangle split leaf modules", () => { capabilitiesForClient(capabilities, minimalClient), ).not.toHaveProperty("retainedControl"); expect( - capabilitiesForSandbox(capabilities, retainedSupport), + capabilitiesForSandbox(capabilities, retainedSupport, CONFIRMED_DEPLOYMENT), ).toMatchObject({ sessions: { continue: true }, retainedControl: capabilities.retainedControl, }); for (const clearedFact of ["cancelRun", "reconstruct"] as const) { - const narrowed = capabilitiesForSandbox(capabilities, { - ...retainedSupport, - [clearedFact]: false, - }); + const narrowed = capabilitiesForSandbox( + capabilities, + { ...retainedSupport, [clearedFact]: false }, + CONFIRMED_DEPLOYMENT, + ); expect(narrowed).toMatchObject({ sessions: { continue: false } }); expect(narrowed).not.toHaveProperty("retainedControl"); } + // Every local fact holds and the deployment still decides: an + // unconfirmed document clears retained control and detached dispatch. + const deploymentRefused = capabilitiesForSandbox( + capabilities, + retainedSupport, + REFUSED_DEPLOYMENT, + ); + expect(deploymentRefused).toMatchObject({ + sessions: { continue: false }, + streaming: { detach: false }, + }); + expect(deploymentRefused).not.toHaveProperty("retainedControl"); const overDeclaredBranching = { ...capabilities, branching: { @@ -214,7 +235,10 @@ describe("Tangle split leaf modules", () => { lookup: false, cleanup: false, }); - expect(capabilitiesForSandbox(overDeclaredBranching, retainedSupport).branching).toMatchObject({ + expect( + capabilitiesForSandbox(overDeclaredBranching, retainedSupport, CONFIRMED_DEPLOYMENT) + .branching, + ).toMatchObject({ checkpoint: false, fork: false, retrySafe: false, @@ -423,7 +447,7 @@ describe("Tangle split leaf modules", () => { exec: async () => execPending.promise as never, refresh: async () => refreshPending.promise, }; - const environment = sandboxInstanceAsEnvironment(box, "tangle-sandbox", minimalClient, capabilities); + const environment = await sandboxInstanceAsEnvironment(box, "tangle-sandbox", minimalClient, capabilities); const alreadyAborted = new AbortController(); alreadyAborted.abort(); await expect(environment.read?.("/tmp/file", { signal: alreadyAborted.signal })).rejects.toThrow(); @@ -467,7 +491,7 @@ describe("Tangle split leaf modules", () => { async *streamPrompt() {}, session: () => session, }; - const environment = sandboxInstanceAsEnvironment( + const environment = await sandboxInstanceAsEnvironment( box, "tangle-sandbox", minimalClient, diff --git a/packages/agent-provider-tangle/src/retained-control-test-helpers.ts b/packages/agent-provider-tangle/src/retained-control-test-helpers.ts index 2d4b46b..6819dad 100644 --- a/packages/agent-provider-tangle/src/retained-control-test-helpers.ts +++ b/packages/agent-provider-tangle/src/retained-control-test-helpers.ts @@ -4,9 +4,45 @@ import { sessionPromptExecutionId, sessionPromptSessionId, } from "./tangle-session-control.js"; +import type { + SandboxInstanceLike, + SandboxRuntimeCapabilityDocument, +} from "./tangle-types.js"; export const TANGLE_PROVIDER = "tangle-sandbox"; +/** + * A deployment that reports every retained-control flag. Fixtures that expect + * exact dispatch or canonical cancellation must serve this document, because + * the adapter reads the deployment before it offers either operation. + */ +export const RETAINED_DEPLOYMENT_DOCUMENT: SandboxRuntimeCapabilityDocument = { + schema: 1, + agentInterface: "0.49.0", + sidecarVersion: "1.0.0-test", + image: `example/sidecar@sha256:${"a".repeat(64)}`, + dispatch: { runControlRef: true, executionIdOnAdmission: true }, + cancel: { canonicalRunCancellation: true, digestBound: true, idempotent: true }, + runs: { executionScopedStatus: true, eventReplay: true }, + interactions: {}, +}; + +/** + * Give a fake sandbox the running status and capability document a retained + * deployment serves. Without both, the adapter reads no deployment truth and + * claims no retained control. + */ +export function retainedDeployment( + box: SandboxInstanceLike, + document: SandboxRuntimeCapabilityDocument | null = RETAINED_DEPLOYMENT_DOCUMENT, +): SandboxInstanceLike { + return { + ...box, + status: box.status ?? "running", + capabilities: async () => document, + }; +} + export { sessionPromptSessionId }; type SemanticTurnInput = Parameters[0]; diff --git a/packages/agent-provider-tangle/src/retained-control.test.ts b/packages/agent-provider-tangle/src/retained-control.test.ts index ecae18b..aea20b9 100644 --- a/packages/agent-provider-tangle/src/retained-control.test.ts +++ b/packages/agent-provider-tangle/src/retained-control.test.ts @@ -23,6 +23,7 @@ import { retainedSessionControlRef } from "./tangle-session-control.js"; import { controlRefForTurn, executionIdForTurn, + retainedDeployment, sessionPromptSessionId, TANGLE_PROVIDER as PROVIDER, } from "./retained-control-test-helpers.js"; @@ -136,7 +137,7 @@ describe("Tangle retained control", () => { effect: "not_live", }), }); - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-wrapper-grant", async *streamPrompt() {}, dispatchPrompt: async (_message, options) => ({ @@ -148,7 +149,7 @@ describe("Tangle retained control", () => { dispatched: true, }), session: (id) => capableSession(id), - }; + }); const provider = createTangleProvider({ client: { create: async () => box, @@ -204,7 +205,7 @@ describe("Tangle retained control", () => { effect: "not_live", }), }); - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-divergent-session", async *streamPrompt() {}, dispatchPrompt: async () => { @@ -216,7 +217,7 @@ describe("Tangle retained control", () => { const { cancelRun: _cancelRun, ...withoutCancelRun } = session; return withoutCancelRun as SandboxSessionLike; }, - }; + }); const provider = createTangleProvider({ client: { create: async () => box, @@ -374,7 +375,7 @@ describe("Tangle retained control", () => { effect: "not_live", }), }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-runtime-contract", async *streamPrompt(_message, options) { replayCalls.push({ ...(options ?? {}) }); @@ -399,7 +400,7 @@ describe("Tangle retained control", () => { }; }, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: sdkShapedClient({ create: async () => box, @@ -492,7 +493,7 @@ describe("Tangle retained control", () => { turnId: "missing-echo-turn", detach: true, }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-missing-echo", async *streamPrompt() {}, dispatchPrompt: async (_message, options) => ({ @@ -502,7 +503,7 @@ describe("Tangle retained control", () => { alreadyExisted: true, dispatched: false, }), - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -521,7 +522,7 @@ describe("Tangle retained control", () => { it("keeps explicit identity stable and lets changed input reach conflict binding", async () => { const executionId = "caller-owned-execution"; const requestDigests = new Map(); - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-explicit-identity", async *streamPrompt() {}, dispatchPrompt: async (_message, options) => { @@ -541,7 +542,7 @@ describe("Tangle retained control", () => { status: "running", }; }, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -616,7 +617,7 @@ describe("Tangle retained control", () => { it("derives one session for a turn and a new execution for changed input", async () => { const dispatches: PromptOptions[] = []; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-derived-identity", async *streamPrompt() {}, dispatchPrompt: async (_message, options) => { @@ -628,7 +629,7 @@ describe("Tangle retained control", () => { status: "running", }; }, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -675,7 +676,7 @@ describe("Tangle retained control", () => { it("rejects a changed request when the sandbox reports the old execution", async () => { let firstExecutionId: string | undefined; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-stale-dispatch-receipt", async *streamPrompt() {}, dispatchPrompt: async (_message, options) => { @@ -687,7 +688,7 @@ describe("Tangle retained control", () => { status: "running", }; }, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -720,7 +721,7 @@ describe("Tangle retained control", () => { const dispatchCalled = deferred(); const dispatchResult = deferred(); const interrupt = vi.fn(async () => ({ cancelled: true })); - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-detached-abort", async *streamPrompt() { streamEntered.resolve(undefined); @@ -742,7 +743,7 @@ describe("Tangle retained control", () => { }, interrupt, }), - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -808,7 +809,7 @@ describe("Tangle retained control", () => { }, interrupt, }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-duplicate-dispatch", async *streamPrompt() {}, dispatchPrompt: async () => { @@ -816,7 +817,7 @@ describe("Tangle retained control", () => { return dispatchResult.promise; }, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -873,7 +874,7 @@ describe("Tangle retained control", () => { }, interrupt, }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-direct-detach", async *streamPrompt() {}, dispatchPrompt: async (_message, options) => { @@ -889,7 +890,7 @@ describe("Tangle retained control", () => { }; }, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -1004,7 +1005,7 @@ describe("Tangle retained control", () => { }), interrupt: async () => ({ cancelled: true }), }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-replay", async *streamPrompt(_message, options) { capturedOptions = options as Record; @@ -1041,7 +1042,7 @@ describe("Tangle retained control", () => { }; }, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -1207,7 +1208,7 @@ describe("Tangle retained control", () => { interrupt: async () => ({ cancelled: true }), cancelRun, }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-cancel", async *streamPrompt() {}, dispatchPrompt: async (_message, options) => { @@ -1220,7 +1221,7 @@ describe("Tangle retained control", () => { }; }, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: sdkShapedClient({ create: async () => box, @@ -1316,7 +1317,7 @@ describe("Tangle retained control", () => { effect: "not_live", }), }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "conformance-environment", async *streamPrompt(_message, options) { if (options?.executionId !== executionId) { @@ -1356,7 +1357,7 @@ describe("Tangle retained control", () => { }; }, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: sdkShapedClient({ create: async () => box, diff --git a/packages/agent-provider-tangle/src/tangle-capabilities.ts b/packages/agent-provider-tangle/src/tangle-capabilities.ts index cf7fccd..7c1d635 100644 --- a/packages/agent-provider-tangle/src/tangle-capabilities.ts +++ b/packages/agent-provider-tangle/src/tangle-capabilities.ts @@ -9,15 +9,21 @@ import type { SandboxInstanceLike, SandboxSessionLike, } from "./tangle-types.js"; +import { + deploymentBacksExactDispatch, + deploymentBacksRetainedControl, + UNMEASURED_DEPLOYMENT, +} from "./tangle-deployment-capabilities.js"; +import type { DeploymentCapabilitySupport } from "./tangle-deployment-capabilities.js"; /** * The full capability document this adapter supports when the Sandbox client * implements every optional method. * - * This is an upper bound, not a claim. `capabilitiesForClient()` and - * `capabilitiesForSandbox()` narrow it to what the deployment actually - * exposes, because a capability the client cannot back becomes an action the - * caller selects and finds missing. + * This is an upper bound, not a claim. `capabilitiesForClient()` narrows it + * to the adapter surface, and `capabilitiesForSandbox()` narrows it again to + * what the deployment behind one sandbox reports, because a capability + * nothing backs becomes an action the caller selects and finds missing. */ export function defaultTangleSandboxCapabilities( harness?: HarnessType, @@ -47,10 +53,11 @@ export function defaultTangleSandboxCapabilities( streaming: { live: true, replay: true, detach: true, turnIdempotency: true }, // Retained control is declared as intent here and stripped by narrowing // wherever the facts cannot prove dispatchPrompt, session, cancelRun, - // and environment reconstruction by id. The four sub-flags are - // all-or-nothing by design: this adapter implements the identities + // and environment reconstruction by id, or wherever the deployment does + // not report the run-control and cancellation flags. The four sub-flags + // are all-or-nothing by design: this adapter implements the identities // together over one Sandbox surface, and the capability schema refuses - // a partial block, so they stand or fall on the same probed fact set. + // a partial block, so they stand or fall on the same fact set. sessions: { continue: true, list: false, messages: false }, retainedControl: { exactRunIdentity: true, @@ -71,9 +78,11 @@ export function defaultTangleSandboxCapabilities( } /** - * Deployment facts that gate declared capabilities. Every fact defaults to - * false when it cannot be established; a false fact clears the matching - * declared capability. + * Adapter-surface facts that gate declared capabilities: which methods this + * process can actually call. Every fact defaults to false when it cannot be + * established; a false fact clears the matching declared capability. These + * facts bound the claim from above — what the connected deployment honors is + * a separate fact, carried by `DeploymentCapabilitySupport`. */ export interface SandboxCapabilitySupport { /** The provider can rebuild an environment by id (`client.get`). */ @@ -123,13 +132,14 @@ type SandboxHttpClient = ConstructorParameters[0]; /** * Mint a lazy instance handle from the sandbox SDK linked into this process. * The handle measures the LINKED SDK's instance and session method surface — - * an adapter-capability fact, not deployment truth. It is valid exactly when - * the client is SDK-backed (carries the SDK `fetch` transport), because the + * an adapter-surface fact and therefore an upper bound, never a claim that + * the connected service honors those methods. It is valid exactly when the + * client is SDK-backed (carries the SDK `fetch` transport), because the * sandboxes such a client returns are instances of these same classes. - * Deployment truth (what the connected service honors) needs the sidecar - * capability endpoint and is a follow-up. The handle and its probe session - * never leave the process: construction and `session(id)` are lazy in the - * SDK, so no request is sent and no billable resource is created. + * Deployment truth arrives per-sandbox, from `box.capabilities()`, and can + * only narrow this bound. The handle and its probe session never leave the + * process: construction and `session(id)` are lazy in the SDK, so no request + * is sent and no billable resource is created. */ function linkedSdkProbeInstance( client: SandboxClientLike, @@ -149,11 +159,13 @@ function linkedSdkProbeInstance( /** * Establish client-stage facts before any sandbox exists. Two sources: * the client's own members (get, describePlacement) and, for an SDK-backed - * client, the linked SDK surface via `linkedSdkProbeInstance`. Retained - * control fails closed: without a probe handle nothing proves `cancelRun`, - * so the provider must not claim it. Box-scoped workspace and streaming - * facts stay at the declared upper bound when no handle can be minted — - * each concrete sandbox re-narrows them in `capabilitiesForSandbox`. + * client, the linked SDK surface via `linkedSdkProbeInstance`. No deployment + * is reachable at this stage, so these facts are the adapter's upper bound. + * Retained control still fails closed: without a probe handle nothing proves + * `cancelRun`, so the provider must not claim it. Box-scoped workspace and + * streaming facts stay at the declared upper bound when no handle can be + * minted — each concrete sandbox re-narrows them in `capabilitiesForSandbox`, + * where the deployment's own document decides retained control. */ export function clientCapabilitySupport( client: SandboxClientLike, @@ -174,18 +186,22 @@ export function clientCapabilitySupport( } /** - * Narrow a declared capability document to established facts. + * Decide whether retained control may be claimed. * - * Braid derives product actions from these flags, so an over-claimed flag is - * an offered action that throws at the moment the user selects it. Retained - * control requires the complete fact set: exact dispatch, a session handle, - * canonical cancellation, and environment reconstruction by id. + * Two independent fact sets must agree. The adapter surface must be able to + * execute it: exact dispatch, a session handle, canonical cancellation, and + * environment reconstruction by id. The connected deployment must honor it: + * a deployment that reports no exact run reference or no canonical + * cancellation refuses the claim even when every local method exists, + * because a method this process can call is not a run the service retains. */ -export function narrowedTangleCapabilities( +export function tangleRetainedControlSupported( declared: AgentEnvironmentCapabilities, support: SandboxCapabilitySupport, -): AgentEnvironmentCapabilities { - const supportsRetainedControl = + deployment: DeploymentCapabilitySupport, +): boolean { + return ( + deploymentBacksRetainedControl(deployment) && declared.sessions.continue === true && declared.streaming.detach === true && declared.streaming.replay === true && @@ -193,7 +209,31 @@ export function narrowedTangleCapabilities( support.reconstruct && support.dispatchPrompt && support.session && - support.cancelRun; + support.cancelRun + ); +} + +/** + * Narrow a declared capability document to established facts. + * + * Braid derives product actions from these flags, so an over-claimed flag is + * an offered action that throws at the moment the user selects it. Detached + * dispatch carries the caller's exact `runControlRef` and refuses a receipt + * that does not echo it, so `streaming.detach` needs the deployment to accept + * that reference — one flag, narrower than the complete retained-control set. + */ +export function narrowedTangleCapabilities( + declared: AgentEnvironmentCapabilities, + support: SandboxCapabilitySupport, + deployment: DeploymentCapabilitySupport, +): AgentEnvironmentCapabilities { + const supportsRetainedControl = tangleRetainedControlSupported( + declared, + support, + deployment, + ); + const supportsDetach = + support.dispatchPrompt && deploymentBacksExactDispatch(deployment); // A cleared fact forces false; a held fact passes the declared value // through unchanged, so a malformed declaration still reaches the schema // at the provider boundary instead of being laundered into a boolean. @@ -201,7 +241,7 @@ export function narrowedTangleCapabilities( ...declared, streaming: { ...declared.streaming, - detach: support.dispatchPrompt ? declared.streaming.detach : false, + detach: supportsDetach ? declared.streaming.detach : false, replay: support.session ? declared.streaming.replay : false, turnIdempotency: support.session ? declared.streaming.turnIdempotency @@ -242,19 +282,30 @@ export function narrowedTangleCapabilities( /** * Narrow provider-level claims to facts the client can prove before any * sandbox exists. `clientCapabilitySupport` documents which facts stay at - * the declared upper bound when the client offers no probe surface. + * the declared upper bound when the client offers no probe surface. The + * deployment is `unmeasured` here by construction: this stage holds no + * sandbox, so there is nothing to ask. Each concrete sandbox re-narrows + * against its own deployment, and can only come back equal or narrower. */ export function capabilitiesForClient( declared: AgentEnvironmentCapabilities, client: SandboxClientLike, ): AgentEnvironmentCapabilities { - return narrowedTangleCapabilities(declared, clientCapabilitySupport(client)); + return narrowedTangleCapabilities( + declared, + clientCapabilitySupport(client), + UNMEASURED_DEPLOYMENT, + ); } -/** Narrow a declared capability document to what this Sandbox instance backs. */ +/** + * Narrow a declared capability document to what this Sandbox instance backs + * and what the deployment behind it reports. + */ export function capabilitiesForSandbox( declared: AgentEnvironmentCapabilities, support: SandboxCapabilitySupport, + deployment: DeploymentCapabilitySupport, ): AgentEnvironmentCapabilities { - return narrowedTangleCapabilities(declared, support); + return narrowedTangleCapabilities(declared, support, deployment); } diff --git a/packages/agent-provider-tangle/src/tangle-deployment-capabilities.ts b/packages/agent-provider-tangle/src/tangle-deployment-capabilities.ts new file mode 100644 index 0000000..2e4454c --- /dev/null +++ b/packages/agent-provider-tangle/src/tangle-deployment-capabilities.ts @@ -0,0 +1,102 @@ +import type { + SandboxInstanceLike, + SandboxRuntimeCapabilityDocument, +} from "./tangle-types.js"; +import { statusFromUnknown } from "./tangle-environment-values.js"; +import { awaitWithSignal } from "./tangle-contract-safety.js"; + +/** + * What the connected deployment reports about the operations this adapter + * builds on top of a run. + * + * `measured: false` belongs to the client stage alone: no sandbox exists + * there, so no deployment can answer, and the adapter's own method surface + * stands as the upper bound. Every stage that holds a concrete sandbox + * reports `measured: true` with explicit flags, and an unreadable document + * lands there as false — claim nothing rather than assume. + */ +export type DeploymentCapabilitySupport = + | { readonly measured: false } + | { + readonly measured: true; + /** Run requests carry the caller's exact `runControlRef`. */ + readonly exactRunControlRef: boolean; + /** Cancellation is canonical, digest-bound, and idempotent. */ + readonly canonicalCancellation: boolean; + }; + +/** The client stage, where no sandbox exists to interrogate. */ +export const UNMEASURED_DEPLOYMENT: DeploymentCapabilitySupport = { + measured: false, +}; + +/** + * Read the deployment facts out of a capability document. + * + * A missing flag is unknown, and unknown is never a claim, so every flag must + * be present and true to count. Canonical cancellation needs all three of its + * flags together: a cancellation that is not bound to the run's request + * digest, or not idempotent under replay, cannot carry retained control. + */ +export function deploymentCapabilitySupport( + document: SandboxRuntimeCapabilityDocument | null | undefined, +): DeploymentCapabilitySupport { + if (!document || typeof document !== "object") { + return { measured: true, exactRunControlRef: false, canonicalCancellation: false }; + } + return { + measured: true, + exactRunControlRef: document.dispatch?.runControlRef === true, + canonicalCancellation: + document.cancel?.canonicalRunCancellation === true && + document.cancel?.digestBound === true && + document.cancel?.idempotent === true, + }; +} + +/** + * Establish the deployment facts for one sandbox through capability + * discovery. + * + * Three inputs resolve to "nothing claimed" without a request: a Sandbox SDK + * older than 0.22.0, which carries no `capabilities` method; a sandbox that + * is not running, whose capability route can only answer with a state error; + * and a `null` document, which is a deployment predating capability discovery + * or one serving a schema this SDK cannot read. + * + * Every other failure propagates. A malformed document means the deployment + * is defective, and swallowing that would hand back a silently degraded + * environment instead of the defect. + */ +export async function readDeploymentCapabilitySupport( + box: SandboxInstanceLike, + options?: { signal?: AbortSignal }, +): Promise { + if (typeof box.capabilities !== "function") { + return deploymentCapabilitySupport(null); + } + if (statusFromUnknown(box.status) !== "running") { + return deploymentCapabilitySupport(null); + } + options?.signal?.throwIfAborted(); + const document = await awaitWithSignal(box.capabilities(), options?.signal); + options?.signal?.throwIfAborted(); + return deploymentCapabilitySupport(document); +} + +/** Whether the deployment backs a detached run carrying an exact reference. */ +export function deploymentBacksExactDispatch( + deployment: DeploymentCapabilitySupport, +): boolean { + return !deployment.measured || deployment.exactRunControlRef; +} + +/** Whether the deployment backs the complete retained-control identity set. */ +export function deploymentBacksRetainedControl( + deployment: DeploymentCapabilitySupport, +): boolean { + return ( + !deployment.measured || + (deployment.exactRunControlRef && deployment.canonicalCancellation) + ); +} diff --git a/packages/agent-provider-tangle/src/tangle-environment-session.ts b/packages/agent-provider-tangle/src/tangle-environment-session.ts index 7977cea..24a1f75 100644 --- a/packages/agent-provider-tangle/src/tangle-environment-session.ts +++ b/packages/agent-provider-tangle/src/tangle-environment-session.ts @@ -61,19 +61,26 @@ type ExactExecutionEventStream = (options: { controlRef?: AgentExactRunControlRef; }) => AsyncIterable; +/** + * @param retainedControl Whether the environment's narrowed capability + * document grants retained control. Canonical cancellation is offered only + * under that grant: a `cancelRun` method the deployment does not honor is an + * action the caller selects and finds rejected on the wire. + */ export function sandboxSessionAsAgentSession( session: SandboxSessionLike, controlRef: AgentRunControlRef | undefined, provider: string, environmentId: string, - dispatch?: (input: AgentTurnInput) => Promise, - exactExecutionEvents?: ExactExecutionEventStream, + dispatch: ((input: AgentTurnInput) => Promise) | undefined, + exactExecutionEvents: ExactExecutionEventStream | undefined, + retainedControl: boolean, ): AgentSession { let activeControlRef: AgentExactRunControlRef | undefined = controlRef ? resolveRetainedSessionControlRef(controlRef, session.id, provider, environmentId) : undefined; let promptInFlight = false; - const cancelRunMethod = session.cancelRun; + const cancelRunMethod = retainedControl ? session.cancelRun : undefined; const cancelRun = typeof cancelRunMethod === "function" ? async ( request: AgentRunCancellationRequest, diff --git a/packages/agent-provider-tangle/src/tangle-environment.ts b/packages/agent-provider-tangle/src/tangle-environment.ts index 3539ade..ce7ba8b 100644 --- a/packages/agent-provider-tangle/src/tangle-environment.ts +++ b/packages/agent-provider-tangle/src/tangle-environment.ts @@ -30,7 +30,12 @@ import { statusFromUnknown, } from "./tangle-environment-values.js"; import { execResultFromSandboxExecResult } from "./tangle-result-values.js"; -import { capabilitiesForSandbox, sandboxCapabilitySupport } from "./tangle-capabilities.js"; +import { + capabilitiesForSandbox, + sandboxCapabilitySupport, + tangleRetainedControlSupported, +} from "./tangle-capabilities.js"; +import { readDeploymentCapabilitySupport } from "./tangle-deployment-capabilities.js"; import { awaitWithSignal, assertBoundedJson, @@ -47,12 +52,22 @@ import { import { dispatchEnvironmentRun } from "./tangle-environment-dispatch.js"; import { sandboxSessionAsAgentSession } from "./tangle-environment-session.js"; -export function sandboxInstanceAsEnvironment( +/** + * Compose one concrete sandbox into an environment. + * + * This is the only stage that can read deployment truth, so it does: one + * `GET /capabilities` against the sandbox decides retained control, and the + * environment then exposes exactly the operations both the adapter surface + * and the deployment back. A deployment that cannot disclose a readable + * document yields no retained-control surface at all. + */ +export async function sandboxInstanceAsEnvironment( box: SandboxInstanceLike, providerName: string, client: SandboxClientLike, declaredCapabilities: AgentEnvironmentCapabilities, -): AgentEnvironment { + operation?: { signal?: AbortSignal }, +): Promise { const environmentId = boundedIdentifier(box.id, "Tangle environment id"); boundedIdentifier(providerName, "Tangle provider name"); if (box.metadata !== undefined) { @@ -62,7 +77,17 @@ export function sandboxInstanceAsEnvironment( assertBoundedJson(box.metadata); } const support = sandboxCapabilitySupport(box, client); - const capabilities = capabilitiesForSandbox(declaredCapabilities, support); + const deployment = await readDeploymentCapabilitySupport(box, operation); + const capabilities = capabilitiesForSandbox( + declaredCapabilities, + support, + deployment, + ); + const retainedControl = tangleRetainedControlSupported( + declaredCapabilities, + support, + deployment, + ); const dispatch = capabilities.streaming.detach && box.dispatchPrompt ? dispatchEnvironmentRun(box, providerName, environmentId) @@ -169,11 +194,13 @@ export function sandboxInstanceAsEnvironment( environmentId, dispatch, exactExecutionEvents, + retainedControl, ); - // sessions.continue was granted from a probe-session fact; this - // backstop holds every concrete session to that fact, so a client - // whose sessions diverge from its probe surface fails loud here - // instead of failing at the first cancellation. + // sessions.continue was granted from the probe session and the + // deployment document together; this backstop holds every + // concrete session to that grant, so a client whose sessions + // diverge from its probe surface fails loud here instead of + // failing at the first cancellation. if ( capabilities.sessions.continue && typeof agentSession.cancelRun !== "function" diff --git a/packages/agent-provider-tangle/src/tangle-provider.ts b/packages/agent-provider-tangle/src/tangle-provider.ts index bfa2b78..0f0d0b3 100644 --- a/packages/agent-provider-tangle/src/tangle-provider.ts +++ b/packages/agent-provider-tangle/src/tangle-provider.ts @@ -116,11 +116,12 @@ export function createTangleProvider( } try { input.signal?.throwIfAborted(); - const environment = sandboxInstanceAsEnvironment( + const environment = await sandboxInstanceAsEnvironment( box, providerName, options.client, declaredCapabilities, + input.signal ? { signal: input.signal } : undefined, ); input.signal?.throwIfAborted(); return environment; @@ -150,11 +151,12 @@ export function createTangleProvider( const box = await awaitWithSignal(options.client.get?.(id, operation), operation?.signal); operation?.signal?.throwIfAborted(); if (!box || boundedIdentifier(box.id, "Tangle environment id") !== id) return null; - return sandboxInstanceAsEnvironment( + return await sandboxInstanceAsEnvironment( box, providerName, options.client, declaredCapabilities, + operation?.signal ? { signal: operation.signal } : undefined, ); }, } diff --git a/packages/agent-provider-tangle/src/tangle-types.ts b/packages/agent-provider-tangle/src/tangle-types.ts index 7fb3735..080e764 100644 --- a/packages/agent-provider-tangle/src/tangle-types.ts +++ b/packages/agent-provider-tangle/src/tangle-types.ts @@ -49,6 +49,40 @@ export interface SandboxClientLike { describePlacement?(box: SandboxInstanceLike): unknown; } +/** + * The `GET /capabilities` document as this adapter reads it: what the DEPLOYED + * sidecar image compiled in, not which methods the linked SDK class carries. + * + * Every field is optional here. The document's own convention is that a + * missing flag means "unknown to that image", never false, so an absent field + * must reach the caller as unknown instead of being coerced. The SDK's + * `SandboxRuntimeCapabilities` is assignable to this shape; + * `deployment-capabilities.test.ts` pins that against the published type. + */ +export interface SandboxRuntimeCapabilityDocument { + schema?: number; + agentInterface?: string; + sidecarVersion?: string; + image?: string; + dispatch?: { + /** Run requests accept a caller-supplied exact `runControlRef`. */ + runControlRef?: boolean; + executionIdOnAdmission?: boolean; + }; + cancel?: { + canonicalRunCancellation?: boolean; + digestBound?: boolean; + idempotent?: boolean; + }; + runs?: { + executionScopedStatus?: boolean; + eventReplay?: boolean; + }; + interactions?: { + responseDedupe?: boolean; + }; +} + export interface SandboxProcessStatusLike { pid: number; running: boolean; @@ -116,6 +150,15 @@ export interface SandboxInstanceLike { ): Promise; }; process?: SandboxProcessManagerLike; + /** + * Capability discovery against the deployment behind this sandbox. Absent + * on a Sandbox SDK older than 0.22.0, which is why every call site feature- + * detects it: an older SDK cannot read deployment truth, so the adapter + * claims no retained control rather than trusting its own method surface. + * Resolves to null when the deployment cannot disclose a document this SDK + * reads; a malformed document throws. + */ + capabilities?(): Promise; refresh?(options?: { signal?: AbortSignal }): Promise; delete?(options?: { signal?: AbortSignal }): Promise; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60a269d..ec9b8ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,10 +179,10 @@ importers: version: link:../agent-provider-testkit '@tangle-network/agent-runtime': specifier: 0.132.13 - version: 0.132.13(@tangle-network/agent-eval@0.145.3)(@tangle-network/agent-interface@packages+agent-interface)(@tangle-network/sandbox@0.21.1) + version: 0.132.13(@tangle-network/agent-eval@0.145.3)(@tangle-network/agent-interface@packages+agent-interface)(@tangle-network/sandbox@0.22.0) '@tangle-network/sandbox': - specifier: 0.21.1 - version: 0.21.1 + specifier: 0.22.0 + version: 0.22.0 '@types/node': specifier: 'catalog:' version: 25.6.0 @@ -1045,8 +1045,8 @@ packages: '@tangle-network/agent-trace-contract@1.0.2': resolution: {integrity: sha512-v7uMh56jkEp4vckevEU9xKsIatbs5dqzGPp69dFLSSXUVit0RP6VD6EANMXVlTCUk+6wVKBLHJx23XspVCEiIA==} - '@tangle-network/sandbox@0.21.1': - resolution: {integrity: sha512-xlqI9fxq9TLCOnmcxOU2XgOD5Ls6IOxLAbfa3SxVa/wUNnUCHtnwRHYteF7zUCwngVs6HR01+9dyChc0Q8m2tA==} + '@tangle-network/sandbox@0.22.0': + resolution: {integrity: sha512-1dUgpaM3wC4s4G4WPgdodCNFY0Qjb+Rkry6/vUvF95bYMK7MHAoueKRNmoQR3trX2IpNg5cfvpt/9UfvCjzqeQ==} peerDependencies: '@mastra/core': ^1.36.0 '@modelcontextprotocol/sdk': ^1.29.0 @@ -3391,7 +3391,7 @@ snapshots: dependencies: '@tangle-network/agent-interface': link:packages/agent-interface - '@tangle-network/agent-runtime@0.132.13(@tangle-network/agent-eval@0.145.3)(@tangle-network/agent-interface@packages+agent-interface)(@tangle-network/sandbox@0.21.1)': + '@tangle-network/agent-runtime@0.132.13(@tangle-network/agent-eval@0.145.3)(@tangle-network/agent-interface@packages+agent-interface)(@tangle-network/sandbox@0.22.0)': dependencies: '@tangle-network/agent-core': 0.6.1 '@tangle-network/agent-eval': 0.145.3 @@ -3401,7 +3401,7 @@ snapshots: '@tangle-network/agent-trace-contract': 1.0.2 tar-stream: 3.2.0 optionalDependencies: - '@tangle-network/sandbox': 0.21.1 + '@tangle-network/sandbox': 0.22.0 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -3409,7 +3409,7 @@ snapshots: '@tangle-network/agent-trace-contract@1.0.2': {} - '@tangle-network/sandbox@0.21.1': + '@tangle-network/sandbox@0.22.0': dependencies: '@tangle-network/agent-core': 0.6.1 '@tangle-network/agent-interface': link:packages/agent-interface diff --git a/scripts/fixtures/tangle-control-consumer.test.ts b/scripts/fixtures/tangle-control-consumer.test.ts index be2fbf5..34bdf83 100644 --- a/scripts/fixtures/tangle-control-consumer.test.ts +++ b/scripts/fixtures/tangle-control-consumer.test.ts @@ -11,6 +11,7 @@ import { createTangleProvider, type SandboxClientLike, type SandboxInstanceLike, + type SandboxRuntimeCapabilityDocument, type SandboxSessionLike, } from "@tangle-network/agent-provider-tangle"; @@ -20,6 +21,25 @@ function acceptPublicTangleClient(client: TangleSandboxClient): SandboxClientLik void acceptPublicTangleClient; +/** A deployment that reports the complete retained-control flag set. */ +const DEPLOYMENT_CAPABILITIES: SandboxRuntimeCapabilityDocument = { + schema: 1, + agentInterface: "0.49.0", + sidecarVersion: "1.0.0-packed", + image: `example/sidecar@sha256:${"c".repeat(64)}`, + dispatch: { runControlRef: true, executionIdOnAdmission: true }, + cancel: { canonicalRunCancellation: true, digestBound: true, idempotent: true }, + runs: { executionScopedStatus: true, eventReplay: true }, + interactions: {}, +}; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + async function collect(values: AsyncIterable): Promise { const output: T[] = []; for await (const value of values) output.push(value); @@ -33,21 +53,33 @@ describe("packed Tangle exact-session control", () => { const manifest = JSON.parse( readFileSync(resolve(dirname(entry), "..", "package.json"), "utf8"), ) as { version?: unknown }; - expect(manifest.version).toBe("0.21.1"); + expect(manifest.version).toBe("0.22.0"); }); it("adapts the actual public Sandbox instance without inventing branching", async () => { + // Composing an environment reads deployment truth, so the transport + // answers the sandbox lookup and capability discovery and nothing else. + const sandboxInfo = { + id: "sandbox-public-surface", + status: "running" as const, + filesystemIncarnationId: "incarnation-1", + filesystemIncarnationProvenance: "fresh" as const, + filesystemIncarnationReadiness: "ready" as const, + createdAt: "2026-08-01T20:00:00.000Z", + }; const publicInstance = new SandboxInstance( { - fetch: async () => { + fetch: async (path: string) => { + if (path === `/v1/sandboxes/${sandboxInfo.id}`) { + return jsonResponse(sandboxInfo); + } + if (path === `/v1/sandboxes/${sandboxInfo.id}/runtime/capabilities`) { + return jsonResponse(DEPLOYMENT_CAPABILITIES); + } throw new Error("packed surface check must not make a network request"); }, } as never, - { - id: "sandbox-public-surface", - status: "running", - createdAt: new Date("2026-08-01T20:00:00.000Z"), - }, + { ...sandboxInfo, createdAt: new Date(sandboxInfo.createdAt) }, ); const provider = createTangleProvider({ client: { create: async () => publicInstance }, @@ -148,6 +180,8 @@ describe("packed Tangle exact-session control", () => { }; const box: SandboxInstanceLike = { id: "sandbox-1", + status: "running", + capabilities: async () => DEPLOYMENT_CAPABILITIES, async *streamPrompt(_message, options) { eventSelector(options); const events = [ @@ -236,6 +270,8 @@ describe("packed Tangle exact-session control", () => { }; const box: SandboxInstanceLike = { id: "sandbox-unproven", + status: "running", + capabilities: async () => DEPLOYMENT_CAPABILITIES, async *streamPrompt() {}, dispatchPrompt: async (_prompt, options) => ({ sessionId: options?.sessionId ?? session.id, diff --git a/scripts/lib/control-cohort.mjs b/scripts/lib/control-cohort.mjs index aba69af..6bd3541 100644 --- a/scripts/lib/control-cohort.mjs +++ b/scripts/lib/control-cohort.mjs @@ -137,7 +137,7 @@ export function prepareControlCohort() { "@tangle-network/agent-interface": `file:${interfaceTarball}`, "@tangle-network/agent-provider-testkit": `file:${testkitTarball}`, "@tangle-network/agent-provider-tangle": `file:${tangleTarball}`, - "@tangle-network/sandbox": "0.21.1", + "@tangle-network/sandbox": "0.22.0", "@types/node": "25.6.0", }, }, From 5d20e5bc2681a861a732470259c33e427016a0f4 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 13 Aug 2026 14:52:35 -0600 Subject: [PATCH 2/3] fix(provider-tangle): publish the environment-scoped capability document A capability the connected deployment decides cannot be stated before an environment exists, because one provider reaches deployments of different ages. The provider document said a run could detach while the environment it created omitted dispatch, so runAgentEnvironmentProviderConformance failed with "streaming.detach requires dispatch()" for an SDK-backed client whose deployment returns null. AgentEnvironment gains an optional capabilities document. The Tangle provider publishes the narrowed document there, frozen, and derives the session grant from it, so the operations an environment exposes and the document a caller reads are the same fact. The conformance suites bind every environment-scoped check to that document when an environment publishes one, and to the provider document otherwise. The provider document stays the adapter's ceiling. agent-runtime asserts the retained-control block from provider.capabilities() before create, so a fail-closed provider document would refuse retained runs against every deployment, including the ones that back them. Every flag the capability document carries now gates the claims it backs. streaming.detach and streaming.turnIdempotency need dispatch.runControlRef with dispatch.executionIdOnAdmission; streaming.replay needs runs.eventReplay; retainedControl needs those with the three cancel flags and runs.executionScopedStatus. Detach also needs a session handle, because a detached run is reachable only through one. A failed capability read no longer fails create() and no longer deletes a sandbox that a cold provision has just paid for. It claims nothing and reports the failure on the warning channel. The caller's own abort still propagates. --- .changeset/lucky-pans-clap.md | 17 +- .changeset/tidy-moons-report.md | 12 + .../src/environment-runtime.ts | 13 + packages/agent-provider-tangle/README.md | 45 ++- .../src/deployment-capabilities.test.ts | 329 +++++++++++++----- .../agent-provider-tangle/src/index.test.ts | 30 +- .../src/leaf-modules.test.ts | 13 +- .../src/retained-control-test-helpers.ts | 35 +- .../src/retained-control.test.ts | 44 +-- .../src/tangle-capabilities.ts | 76 ++-- .../src/tangle-deployment-capabilities.ts | 154 +++++--- .../src/tangle-environment.ts | 30 +- .../agent-provider-tangle/src/tangle-types.ts | 21 +- .../src/conformance-helpers.ts | 18 + .../src/conformance-types.ts | 6 + .../src/provider-conformance.ts | 37 +- .../src/session-replay-conformance.ts | 20 +- .../fixtures/tangle-control-consumer.test.ts | 28 +- 18 files changed, 651 insertions(+), 277 deletions(-) create mode 100644 .changeset/tidy-moons-report.md diff --git a/.changeset/lucky-pans-clap.md b/.changeset/lucky-pans-clap.md index 69223aa..8de4b14 100644 --- a/.changeset/lucky-pans-clap.md +++ b/.changeset/lucky-pans-clap.md @@ -2,10 +2,15 @@ "@tangle-network/agent-provider-tangle": minor --- -Derive retained control from deployment capability discovery. +Derive retained control from deployment capability discovery, and publish the result on the environment. -Composing an environment now calls `box.capabilities()` once and takes the retained-control claim from that document instead of from the linked Sandbox SDK's method surface. -`retainedControl` needs `dispatch.runControlRef` with `cancel.canonicalRunCancellation`, `cancel.digestBound`, and `cancel.idempotent`; `streaming.detach` needs `dispatch.runControlRef`. -An SDK older than 0.22.0, a sandbox that is not running, a `null` document, or a missing flag claims nothing: the environment omits `dispatch` and its sessions omit `cancelRun`. -A malformed document throws and `create()` deletes the sandbox. -The client stage keeps its adapter-surface probe as an upper bound, because no deployment exists to ask before a sandbox is created. +Composing an environment now calls `box.capabilities()` once and takes every deployment-decided claim from that document instead of from the linked Sandbox SDK's method surface. +The narrowed document is published as `environment.capabilities`, which is the document to read before offering an operation: the operations an environment exposes match it exactly, while `provider.capabilities()` states the adapter's ceiling before any sandbox exists. + +Every flag the capability document carries now gates the claims it backs. +`streaming.detach` and `streaming.turnIdempotency` need `dispatch.runControlRef` with `dispatch.executionIdOnAdmission`; `streaming.replay` needs `runs.eventReplay`; `retainedControl` needs those plus `cancel.canonicalRunCancellation`, `cancel.digestBound`, `cancel.idempotent`, and `runs.executionScopedStatus`. +Detached dispatch also needs a session handle, because a detached run is reachable only through one. + +Five inputs claim nothing: an SDK older than 0.22.0, a sandbox that is not running, a `null` document, a document that leaves a required flag unset, and a capability read that fails. +Such environments omit `dispatch` and `session`. +A failed read no longer fails `create()` and no longer deletes the sandbox a cold provision has already paid for; it claims nothing and reports the failure on the warning channel. diff --git a/.changeset/tidy-moons-report.md b/.changeset/tidy-moons-report.md new file mode 100644 index 0000000..c2a24a4 --- /dev/null +++ b/.changeset/tidy-moons-report.md @@ -0,0 +1,12 @@ +--- +"@tangle-network/agent-interface": minor +"@tangle-network/agent-provider-testkit": minor +--- + +Add the environment-scoped capability document. + +`AgentEnvironment.capabilities` is an optional document that describes one environment. +A capability the connected deployment decides cannot be stated by `AgentEnvironmentProvider.capabilities()`, because one provider reaches deployments of different ages; a provider that measures such a capability per environment publishes the measured answer here, and the operations that environment exposes match it. + +`runAgentEnvironmentProviderConformance` and `runSessionReplayConformance` now bind every environment-scoped check to that document when the environment publishes one, and to the provider document otherwise. +The provider report gains `environmentCapabilities`, which is the document the checks ran against. diff --git a/packages/agent-interface/src/environment-runtime.ts b/packages/agent-interface/src/environment-runtime.ts index 59b5302..0174fa1 100644 --- a/packages/agent-interface/src/environment-runtime.ts +++ b/packages/agent-interface/src/environment-runtime.ts @@ -233,6 +233,19 @@ export interface AgentEnvironment { readonly id: string; readonly provider: string; readonly name?: string; + /** + * The capability document for THIS environment, and the document a caller + * reads to decide which operation to offer against it. + * + * A capability the connected deployment decides is environment-scoped: one + * provider reaches deployments of different ages, so + * {@link AgentEnvironmentProvider.capabilities} can only state what holds + * before an environment exists. A provider that measures a capability per + * environment publishes the measured answer here, and the operations this + * environment exposes match it exactly. Absent when the provider document + * already describes every environment it creates. + */ + readonly capabilities?: AgentEnvironmentCapabilities; status(options?: { signal?: AbortSignal }): Promise; stream(input: AgentTurnInput): AsyncIterable; dispatch?(input: AgentTurnInput): Promise; diff --git a/packages/agent-provider-tangle/README.md b/packages/agent-provider-tangle/README.md index f53bdb6..cb23116 100644 --- a/packages/agent-provider-tangle/README.md +++ b/packages/agent-provider-tangle/README.md @@ -19,22 +19,45 @@ Reconstruct an exact session with `environment.session(reference.id, { controlRe 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`. -Capabilities are derived in two stages, and each stage claims only what it can establish. +## Two capability documents -The client stage runs before any sandbox exists, so it can only measure the adapter surface. +Capabilities are derived in two stages, and the two stages answer different questions. + +`provider.capabilities()` answers "what can this provider do against a deployment that backs it". +It runs before any sandbox exists, so it measures the adapter surface alone and states the adapter's ceiling for everything a deployment decides. A lazy instance handle minted from the linked Sandbox SDK over the client's `fetch` transport must prove `dispatchPrompt`, `session`, and `cancelRun`, and the client must expose `get` for reconstruction; the probe sends no request and creates no resource. -That handle measures the linked SDK's method surface, which is an upper bound and never a statement about the connected service. -A client that cannot prove those facts gets no claim, so the runtime rejects retained dispatch before any sandbox is created. +That handle measures the linked SDK's method surface, never the connected service. +A client that cannot prove those facts gets no retained-control claim, so the runtime rejects retained dispatch before any sandbox is created. + +`environment.capabilities` answers "what can this environment do", and it is the document to read before offering an operation. +Composing an environment calls `box.capabilities()` once and derives every deployment-decided claim from that document. +The operations an environment exposes match its own document exactly: a claim the document does not carry has no method behind it. +One provider reaches deployments of different ages, which is why the environment carries its own document rather than inheriting the provider's. + +Each deployment flag this adapter reads gates the claims it backs, and no flag is read that gates nothing: -The sandbox stage reads deployment truth. -Composing an environment calls `box.capabilities()` once and derives the retained-control claim from that document, not from the linked SDK. -`retainedControl` needs `dispatch.runControlRef` together with `cancel.canonicalRunCancellation`, `cancel.digestBound`, and `cancel.idempotent`; `streaming.detach` needs `dispatch.runControlRef` alone, because detached dispatch carries the caller's exact reference and refuses a receipt that does not echo it. +| Deployment flag | Claims it gates | +| --- | --- | +| `dispatch.runControlRef` | `streaming.detach`, `streaming.turnIdempotency`, `retainedControl` | +| `dispatch.executionIdOnAdmission` | `streaming.detach`, `streaming.turnIdempotency`, `retainedControl` | +| `cancel.canonicalRunCancellation` | `retainedControl`, `sessions.continue`, `session.cancelRun` | +| `cancel.digestBound` | `retainedControl`, `sessions.continue`, `session.cancelRun` | +| `cancel.idempotent` | `retainedControl`, `sessions.continue`, `session.cancelRun` | +| `runs.eventReplay` | `streaming.replay`, `retainedControl` | +| `runs.executionScopedStatus` | `retainedControl`, `sessions.continue` | + +Detached dispatch carries the caller's exact reference and refuses a receipt that does not name the execution back, so it needs both `dispatch` flags and a session handle to reach the run through. +`retainedControl` needs every flag in the table, because the capability schema refuses a partial block and each identity rests on its own flag. A missing flag means unknown, and unknown is never a claim. -The adapter surface stays the ceiling: a deployment claim can only narrow what the client can execute, never widen it. -Four inputs claim nothing at all: a Sandbox SDK older than 0.22.0, a sandbox that is not running, a `null` document (a deployment predating capability discovery, or one serving a newer schema this SDK cannot read), and a document that leaves any required flag unset. -In each case the environment omits `dispatch` and its sessions omit `cancelRun`, so a caller never selects an action the deployment will reject. -A malformed document is different: capability discovery throws, `create()` deletes the sandbox it just made, and the error propagates. +Five inputs claim nothing at all: a Sandbox SDK older than 0.22.0, a sandbox that is not running, a `null` document (a deployment predating capability discovery, or one serving a newer schema this SDK cannot read), a document that leaves any required flag unset, and a capability read that fails. +In each case the environment omits `dispatch` and `session`, so a caller never selects an action the deployment will reject. +A failed read claims nothing rather than failing `create()`: discovery runs against a sandbox a cold provision has already paid for, and a transport failure is not evidence about the deployment. +The failure is reported on the warning channel. + +The document is measured once, when the environment is composed. +A sandbox that is not yet running cannot answer, so an environment composed during provisioning claims nothing and keeps claiming nothing — the exposed operations and the document are composed together, and a caller may already hold either one. +Compose the environment again through `provider.get(id)` once the sandbox is running. Pass the SDK client itself when retained control matters. An object-spread wrapper (`{ ...client }`) drops class prototype methods, including `fetch`, so the provider treats the wrapper as a non-SDK client and claims no retained control. diff --git a/packages/agent-provider-tangle/src/deployment-capabilities.test.ts b/packages/agent-provider-tangle/src/deployment-capabilities.test.ts index 260563b..e40a2be 100644 --- a/packages/agent-provider-tangle/src/deployment-capabilities.test.ts +++ b/packages/agent-provider-tangle/src/deployment-capabilities.test.ts @@ -1,16 +1,22 @@ import { describe, expect, it, vi } from "vitest"; -import type { SandboxRuntimeCapabilities } from "@tangle-network/sandbox"; +import type { SandboxEvent, SandboxRuntimeCapabilities } from "@tangle-network/sandbox"; +import { runAgentEnvironmentProviderConformance } from "@tangle-network/agent-provider-testkit"; import { agentRunCancellationRequestDigest } from "@tangle-network/agent-interface"; import type { AgentExactRunControlRef } from "@tangle-network/agent-interface"; -import type { PromptOptions } from "@tangle-network/sandbox"; import { createTangleProvider, + type SandboxClientLike, type SandboxInstanceLike, type SandboxRuntimeCapabilityDocument, - type SandboxSessionLike, } from "./index.js"; -import { deploymentCapabilitySupport } from "./tangle-deployment-capabilities.js"; -import { RETAINED_DEPLOYMENT_DOCUMENT } from "./retained-control-test-helpers.js"; +import { + deploymentCapabilitySupport, + UNPROVEN_DEPLOYMENT, +} from "./tangle-deployment-capabilities.js"; +import { + RETAINED_DEPLOYMENT_DOCUMENT, + retainedSessionHandle, +} from "./retained-control-test-helpers.js"; /** * The published SDK document is the wire fact this adapter reads. Assigning @@ -30,38 +36,6 @@ const PUBLISHED_DOCUMENT: SandboxRuntimeCapabilities = { const PUBLISHED_DOCUMENT_AS_READ: SandboxRuntimeCapabilityDocument = PUBLISHED_DOCUMENT; -function echoedExecution(options: PromptOptions | undefined) { - return options?.executionId; -} - -function capableSession(id: string): SandboxSessionLike { - return { - id, - status: async () => ({ status: "running" }), - async *events() {}, - result: async (options) => ({ - success: true, - status: "success", - executionId: echoedExecution(options), - durationMs: 1, - }), - prompt: async (_message, options) => ({ - success: true, - status: "success", - executionId: echoedExecution(options), - durationMs: 1, - }), - interrupt: async () => ({ cancelled: true }), - cancelRun: async (request) => ({ - operationId: request.operationId, - requestDigest: request.requestDigest, - run: request.run, - status: "accepted", - effect: "not_live", - }), - }; -} - /** * One capable sandbox behind one deployment. Every local method retained * control needs is present, so the capability document is the only variable: @@ -85,7 +59,7 @@ function deployedProvider(options: { alreadyExisted: false, dispatched: true, }), - session: (id) => capableSession(id), + session: retainedSessionHandle, delete: deleted, ...(options.capabilities ? { capabilities: options.capabilities } : {}), }; @@ -98,6 +72,60 @@ function deployedProvider(options: { return { provider, box, sessionId, deleted }; } +/** + * One SDK-backed client, so the client stage mints the linked SDK probe and + * measures its complete method surface. The sandbox carries every workspace + * and session method that surface promises, which leaves the deployment + * document as the single variable between the two capability stages. + */ +function sdkBackedProvider(document: SandboxRuntimeCapabilityDocument | null) { + const files = new Map(); + const sessionId = "session-sdk-backed"; + const box: SandboxInstanceLike = { + id: "sbx-sdk-backed", + status: "running", + async *streamPrompt(_message, promptOptions): AsyncIterable { + yield { + type: "result", + data: { + finalText: "ok", + sessionId: promptOptions?.sessionId ?? sessionId, + ...(promptOptions?.executionId + ? { executionId: promptOptions.executionId } + : {}), + }, + } as SandboxEvent; + }, + dispatchPrompt: async (_message, promptOptions) => ({ + sessionId: promptOptions?.sessionId ?? sessionId, + executionId: promptOptions?.executionId, + runControlRef: promptOptions?.runControlRef, + status: "running", + alreadyExisted: false, + dispatched: true, + }), + session: retainedSessionHandle, + read: async (path) => files.get(path) ?? "", + write: async (path, content) => { + files.set(path, content); + return { path, written: true }; + }, + exec: async () => ({ exitCode: 0, stdout: "ok\n", stderr: "" }), + capabilities: async () => document, + delete: async () => undefined, + }; + const client: SandboxClientLike = { + create: async () => box, + get: async (id) => (id === box.id ? box : null), + // The SDK transport the client stage mints its probe over. The probe is + // lazy, so a request here means it stopped being lazy. + fetch: async () => { + throw new Error("the capability probe must not send a request"); + }, + }; + return { provider: createTangleProvider({ client }), box, sessionId }; +} + describe("Tangle deployment capability discovery", () => { it("claims retained control when the deployment reports the complete flag set", async () => { const capabilities = vi.fn(async () => PUBLISHED_DOCUMENT_AS_READ); @@ -128,29 +156,34 @@ describe("Tangle deployment capability discovery", () => { it("claims nothing when the deployment cannot disclose a document", async () => { // A deployment predating capability discovery, or one serving a schema - // this SDK cannot read, arrives as null. Unknown is not a claim. - const { provider, sessionId } = deployedProvider({ - capabilities: async () => null, - }); + // this SDK cannot read, arrives as null. Unknown is not a claim, and the + // session surface goes with it: result identity, cursor replay, and + // canonical cancellation all rest on facts this deployment never reported. + const { provider } = deployedProvider({ capabilities: async () => null }); const environment = await provider.create({ profile: { name: "worker" } }); expect(environment.dispatch).toBeUndefined(); - expect(environment.session!(sessionId).cancelRun).toBeUndefined(); + expect(environment.session).toBeUndefined(); + expect(environment.capabilities).toMatchObject({ + streaming: { detach: false, replay: false, turnIdempotency: false }, + sessions: { continue: false }, + }); + expect(environment.capabilities).not.toHaveProperty("retainedControl"); }); it("claims nothing when the linked SDK predates capability discovery", async () => { // No `capabilities` method at all: the adapter cannot read deployment // truth, so it must not fall back to its own method surface. - const { provider, sessionId } = deployedProvider({}); + const { provider } = deployedProvider({}); const environment = await provider.create({ profile: { name: "worker" } }); expect(environment.dispatch).toBeUndefined(); - expect(environment.session!(sessionId).cancelRun).toBeUndefined(); + expect(environment.session).toBeUndefined(); }); it("claims nothing when the sandbox is not running to answer", async () => { const capabilities = vi.fn(async () => PUBLISHED_DOCUMENT_AS_READ); - const { provider, sessionId } = deployedProvider({ + const { provider } = deployedProvider({ capabilities, status: "stopped", }); @@ -158,66 +191,188 @@ describe("Tangle deployment capability discovery", () => { expect(capabilities).not.toHaveBeenCalled(); expect(environment!.dispatch).toBeUndefined(); - expect(environment!.session!(sessionId).cancelRun).toBeUndefined(); + expect(environment!.session).toBeUndefined(); }); - it("drops cancellation but keeps dispatch when only the cancel flags are unreported", async () => { - // Absence is unknown, so a document that never mentions idempotent - // cancellation cannot carry retained control. Exact dispatch is a - // separate flag and survives on its own evidence. - const { provider, sessionId } = deployedProvider({ - capabilities: async () => ({ - ...RETAINED_DEPLOYMENT_DOCUMENT, - cancel: { canonicalRunCancellation: true, digestBound: true }, - }), - }); - const environment = await provider.create({ profile: { name: "worker" } }); + it("drops every claim its single missing flag backs", async () => { + // One flag at a time, dropped from a complete document. Each row states + // what survives without that flag, so a claim that outlives the flag it + // rests on fails here instead of reaching a caller. + const cases = [ + { + flag: "dispatch.runControlRef", + document: { + ...RETAINED_DEPLOYMENT_DOCUMENT, + dispatch: { executionIdOnAdmission: true }, + }, + detach: false, + replay: true, + }, + { + flag: "dispatch.executionIdOnAdmission", + document: { + ...RETAINED_DEPLOYMENT_DOCUMENT, + dispatch: { runControlRef: true }, + }, + detach: false, + replay: true, + }, + { + flag: "cancel.canonicalRunCancellation", + document: { + ...RETAINED_DEPLOYMENT_DOCUMENT, + cancel: { digestBound: true, idempotent: true }, + }, + detach: true, + replay: true, + }, + { + flag: "cancel.digestBound", + document: { + ...RETAINED_DEPLOYMENT_DOCUMENT, + cancel: { canonicalRunCancellation: true, idempotent: true }, + }, + detach: true, + replay: true, + }, + { + flag: "cancel.idempotent", + document: { + ...RETAINED_DEPLOYMENT_DOCUMENT, + cancel: { canonicalRunCancellation: true, digestBound: true }, + }, + detach: true, + replay: true, + }, + { + flag: "runs.eventReplay", + document: { + ...RETAINED_DEPLOYMENT_DOCUMENT, + runs: { executionScopedStatus: true }, + }, + detach: true, + replay: false, + }, + { + flag: "runs.executionScopedStatus", + document: { + ...RETAINED_DEPLOYMENT_DOCUMENT, + runs: { eventReplay: true }, + }, + detach: true, + replay: true, + }, + ] as const; - expect(typeof environment.dispatch).toBe("function"); - expect(environment.session!(sessionId).cancelRun).toBeUndefined(); + for (const testCase of cases) { + const { provider, sessionId } = deployedProvider({ + capabilities: async () => testCase.document, + }); + const environment = await provider.create({ profile: { name: "worker" } }); + const claimed = environment.capabilities!; + + expect({ + flag: testCase.flag, + detach: claimed.streaming.detach, + replay: claimed.streaming.replay, + continued: claimed.sessions.continue, + }).toEqual({ + flag: testCase.flag, + detach: testCase.detach, + replay: testCase.replay, + continued: false, + }); + expect(claimed).not.toHaveProperty("retainedControl"); + expect(environment.session!(sessionId).cancelRun).toBeUndefined(); + } }); - it("drops dispatch when the deployment does not accept an exact run reference", async () => { - const { provider, sessionId } = deployedProvider({ - capabilities: async () => ({ - ...RETAINED_DEPLOYMENT_DOCUMENT, - dispatch: { executionIdOnAdmission: true }, - }), - }); - const environment = await provider.create({ profile: { name: "worker" } }); + it("claims nothing and keeps the sandbox when capability discovery fails", async () => { + // Discovery runs against a sandbox a cold provision has already paid for. + // A failed read is not evidence about the deployment, so the adapter + // claims nothing, reports the failure, and keeps the sandbox. + const warned = vi.spyOn(console, "warn").mockImplementation(() => undefined); + try { + const { provider, deleted } = deployedProvider({ + capabilities: async () => { + throw new Error("Capability discovery returned a non-object document"); + }, + }); - expect(environment.dispatch).toBeUndefined(); - expect(environment.session!(sessionId).cancelRun).toBeUndefined(); + const environment = await provider.create({ profile: { name: "worker" } }); + + expect(deleted).not.toHaveBeenCalled(); + expect(environment.dispatch).toBeUndefined(); + expect(environment.session).toBeUndefined(); + expect(warned).toHaveBeenCalledWith( + expect.stringContaining("Tangle capability discovery failed"), + expect.any(Error), + ); + } finally { + warned.mockRestore(); + } }); - it("fails loud and deletes the sandbox when capability discovery breaks", async () => { + it("propagates the caller's own abort instead of claiming nothing", async () => { + const controller = new AbortController(); const { provider, deleted } = deployedProvider({ - capabilities: async () => { - throw new Error("Capability discovery returned a non-object document"); + capabilities: () => { + controller.abort(new Error("caller cancelled")); + // A read still in flight when the caller aborts: the abort decides. + return new Promise(() => undefined); }, }); await expect( - provider.create({ profile: { name: "worker" } }), - ).rejects.toThrow(/Capability discovery returned/); + provider.create({ profile: { name: "worker" }, signal: controller.signal }), + ).rejects.toThrow(/caller cancelled/); expect(deleted).toHaveBeenCalledTimes(1); }); + it("pairs the client-stage document against the sandbox surface it produces", async () => { + // The client-stage document is the only capability document a consumer + // reads, so it must describe the environment this provider builds. An + // SDK-backed client measures the linked SDK's full method surface, which + // leaves the deployment document as the single variable: run the repo's + // conformance suite over both answers a deployment can give. + for (const document of [null, RETAINED_DEPLOYMENT_DOCUMENT]) { + const { provider } = sdkBackedProvider(document); + await expect( + runAgentEnvironmentProviderConformance({ + name: "tangle-deployment", + createProvider: () => provider, + }), + ).resolves.toMatchObject({ provider: "tangle-sandbox" }); + } + }); + + it("keeps the published environment document beyond a caller's reach", async () => { + // The document and the exposed operations are decided together. A caller + // that could write a flag would describe a surface this environment has + // no method for, which is the disagreement the document exists to close. + const { provider } = sdkBackedProvider(null); + const environment = await provider.create({ profile: { name: "worker" } }); + const published = environment.capabilities as { + streaming: { detach: boolean }; + }; + + expect(() => { + published.streaming.detach = true; + }).toThrow(TypeError); + expect(environment.capabilities!.streaming.detach).toBe(false); + expect(environment.dispatch).toBeUndefined(); + }); + it("reads every flag as unknown until the document sets it", () => { expect(deploymentCapabilitySupport(PUBLISHED_DOCUMENT_AS_READ)).toEqual({ - measured: true, - exactRunControlRef: true, + exactDispatch: true, canonicalCancellation: true, + eventReplay: true, + executionScopedStatus: true, }); - expect(deploymentCapabilitySupport(null)).toEqual({ - measured: true, - exactRunControlRef: false, - canonicalCancellation: false, - }); - expect(deploymentCapabilitySupport({ schema: 1 })).toEqual({ - measured: true, - exactRunControlRef: false, - canonicalCancellation: false, - }); + expect(deploymentCapabilitySupport(null)).toEqual(UNPROVEN_DEPLOYMENT); + expect(deploymentCapabilitySupport({ schema: 1 })).toEqual( + UNPROVEN_DEPLOYMENT, + ); }); }); diff --git a/packages/agent-provider-tangle/src/index.test.ts b/packages/agent-provider-tangle/src/index.test.ts index 6c3983a..c482032 100644 --- a/packages/agent-provider-tangle/src/index.test.ts +++ b/packages/agent-provider-tangle/src/index.test.ts @@ -20,6 +20,7 @@ import { controlRefForTurn, executionIdForTurn, retainedDeployment, + retainedSessionHandle, TANGLE_PROVIDER, } from "./retained-control-test-helpers.js"; @@ -287,11 +288,11 @@ describe("createTangleProvider", () => { }, interrupt: async () => ({ cancelled: true }), }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: environmentId, async *streamPrompt() {}, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -553,11 +554,11 @@ describe("createTangleProvider", () => { }, interrupt: async () => ({ cancelled: true }), }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-session-advance", async *streamPrompt(): AsyncIterable {}, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -640,11 +641,11 @@ describe("createTangleProvider", () => { }, interrupt: async () => ({ cancelled: true }), }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-turn-identity", async *streamPrompt() {}, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -716,6 +717,7 @@ describe("createTangleProvider", () => { sessionId: options?.sessionId, executionId: "execution-from-server", }), + session: retainedSessionHandle, }); const provider = createTangleProvider({ client: { create: async () => box }, @@ -1046,7 +1048,7 @@ describe("createTangleProvider", () => { }), interrupt: async () => ({ cancelled: true }), }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-competing-event", async *streamPrompt() { yield { @@ -1060,7 +1062,7 @@ describe("createTangleProvider", () => { } as SandboxEvent; }, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -1101,11 +1103,11 @@ describe("createTangleProvider", () => { }), interrupt, }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-unbound", async *streamPrompt() {}, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -1144,11 +1146,11 @@ describe("createTangleProvider", () => { prompt: async () => result, interrupt: async () => ({ cancelled: true }), }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: environmentId, async *streamPrompt() {}, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -1219,11 +1221,11 @@ describe("createTangleProvider", () => { }), interrupt: async () => ({ cancelled: true }), }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: environmentId, async *streamPrompt(): AsyncIterable {}, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); diff --git a/packages/agent-provider-tangle/src/leaf-modules.test.ts b/packages/agent-provider-tangle/src/leaf-modules.test.ts index 53b7734..92a1170 100644 --- a/packages/agent-provider-tangle/src/leaf-modules.test.ts +++ b/packages/agent-provider-tangle/src/leaf-modules.test.ts @@ -9,7 +9,10 @@ import { sandboxCapabilitySupport, } from "./tangle-capabilities.js"; import { deploymentCapabilitySupport } from "./tangle-deployment-capabilities.js"; -import { RETAINED_DEPLOYMENT_DOCUMENT } from "./retained-control-test-helpers.js"; +import { + RETAINED_DEPLOYMENT_DOCUMENT, + retainedDeployment, +} from "./retained-control-test-helpers.js"; import { assertBoundedJson, awaitWithSignal, @@ -438,7 +441,7 @@ describe("Tangle split leaf modules", () => { prompt: async () => promptResult(), interrupt: async () => interruptPending.promise, }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-1", status: "running", async *streamPrompt() {}, @@ -446,7 +449,7 @@ describe("Tangle split leaf modules", () => { read: async () => readPending.promise, exec: async () => execPending.promise as never, refresh: async () => refreshPending.promise, - }; + }); const environment = await sandboxInstanceAsEnvironment(box, "tangle-sandbox", minimalClient, capabilities); const alreadyAborted = new AbortController(); alreadyAborted.abort(); @@ -486,11 +489,11 @@ describe("Tangle split leaf modules", () => { prompt: async () => promptResult(), interrupt: async () => ({ cancelled: true }), }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "replay-environment", async *streamPrompt() {}, session: () => session, - }; + }); const environment = await sandboxInstanceAsEnvironment( box, "tangle-sandbox", diff --git a/packages/agent-provider-tangle/src/retained-control-test-helpers.ts b/packages/agent-provider-tangle/src/retained-control-test-helpers.ts index 6819dad..e6cbbc7 100644 --- a/packages/agent-provider-tangle/src/retained-control-test-helpers.ts +++ b/packages/agent-provider-tangle/src/retained-control-test-helpers.ts @@ -7,6 +7,7 @@ import { import type { SandboxInstanceLike, SandboxRuntimeCapabilityDocument, + SandboxSessionLike, } from "./tangle-types.js"; export const TANGLE_PROVIDER = "tangle-sandbox"; @@ -24,7 +25,6 @@ export const RETAINED_DEPLOYMENT_DOCUMENT: SandboxRuntimeCapabilityDocument = { dispatch: { runControlRef: true, executionIdOnAdmission: true }, cancel: { canonicalRunCancellation: true, digestBound: true, idempotent: true }, runs: { executionScopedStatus: true, eventReplay: true }, - interactions: {}, }; /** @@ -43,6 +43,39 @@ export function retainedDeployment( }; } +/** + * The session surface a sandbox behind a retained deployment carries. A + * fixture that dispatches needs one: a detached run is reachable only through + * a session handle, so the adapter claims no detach without it. + */ +export function retainedSessionHandle(id: string): SandboxSessionLike { + return { + id, + status: async () => ({ status: "running" }), + async *events() {}, + result: async (options) => ({ + success: true, + status: "success", + executionId: options?.executionId, + durationMs: 1, + }), + prompt: async (_message, options) => ({ + success: true, + status: "success", + executionId: options?.executionId, + durationMs: 1, + }), + interrupt: async () => ({ cancelled: true }), + cancelRun: async (request) => ({ + operationId: request.operationId, + requestDigest: request.requestDigest, + run: request.run, + status: "accepted", + effect: "not_live", + }), + }; +} + export { sessionPromptSessionId }; type SemanticTurnInput = Parameters[0]; diff --git a/packages/agent-provider-tangle/src/retained-control.test.ts b/packages/agent-provider-tangle/src/retained-control.test.ts index aea20b9..a31f32b 100644 --- a/packages/agent-provider-tangle/src/retained-control.test.ts +++ b/packages/agent-provider-tangle/src/retained-control.test.ts @@ -24,6 +24,7 @@ import { controlRefForTurn, executionIdForTurn, retainedDeployment, + retainedSessionHandle, sessionPromptSessionId, TANGLE_PROVIDER as PROVIDER, } from "./retained-control-test-helpers.js"; @@ -112,31 +113,6 @@ describe("Tangle retained control", () => { // claim, but the concrete box proves every fact, so the sandbox stage // must still grant retained control on the production create path. const sessionId = "session-wrapper-grant"; - const capableSession = (id: string): SandboxSessionLike => ({ - id, - status: async () => ({ status: "running" }), - async *events() {}, - result: async (options) => ({ - success: true, - status: "success", - executionId: echoedExecution(options), - durationMs: 1, - }), - prompt: async (_message, options) => ({ - success: true, - status: "success", - executionId: echoedExecution(options), - durationMs: 1, - }), - interrupt: async () => ({ cancelled: true }), - cancelRun: async (request) => ({ - operationId: request.operationId, - requestDigest: request.requestDigest, - run: request.run, - status: "accepted", - effect: "not_live", - }), - }); const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-wrapper-grant", async *streamPrompt() {}, @@ -148,7 +124,7 @@ describe("Tangle retained control", () => { alreadyExisted: false, dispatched: true, }), - session: (id) => capableSession(id), + session: retainedSessionHandle, }); const provider = createTangleProvider({ client: { @@ -279,11 +255,11 @@ describe("Tangle retained control", () => { }), interrupt: async () => ({ cancelled: true }), }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-status-binding", async *streamPrompt() {}, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -503,6 +479,7 @@ describe("Tangle retained control", () => { alreadyExisted: true, dispatched: false, }), + session: retainedSessionHandle, }); const provider = createTangleProvider({ client: { create: async () => box }, @@ -542,6 +519,7 @@ describe("Tangle retained control", () => { status: "running", }; }, + session: retainedSessionHandle, }); const provider = createTangleProvider({ client: { create: async () => box }, @@ -629,6 +607,7 @@ describe("Tangle retained control", () => { status: "running", }; }, + session: retainedSessionHandle, }); const provider = createTangleProvider({ client: { create: async () => box }, @@ -688,6 +667,7 @@ describe("Tangle retained control", () => { status: "running", }; }, + session: retainedSessionHandle, }); const provider = createTangleProvider({ client: { create: async () => box }, @@ -1104,11 +1084,11 @@ describe("Tangle retained control", () => { }), interrupt: async () => ({ cancelled: true }), }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: environmentId, async *streamPrompt() {}, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -1154,11 +1134,11 @@ describe("Tangle retained control", () => { }, interrupt: async () => ({ cancelled: true }), }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: environmentId, async *streamPrompt() {}, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); diff --git a/packages/agent-provider-tangle/src/tangle-capabilities.ts b/packages/agent-provider-tangle/src/tangle-capabilities.ts index 7c1d635..d82f587 100644 --- a/packages/agent-provider-tangle/src/tangle-capabilities.ts +++ b/packages/agent-provider-tangle/src/tangle-capabilities.ts @@ -10,9 +10,8 @@ import type { SandboxSessionLike, } from "./tangle-types.js"; import { - deploymentBacksExactDispatch, + ADAPTER_CEILING_DEPLOYMENT, deploymentBacksRetainedControl, - UNMEASURED_DEPLOYMENT, } from "./tangle-deployment-capabilities.js"; import type { DeploymentCapabilitySupport } from "./tangle-deployment-capabilities.js"; @@ -157,15 +156,13 @@ function linkedSdkProbeInstance( } /** - * Establish client-stage facts before any sandbox exists. Two sources: - * the client's own members (get, describePlacement) and, for an SDK-backed - * client, the linked SDK surface via `linkedSdkProbeInstance`. No deployment - * is reachable at this stage, so these facts are the adapter's upper bound. - * Retained control still fails closed: without a probe handle nothing proves - * `cancelRun`, so the provider must not claim it. Box-scoped workspace and - * streaming facts stay at the declared upper bound when no handle can be - * minted — each concrete sandbox re-narrows them in `capabilitiesForSandbox`, - * where the deployment's own document decides retained control. + * Establish client-stage facts before any sandbox exists. Two sources: the + * client's own members (get, describePlacement) and, for an SDK-backed client, + * the linked SDK surface via `linkedSdkProbeInstance`. These facts bound what + * the adapter can execute; the deployment that decides whether an execution is + * honored is unreachable at this stage. Box-scoped workspace facts stay at the + * declared upper bound when no handle can be minted, and each concrete sandbox + * re-measures them in `capabilitiesForSandbox`. */ export function clientCapabilitySupport( client: SandboxClientLike, @@ -191,9 +188,10 @@ export function clientCapabilitySupport( * Two independent fact sets must agree. The adapter surface must be able to * execute it: exact dispatch, a session handle, canonical cancellation, and * environment reconstruction by id. The connected deployment must honor it: - * a deployment that reports no exact run reference or no canonical - * cancellation refuses the claim even when every local method exists, - * because a method this process can call is not a run the service retains. + * exact dispatch, canonical cancellation, event replay, and execution-scoped + * status together. A deployment that leaves any of the four unreported refuses + * the claim even when every local method exists, because a method this process + * can call is not a run the service retains. */ export function tangleRetainedControlSupported( declared: AgentEnvironmentCapabilities, @@ -217,10 +215,14 @@ export function tangleRetainedControlSupported( * Narrow a declared capability document to established facts. * * Braid derives product actions from these flags, so an over-claimed flag is - * an offered action that throws at the moment the user selects it. Detached - * dispatch carries the caller's exact `runControlRef` and refuses a receipt - * that does not echo it, so `streaming.detach` needs the deployment to accept - * that reference — one flag, narrower than the complete retained-control set. + * an offered action that throws at the moment the user selects it. Each flag + * takes the narrowest fact set it rests on. Detached dispatch carries the + * caller's exact `runControlRef` and refuses a receipt that does not echo the + * execution back, and it is only reachable through a session handle, so + * `streaming.detach` needs exact dispatch from the deployment plus both local + * methods. Cursor replay needs the deployment's own event replay, and turn + * idempotency needs the deployment to honor the exact reference that + * identifies a repeated turn. */ export function narrowedTangleCapabilities( declared: AgentEnvironmentCapabilities, @@ -233,7 +235,7 @@ export function narrowedTangleCapabilities( deployment, ); const supportsDetach = - support.dispatchPrompt && deploymentBacksExactDispatch(deployment); + support.dispatchPrompt && support.session && deployment.exactDispatch; // A cleared fact forces false; a held fact passes the declared value // through unchanged, so a malformed declaration still reaches the schema // at the provider boundary instead of being laundered into a boolean. @@ -242,8 +244,11 @@ export function narrowedTangleCapabilities( streaming: { ...declared.streaming, detach: supportsDetach ? declared.streaming.detach : false, - replay: support.session ? declared.streaming.replay : false, - turnIdempotency: support.session + replay: + support.session && deployment.eventReplay + ? declared.streaming.replay + : false, + turnIdempotency: deployment.exactDispatch ? declared.streaming.turnIdempotency : false, }, @@ -281,11 +286,15 @@ export function narrowedTangleCapabilities( /** * Narrow provider-level claims to facts the client can prove before any - * sandbox exists. `clientCapabilitySupport` documents which facts stay at - * the declared upper bound when the client offers no probe surface. The - * deployment is `unmeasured` here by construction: this stage holds no - * sandbox, so there is nothing to ask. Each concrete sandbox re-narrows - * against its own deployment, and can only come back equal or narrower. + * sandbox exists. + * + * This document answers "what can this provider do against a deployment that + * backs it", which is the question a caller selects a provider on. No sandbox + * exists here, so the deployment input is the adapter's ceiling and this + * document is a bound, never a statement about one environment. Each concrete + * sandbox reads its own deployment in `capabilitiesForSandbox` and publishes + * the answer as `AgentEnvironment.capabilities`, which is the document a + * caller reads to decide which operation to offer against that environment. */ export function capabilitiesForClient( declared: AgentEnvironmentCapabilities, @@ -294,10 +303,23 @@ export function capabilitiesForClient( return narrowedTangleCapabilities( declared, clientCapabilitySupport(client), - UNMEASURED_DEPLOYMENT, + ADAPTER_CEILING_DEPLOYMENT, ); } +/** + * Freeze a capability document before an environment publishes it. + * + * The document and the operations an environment exposes are decided together + * and must stay equal, so the copy a caller holds cannot be writable: a + * mutated flag would describe a surface this environment does not have. + */ +export function frozenCapabilityDocument(document: T): T { + if (document === null || typeof document !== "object") return document; + for (const value of Object.values(document)) frozenCapabilityDocument(value); + return Object.freeze(document); +} + /** * Narrow a declared capability document to what this Sandbox instance backs * and what the deployment behind it reports. diff --git a/packages/agent-provider-tangle/src/tangle-deployment-capabilities.ts b/packages/agent-provider-tangle/src/tangle-deployment-capabilities.ts index 2e4454c..bd74ce8 100644 --- a/packages/agent-provider-tangle/src/tangle-deployment-capabilities.ts +++ b/packages/agent-provider-tangle/src/tangle-deployment-capabilities.ts @@ -6,97 +6,141 @@ import { statusFromUnknown } from "./tangle-environment-values.js"; import { awaitWithSignal } from "./tangle-contract-safety.js"; /** - * What the connected deployment reports about the operations this adapter - * builds on top of a run. + * What the connected deployment reports about the run operations this adapter + * builds on top of a sandbox. * - * `measured: false` belongs to the client stage alone: no sandbox exists - * there, so no deployment can answer, and the adapter's own method surface - * stands as the upper bound. Every stage that holds a concrete sandbox - * reports `measured: true` with explicit flags, and an unreadable document - * lands there as false — claim nothing rather than assume. + * Each fact is the conjunction of every document flag its operation needs, so + * a document that reports part of an operation reports none of it. A flag the + * document leaves unset is unknown, and unknown is never a claim: an absent, + * unreadable, or partial document leaves every fact false. */ -export type DeploymentCapabilitySupport = - | { readonly measured: false } - | { - readonly measured: true; - /** Run requests carry the caller's exact `runControlRef`. */ - readonly exactRunControlRef: boolean; - /** Cancellation is canonical, digest-bound, and idempotent. */ - readonly canonicalCancellation: boolean; - }; +export interface DeploymentCapabilitySupport { + /** + * Run requests carry the caller's exact `runControlRef`, and admission + * echoes the executionId. Detached dispatch needs both: it sends the + * reference and refuses a receipt that does not name the execution back. + */ + readonly exactDispatch: boolean; + /** Cancellation is canonical, digest-bound, and idempotent under replay. */ + readonly canonicalCancellation: boolean; + /** Buffered run events replay by execution under stable event ids. */ + readonly eventReplay: boolean; + /** Status and results select one execution, not the session's latest. */ + readonly executionScopedStatus: boolean; +} -/** The client stage, where no sandbox exists to interrogate. */ -export const UNMEASURED_DEPLOYMENT: DeploymentCapabilitySupport = { - measured: false, +/** + * The deployment backs nothing. + * + * This is the client stage, where no sandbox exists to ask, and it is also + * every answer that fails to establish a fact: no capability method, a sandbox + * that cannot answer, a `null` document, a failed request, and a document that + * leaves a required flag unset. + */ +export const UNPROVEN_DEPLOYMENT: DeploymentCapabilitySupport = { + exactDispatch: false, + canonicalCancellation: false, + eventReplay: false, + executionScopedStatus: false, }; /** - * Read the deployment facts out of a capability document. + * The client stage's deployment input: this adapter's ceiling, not a fact. + * + * No sandbox exists before create, so no deployment can be asked, and the + * provider document answers a different question from the environment's — it + * states what this adapter offers against a deployment that backs it, which is + * what a caller selects a provider on. `AgentEnvironment.capabilities` carries + * the measured answer for one sandbox, and every operation an environment + * exposes follows that document, never this ceiling. * - * A missing flag is unknown, and unknown is never a claim, so every flag must - * be present and true to count. Canonical cancellation needs all three of its - * flags together: a cancellation that is not bound to the run's request - * digest, or not idempotent under replay, cannot carry retained control. + * The ceiling stays wide deliberately. A provider document that claimed + * nothing before create would refuse retained runs against every deployment, + * including the ones that back them, because a caller must read the provider + * document to decide whether to start one at all. + */ +export const ADAPTER_CEILING_DEPLOYMENT: DeploymentCapabilitySupport = { + exactDispatch: true, + canonicalCancellation: true, + eventReplay: true, + executionScopedStatus: true, +}; + +/** + * Read the deployment facts out of a capability document. Every flag this + * adapter acts on is read here; the shape carries no flag it does not act on. */ export function deploymentCapabilitySupport( document: SandboxRuntimeCapabilityDocument | null | undefined, ): DeploymentCapabilitySupport { - if (!document || typeof document !== "object") { - return { measured: true, exactRunControlRef: false, canonicalCancellation: false }; - } + if (!document || typeof document !== "object") return UNPROVEN_DEPLOYMENT; return { - measured: true, - exactRunControlRef: document.dispatch?.runControlRef === true, + exactDispatch: + document.dispatch?.runControlRef === true && + document.dispatch?.executionIdOnAdmission === true, canonicalCancellation: document.cancel?.canonicalRunCancellation === true && document.cancel?.digestBound === true && document.cancel?.idempotent === true, + eventReplay: document.runs?.eventReplay === true, + executionScopedStatus: document.runs?.executionScopedStatus === true, }; } /** * Establish the deployment facts for one sandbox through capability - * discovery. + * discovery. Every outcome but the caller's own abort resolves to a fact set. * - * Three inputs resolve to "nothing claimed" without a request: a Sandbox SDK - * older than 0.22.0, which carries no `capabilities` method; a sandbox that - * is not running, whose capability route can only answer with a state error; - * and a `null` document, which is a deployment predating capability discovery - * or one serving a schema this SDK cannot read. + * Three inputs answer without a request: a Sandbox SDK older than 0.22.0, + * which carries no `capabilities` method; a sandbox that is not running, whose + * capability route can only answer with a state error; and a `null` document, + * which is a deployment predating capability discovery or one serving a schema + * this SDK cannot read. * - * Every other failure propagates. A malformed document means the deployment - * is defective, and swallowing that would hand back a silently degraded - * environment instead of the defect. + * A failed request resolves the same way. Discovery runs against a sandbox + * that a cold provision has just paid for, and a transport failure or a + * defective document is not evidence about the run operations: failing here + * would trade an unknown for the certain loss of that sandbox. The failure + * reaches the warning channel, and the environment then offers no operation + * the document did not prove. */ export async function readDeploymentCapabilitySupport( box: SandboxInstanceLike, options?: { signal?: AbortSignal }, ): Promise { - if (typeof box.capabilities !== "function") { - return deploymentCapabilitySupport(null); - } - if (statusFromUnknown(box.status) !== "running") { - return deploymentCapabilitySupport(null); - } + if (typeof box.capabilities !== "function") return UNPROVEN_DEPLOYMENT; + if (statusFromUnknown(box.status) !== "running") return UNPROVEN_DEPLOYMENT; options?.signal?.throwIfAborted(); - const document = await awaitWithSignal(box.capabilities(), options?.signal); + let document: SandboxRuntimeCapabilityDocument | null | undefined; + try { + document = await awaitWithSignal(box.capabilities(), options?.signal); + } catch (error) { + options?.signal?.throwIfAborted(); + console.warn( + `Tangle capability discovery failed for sandbox ${box.id}: the deployment backs nothing`, + error, + ); + return UNPROVEN_DEPLOYMENT; + } options?.signal?.throwIfAborted(); return deploymentCapabilitySupport(document); } -/** Whether the deployment backs a detached run carrying an exact reference. */ -export function deploymentBacksExactDispatch( - deployment: DeploymentCapabilitySupport, -): boolean { - return !deployment.measured || deployment.exactRunControlRef; -} - -/** Whether the deployment backs the complete retained-control identity set. */ +/** + * Whether the deployment backs the complete retained-control identity set. + * The capability schema refuses a partial retained-control block, and each + * identity rests on its own deployment fact, so they stand together: exact + * dispatch for run identity, execution-scoped status for result identity, + * event replay for event identity, and canonical cancellation for + * cancellation idempotency. + */ export function deploymentBacksRetainedControl( deployment: DeploymentCapabilitySupport, ): boolean { return ( - !deployment.measured || - (deployment.exactRunControlRef && deployment.canonicalCancellation) + deployment.exactDispatch && + deployment.canonicalCancellation && + deployment.eventReplay && + deployment.executionScopedStatus ); } diff --git a/packages/agent-provider-tangle/src/tangle-environment.ts b/packages/agent-provider-tangle/src/tangle-environment.ts index ce7ba8b..b306b44 100644 --- a/packages/agent-provider-tangle/src/tangle-environment.ts +++ b/packages/agent-provider-tangle/src/tangle-environment.ts @@ -1,4 +1,5 @@ import { AgentTurnInputSchema } from "@tangle-network/agent-interface"; +import { AgentEnvironmentCapabilitiesSchema } from "@tangle-network/agent-interface/environment-provider"; import type { AgentExactRunControlRef, AgentRunControlRef, @@ -32,8 +33,8 @@ import { import { execResultFromSandboxExecResult } from "./tangle-result-values.js"; import { capabilitiesForSandbox, + frozenCapabilityDocument, sandboxCapabilitySupport, - tangleRetainedControlSupported, } from "./tangle-capabilities.js"; import { readDeploymentCapabilitySupport } from "./tangle-deployment-capabilities.js"; import { @@ -59,7 +60,15 @@ import { sandboxSessionAsAgentSession } from "./tangle-environment-session.js"; * `GET /capabilities` against the sandbox decides retained control, and the * environment then exposes exactly the operations both the adapter surface * and the deployment back. A deployment that cannot disclose a readable - * document yields no retained-control surface at all. + * document yields no retained-control surface at all. The environment + * publishes the resulting document on `capabilities`, so a caller reads the + * answer for this sandbox rather than the provider's pre-sandbox claim. + * + * The document is measured once, here. A sandbox that is not yet running + * cannot answer, so an environment composed during provisioning claims + * nothing and keeps claiming nothing: the exposed operations and the document + * are composed together and a caller may already hold either one. Compose the + * environment again through `provider.get(id)` once the sandbox is running. */ export async function sandboxInstanceAsEnvironment( box: SandboxInstanceLike, @@ -78,16 +87,14 @@ export async function sandboxInstanceAsEnvironment( } const support = sandboxCapabilitySupport(box, client); const deployment = await readDeploymentCapabilitySupport(box, operation); - const capabilities = capabilitiesForSandbox( - declaredCapabilities, - support, - deployment, - ); - const retainedControl = tangleRetainedControlSupported( - declaredCapabilities, - support, - deployment, + const capabilities = frozenCapabilityDocument( + AgentEnvironmentCapabilitiesSchema.parse( + capabilitiesForSandbox(declaredCapabilities, support, deployment), + ), ); + // The published document is the single source for what this environment + // offers, so the session surface reads its grant from there. + const retainedControl = capabilities.retainedControl !== undefined; const dispatch = capabilities.streaming.detach && box.dispatchPrompt ? dispatchEnvironmentRun(box, providerName, environmentId) @@ -112,6 +119,7 @@ export async function sandboxInstanceAsEnvironment( id: environmentId, provider: providerName, ...(box.name ? { name: boundedString(box.name, "Tangle environment name") } : {}), + capabilities, async status(options?: { signal?: AbortSignal }): Promise { assertOptionKeys(options, ["signal"], "Tangle environment status"); await awaitWithSignal(box.refresh?.(options), options?.signal); diff --git a/packages/agent-provider-tangle/src/tangle-types.ts b/packages/agent-provider-tangle/src/tangle-types.ts index 080e764..2ef2509 100644 --- a/packages/agent-provider-tangle/src/tangle-types.ts +++ b/packages/agent-provider-tangle/src/tangle-types.ts @@ -53,10 +53,14 @@ export interface SandboxClientLike { * The `GET /capabilities` document as this adapter reads it: what the DEPLOYED * sidecar image compiled in, not which methods the linked SDK class carries. * - * Every field is optional here. The document's own convention is that a - * missing flag means "unknown to that image", never false, so an absent field - * must reach the caller as unknown instead of being coerced. The SDK's - * `SandboxRuntimeCapabilities` is assignable to this shape; + * Every capability flag this shape declares gates a claim the adapter makes, + * and the wire document's other flags are absent here because the adapter does + * not act on them yet. Every field is optional, and the document's own + * convention is that a missing flag means "unknown to that image", never + * false. The linked SDK parses a v1 wire body strictly, but this adapter reads + * any `SandboxInstanceLike`, so it never assumes a flag was validated: an + * absent field reaches the claim as unknown instead of being coerced. The + * SDK's `SandboxRuntimeCapabilities` is assignable to this shape; * `deployment-capabilities.test.ts` pins that against the published type. */ export interface SandboxRuntimeCapabilityDocument { @@ -67,20 +71,23 @@ export interface SandboxRuntimeCapabilityDocument { dispatch?: { /** Run requests accept a caller-supplied exact `runControlRef`. */ runControlRef?: boolean; + /** Admission echoes the executionId the request named. */ executionIdOnAdmission?: boolean; }; cancel?: { + /** Cancellation accepts the canonical digest-bound request. */ canonicalRunCancellation?: boolean; + /** Cancellation binds to the run's request digest. */ digestBound?: boolean; + /** Replaying an operation id returns the stored acknowledgement. */ idempotent?: boolean; }; runs?: { + /** Status and results select one execution of a session. */ executionScopedStatus?: boolean; + /** Buffered run events replay by execution. */ eventReplay?: boolean; }; - interactions?: { - responseDedupe?: boolean; - }; } export interface SandboxProcessStatusLike { diff --git a/packages/agent-provider-testkit/src/conformance-helpers.ts b/packages/agent-provider-testkit/src/conformance-helpers.ts index 8f9b3b3..7c33c5d 100644 --- a/packages/agent-provider-testkit/src/conformance-helpers.ts +++ b/packages/agent-provider-testkit/src/conformance-helpers.ts @@ -1,4 +1,5 @@ import { isDeepStrictEqual } from "node:util"; +import { AgentEnvironmentCapabilitiesSchema } from "@tangle-network/agent-interface/environment-provider"; import type { AgentEnvironment, AgentEnvironmentCapabilities, @@ -6,6 +7,23 @@ import type { } from "@tangle-network/agent-interface/environment-provider"; import { ProviderConformanceError } from "./conformance-types.js"; +/** + * The capability document that describes one environment. + * + * A capability the connected deployment decides is environment-scoped, so the + * provider document cannot state it: one provider reaches deployments of + * different ages. An environment that publishes its own document answers for + * itself, and every exposure check binds to that answer. An environment that + * publishes none is fully described by the provider document. + */ +export function environmentCapabilityDocument( + environment: AgentEnvironment, + providerCapabilities: AgentEnvironmentCapabilities, +): AgentEnvironmentCapabilities { + if (environment.capabilities === undefined) return providerCapabilities; + return AgentEnvironmentCapabilitiesSchema.parse(environment.capabilities); +} + export async function checkWorkspace( environment: AgentEnvironment, capabilities: AgentEnvironmentCapabilities, diff --git a/packages/agent-provider-testkit/src/conformance-types.ts b/packages/agent-provider-testkit/src/conformance-types.ts index d0a4cef..baf3ec3 100644 --- a/packages/agent-provider-testkit/src/conformance-types.ts +++ b/packages/agent-provider-testkit/src/conformance-types.ts @@ -43,7 +43,13 @@ export interface ProviderConformanceOptions { export interface ProviderConformanceReport { provider: string; environmentId: string; + /** The provider document, read before any environment exists. */ capabilities: AgentEnvironmentCapabilities; + /** + * The document every exposure check ran against: the created environment's + * own document when it publishes one, and the provider document otherwise. + */ + environmentCapabilities: AgentEnvironmentCapabilities; events: number; checked: string[]; } diff --git a/packages/agent-provider-testkit/src/provider-conformance.ts b/packages/agent-provider-testkit/src/provider-conformance.ts index d4095f3..33c4f94 100644 --- a/packages/agent-provider-testkit/src/provider-conformance.ts +++ b/packages/agent-provider-testkit/src/provider-conformance.ts @@ -1,6 +1,6 @@ import { AgentEnvironmentCapabilitiesSchema } from "@tangle-network/agent-interface/environment-provider"; import type { ProviderConformanceOptions, ProviderConformanceReport } from "./conformance-types.js"; -import { assert, checkCapabilityExposure, checkWorkspace, collect, isTerminalEvent, withEnvironmentCleanup } from "./conformance-helpers.js"; +import { assert, checkCapabilityExposure, checkWorkspace, collect, environmentCapabilityDocument, isTerminalEvent, withEnvironmentCleanup } from "./conformance-helpers.js"; export async function runAgentEnvironmentProviderConformance( options: ProviderConformanceOptions, @@ -28,8 +28,15 @@ export async function runAgentEnvironmentProviderConformance( 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); - checkCapabilityExposure(environment, capabilities, checked); - if (capabilities.interactions) { + // Every check below is about this environment, so it binds to the document + // that describes this environment. + const environmentCapabilities = environmentCapabilityDocument( + environment, + capabilities, + ); + checked.push("environment-capabilities"); + checkCapabilityExposure(environment, environmentCapabilities, checked); + if (environmentCapabilities.interactions) { assert( typeof environment.respondToInteraction === "function", "interaction capability requires respondToInteraction()", @@ -37,19 +44,20 @@ export async function runAgentEnvironmentProviderConformance( ); } if ( - capabilities.branching.retrySafe || - capabilities.branching.lookup || - capabilities.branching.cleanup + environmentCapabilities.branching.retrySafe || + environmentCapabilities.branching.lookup || + environmentCapabilities.branching.cleanup ) { assert( - capabilities.branching.checkpoint && capabilities.branching.fork, + environmentCapabilities.branching.checkpoint && + environmentCapabilities.branching.fork, "durable branching requires checkpoint and fork capabilities", checked, ); assert( - capabilities.branching.retrySafe && - capabilities.branching.lookup && - capabilities.branching.cleanup, + environmentCapabilities.branching.retrySafe && + environmentCapabilities.branching.lookup && + environmentCapabilities.branching.cleanup, "durable branching idempotency, lookup, and cleanup are all-or-nothing", checked, ); @@ -89,7 +97,7 @@ export async function runAgentEnvironmentProviderConformance( "stream must emit a terminal result/done/status event", checked, ); - if (options.requireUsage || capabilities.usage) { + if (options.requireUsage || environmentCapabilities.usage) { assert( events.some((event) => Boolean(event.usage)), "provider declared usage support but emitted no usage", @@ -98,7 +106,7 @@ export async function runAgentEnvironmentProviderConformance( } checked.push("stream"); - if (capabilities.nativeContinuation !== undefined) { + if (environmentCapabilities.nativeContinuation !== undefined) { assert( typeof environment.session === "function", "native continuation requires session()", @@ -118,7 +126,7 @@ export async function runAgentEnvironmentProviderConformance( checked.push("native-continuation-operations"); } - if (options.requireDispatch || capabilities.streaming.detach) { + if (options.requireDispatch || environmentCapabilities.streaming.detach) { assert( typeof environment.dispatch === "function", "detach support requires dispatch()", @@ -132,13 +140,14 @@ export async function runAgentEnvironmentProviderConformance( checked.push("dispatch"); } - await checkWorkspace(environment, capabilities, checked); + await checkWorkspace(environment, environmentCapabilities, checked); checked.push("capability-denial"); return { provider: provider.name, environmentId: environment.id, capabilities, + environmentCapabilities, events: events.length, checked, }; diff --git a/packages/agent-provider-testkit/src/session-replay-conformance.ts b/packages/agent-provider-testkit/src/session-replay-conformance.ts index 5f3addf..e6ab4de 100644 --- a/packages/agent-provider-testkit/src/session-replay-conformance.ts +++ b/packages/agent-provider-testkit/src/session-replay-conformance.ts @@ -1,7 +1,7 @@ import { AgentEnvironmentCapabilitiesSchema } from "@tangle-network/agent-interface/environment-provider"; import { AgentRunControlRefSchema } from "@tangle-network/agent-interface"; import type { SessionReplayConformanceOptions, SessionReplayConformanceReport } from "./conformance-types.js"; -import { assert, collect, deepEqual, isTerminalEvent, withEnvironmentCleanup } from "./conformance-helpers.js"; +import { assert, collect, deepEqual, environmentCapabilityDocument, isTerminalEvent, withEnvironmentCleanup } from "./conformance-helpers.js"; export async function runSessionReplayConformance( options: SessionReplayConformanceOptions, @@ -11,14 +11,28 @@ export async function runSessionReplayConformance( const capabilities = AgentEnvironmentCapabilitiesSchema.parse( await provider.capabilities(), ); - assert(capabilities.streaming.detach, "provider must declare detach", checked); - assert(capabilities.streaming.replay, "provider must declare replay", checked); const environment = await provider.create({ profile: { name: `${options.name}-profile` }, name: `${options.name}-environment`, ...(options.createInput ?? {}), }); return withEnvironmentCleanup(environment, checked, async () => { + // Detach and replay can rest on the connected deployment, so the answer + // belongs to this environment. It is read after create for that reason. + const environmentCapabilities = environmentCapabilityDocument( + environment, + capabilities, + ); + assert( + environmentCapabilities.streaming.detach, + "provider must declare detach", + checked, + ); + assert( + environmentCapabilities.streaming.replay, + "provider must declare replay", + checked, + ); assert( typeof environment.dispatch === "function", "detach requires dispatch()", diff --git a/scripts/fixtures/tangle-control-consumer.test.ts b/scripts/fixtures/tangle-control-consumer.test.ts index 34bdf83..48dda55 100644 --- a/scripts/fixtures/tangle-control-consumer.test.ts +++ b/scripts/fixtures/tangle-control-consumer.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import { SandboxInstance, type SandboxEvent, + type SandboxRuntimeCapabilities, type TangleSandboxClient, } from "@tangle-network/sandbox"; import { @@ -21,8 +22,13 @@ function acceptPublicTangleClient(client: TangleSandboxClient): SandboxClientLik void acceptPublicTangleClient; -/** A deployment that reports the complete retained-control flag set. */ -const DEPLOYMENT_CAPABILITIES: SandboxRuntimeCapabilityDocument = { +/** + * The wire body of `GET /capabilities` for a deployment that reports the + * complete retained-control flag set. It carries the SDK's type because the + * SDK parses it: a v1 document that omits a declared group is malformed, so + * this body must stay complete even where the adapter reads only part of it. + */ +const DEPLOYMENT_CAPABILITIES: SandboxRuntimeCapabilities = { schema: 1, agentInterface: "0.49.0", sidecarVersion: "1.0.0-packed", @@ -32,6 +38,8 @@ const DEPLOYMENT_CAPABILITIES: SandboxRuntimeCapabilityDocument = { runs: { executionScopedStatus: true, eventReplay: true }, interactions: {}, }; +const DEPLOYMENT_CAPABILITIES_AS_READ: SandboxRuntimeCapabilityDocument = + DEPLOYMENT_CAPABILITIES; function jsonResponse(body: unknown): Response { return new Response(JSON.stringify(body), { @@ -95,6 +103,18 @@ describe("packed Tangle exact-session control", () => { expect(capabilities.branching).toEqual({ checkpoint: false, fork: false }); expect(environment.checkpoint).toBeUndefined(); expect(environment.fork).toBeUndefined(); + + // The environment-scoped document reaches a packed consumer, and the + // operations it exposes match it. This deployment backs exact dispatch + // and event replay, while the client offers no `get`, so the run cannot + // be reconstructed and retained control stays unclaimed. + expect(environment.capabilities).toMatchObject({ + streaming: { detach: true, replay: true, turnIdempotency: true }, + sessions: { continue: false }, + }); + expect(environment.capabilities).not.toHaveProperty("retainedControl"); + expect(typeof environment.dispatch).toBe("function"); + expect(typeof environment.session).toBe("function"); }); it("claims retained control for an SDK-backed client with reconstruction", async () => { @@ -181,7 +201,7 @@ describe("packed Tangle exact-session control", () => { const box: SandboxInstanceLike = { id: "sandbox-1", status: "running", - capabilities: async () => DEPLOYMENT_CAPABILITIES, + capabilities: async () => DEPLOYMENT_CAPABILITIES_AS_READ, async *streamPrompt(_message, options) { eventSelector(options); const events = [ @@ -271,7 +291,7 @@ describe("packed Tangle exact-session control", () => { const box: SandboxInstanceLike = { id: "sandbox-unproven", status: "running", - capabilities: async () => DEPLOYMENT_CAPABILITIES, + capabilities: async () => DEPLOYMENT_CAPABILITIES_AS_READ, async *streamPrompt() {}, dispatchPrompt: async (_prompt, options) => ({ sessionId: options?.sessionId ?? session.id, From b3106e14e9eb7012d24b2a9ee9341fa08e1c6b1d Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 13 Aug 2026 15:18:53 -0600 Subject: [PATCH 3/3] test(provider-tangle): bind the two capability documents and pin the aborted read The two capability stages had no asserted relation. Every exposure check binds to the document an environment publishes, which leaves the provider document unchecked against it: an environment could claim what the client stage does not carry, or drop a claim the client stage advertises, and no test failed. Two mutations proved the gap, each of them green on the whole suite. The client-stage and sandbox-stage documents are now paired over both answers a deployment can give to an SDK-backed client. The sandbox stage claims nothing beyond the client stage, the two agree on every claim the deployment does not decide, and the exposed operations follow the sandbox-stage document. Equality across the stages stays unasserted: the client stage runs before any sandbox exists, so a deployment that discloses nothing would drag the provider document down and refuse retained runs against every deployment. A read that fails while the caller's abort lands now propagates the abort at the capability boundary and reaches no warning channel. The provider re-checks the signal after composition, so that boundary was unpinned. The README flag table stated part of what four flags gate. Every flag in it gates sessions.continue, retainedControl, and session.cancelRun. The README and the changeset also counted a document that leaves a flag unset among the inputs that claim nothing; such a document drops the claims that flag gates and keeps the rest. --- .changeset/lucky-pans-clap.md | 5 +- packages/agent-provider-tangle/README.md | 22 +- .../src/deployment-capabilities.test.ts | 190 ++++++++++++++++-- 3 files changed, 193 insertions(+), 24 deletions(-) diff --git a/.changeset/lucky-pans-clap.md b/.changeset/lucky-pans-clap.md index 8de4b14..4453559 100644 --- a/.changeset/lucky-pans-clap.md +++ b/.changeset/lucky-pans-clap.md @@ -8,9 +8,10 @@ Composing an environment now calls `box.capabilities()` once and takes every dep The narrowed document is published as `environment.capabilities`, which is the document to read before offering an operation: the operations an environment exposes match it exactly, while `provider.capabilities()` states the adapter's ceiling before any sandbox exists. Every flag the capability document carries now gates the claims it backs. -`streaming.detach` and `streaming.turnIdempotency` need `dispatch.runControlRef` with `dispatch.executionIdOnAdmission`; `streaming.replay` needs `runs.eventReplay`; `retainedControl` needs those plus `cancel.canonicalRunCancellation`, `cancel.digestBound`, `cancel.idempotent`, and `runs.executionScopedStatus`. +`streaming.detach` and `streaming.turnIdempotency` need `dispatch.runControlRef` with `dispatch.executionIdOnAdmission`; `streaming.replay` needs `runs.eventReplay`; `sessions.continue`, `retainedControl`, and `session.cancelRun` need those plus `cancel.canonicalRunCancellation`, `cancel.digestBound`, `cancel.idempotent`, and `runs.executionScopedStatus`. Detached dispatch also needs a session handle, because a detached run is reachable only through one. -Five inputs claim nothing: an SDK older than 0.22.0, a sandbox that is not running, a `null` document, a document that leaves a required flag unset, and a capability read that fails. +Four inputs claim nothing: an SDK older than 0.22.0, a sandbox that is not running, a `null` document, and a capability read that fails. Such environments omit `dispatch` and `session`. +A document that leaves a flag unset drops the claims that flag gates and keeps the rest, so it can still carry `streaming.detach` and `streaming.replay`. A failed read no longer fails `create()` and no longer deletes the sandbox a cold provision has already paid for; it claims nothing and reports the failure on the warning channel. diff --git a/packages/agent-provider-tangle/README.md b/packages/agent-provider-tangle/README.md index cb23116..c24e69d 100644 --- a/packages/agent-provider-tangle/README.md +++ b/packages/agent-provider-tangle/README.md @@ -38,20 +38,24 @@ Each deployment flag this adapter reads gates the claims it backs, and no flag i | Deployment flag | Claims it gates | | --- | --- | -| `dispatch.runControlRef` | `streaming.detach`, `streaming.turnIdempotency`, `retainedControl` | -| `dispatch.executionIdOnAdmission` | `streaming.detach`, `streaming.turnIdempotency`, `retainedControl` | -| `cancel.canonicalRunCancellation` | `retainedControl`, `sessions.continue`, `session.cancelRun` | -| `cancel.digestBound` | `retainedControl`, `sessions.continue`, `session.cancelRun` | -| `cancel.idempotent` | `retainedControl`, `sessions.continue`, `session.cancelRun` | -| `runs.eventReplay` | `streaming.replay`, `retainedControl` | -| `runs.executionScopedStatus` | `retainedControl`, `sessions.continue` | +| `dispatch.runControlRef` | `streaming.detach`, `streaming.turnIdempotency`, `sessions.continue`, `retainedControl`, `session.cancelRun` | +| `dispatch.executionIdOnAdmission` | `streaming.detach`, `streaming.turnIdempotency`, `sessions.continue`, `retainedControl`, `session.cancelRun` | +| `cancel.canonicalRunCancellation` | `sessions.continue`, `retainedControl`, `session.cancelRun` | +| `cancel.digestBound` | `sessions.continue`, `retainedControl`, `session.cancelRun` | +| `cancel.idempotent` | `sessions.continue`, `retainedControl`, `session.cancelRun` | +| `runs.eventReplay` | `streaming.replay`, `sessions.continue`, `retainedControl`, `session.cancelRun` | +| `runs.executionScopedStatus` | `sessions.continue`, `retainedControl`, `session.cancelRun` | Detached dispatch carries the caller's exact reference and refuses a receipt that does not name the execution back, so it needs both `dispatch` flags and a session handle to reach the run through. -`retainedControl` needs every flag in the table, because the capability schema refuses a partial block and each identity rests on its own flag. +`sessions.continue`, `retainedControl`, and `session.cancelRun` need every flag in the table, because the capability schema refuses a partial retained-control block and each identity rests on its own flag. +A claim takes its operation with it: `streaming.detach` gates `dispatch()`, and `session()` stands while any of `streaming.detach`, `streaming.replay`, or `sessions.continue` stands. A missing flag means unknown, and unknown is never a claim. -Five inputs claim nothing at all: a Sandbox SDK older than 0.22.0, a sandbox that is not running, a `null` document (a deployment predating capability discovery, or one serving a newer schema this SDK cannot read), a document that leaves any required flag unset, and a capability read that fails. +Four inputs claim nothing at all: a Sandbox SDK older than 0.22.0, a sandbox that is not running, a `null` document (a deployment predating capability discovery, or one serving a newer schema this SDK cannot read), and a capability read that fails. In each case the environment omits `dispatch` and `session`, so a caller never selects an action the deployment will reject. +A document that leaves a flag unset is not one of them. +It drops the claims that flag gates and keeps every claim its remaining flags back. +A document without `cancel.digestBound` still carries `streaming.detach` and `streaming.replay`, and its environment still exposes `dispatch` and `session`. A failed read claims nothing rather than failing `create()`: discovery runs against a sandbox a cold provision has already paid for, and a transport failure is not evidence about the deployment. The failure is reported on the warning channel. diff --git a/packages/agent-provider-tangle/src/deployment-capabilities.test.ts b/packages/agent-provider-tangle/src/deployment-capabilities.test.ts index e40a2be..3b53dc9 100644 --- a/packages/agent-provider-tangle/src/deployment-capabilities.test.ts +++ b/packages/agent-provider-tangle/src/deployment-capabilities.test.ts @@ -3,6 +3,7 @@ import type { SandboxEvent, SandboxRuntimeCapabilities } from "@tangle-network/s import { runAgentEnvironmentProviderConformance } from "@tangle-network/agent-provider-testkit"; import { agentRunCancellationRequestDigest } from "@tangle-network/agent-interface"; import type { AgentExactRunControlRef } from "@tangle-network/agent-interface"; +import type { AgentEnvironmentCapabilities } from "@tangle-network/agent-interface/environment-provider"; import { createTangleProvider, type SandboxClientLike, @@ -11,6 +12,7 @@ import { } from "./index.js"; import { deploymentCapabilitySupport, + readDeploymentCapabilitySupport, UNPROVEN_DEPLOYMENT, } from "./tangle-deployment-capabilities.js"; import { @@ -126,6 +128,73 @@ function sdkBackedProvider(document: SandboxRuntimeCapabilityDocument | null) { return { provider: createTangleProvider({ client }), box, sessionId }; } +/** + * Every claim a capability document carries, addressed by path. A relation + * between two documents is stated over all of them rather than over a chosen + * few, so a claim the schema gains later joins the comparison on its own. + */ +function capabilityClaims( + document: AgentEnvironmentCapabilities, +): Map { + const claims = new Map(); + const walk = (value: unknown, path: string): void => { + if (typeof value === "boolean") { + claims.set(path, value); + return; + } + if (value === null || typeof value !== "object") return; + for (const [key, nested] of Object.entries(value)) { + walk(nested, path === "" ? key : `${path}.${key}`); + } + }; + walk(document, ""); + return claims; +} + +/** The claims the connected deployment decides, and only those. */ +const DEPLOYMENT_DECIDED_CLAIMS = [ + "streaming.detach", + "streaming.replay", + "streaming.turnIdempotency", + "sessions.continue", + "retainedControl", +] as const; + +function decidedByDeployment(path: string): boolean { + return DEPLOYMENT_DECIDED_CLAIMS.some( + (claim) => path === claim || path.startsWith(`${claim}.`), + ); +} + +function deploymentDecidedClaims(document: AgentEnvironmentCapabilities) { + return { + detach: document.streaming.detach, + replay: document.streaming.replay, + turnIdempotency: document.streaming.turnIdempotency, + continued: document.sessions.continue, + retainedControl: document.retainedControl !== undefined, + }; +} + +function claimsTheDeploymentDoesNotDecide( + document: AgentEnvironmentCapabilities, +): Record { + return Object.fromEntries( + [...capabilityClaims(document)].filter(([path]) => !decidedByDeployment(path)), + ); +} + +/** The paths where one document claims what the other does not back. */ +function claimsBeyond( + document: AgentEnvironmentCapabilities, + ceiling: AgentEnvironmentCapabilities, +): string[] { + const bound = capabilityClaims(ceiling); + return [...capabilityClaims(document)] + .filter(([path, claimed]) => claimed && bound.get(path) !== true) + .map(([path]) => path); +} + describe("Tangle deployment capability discovery", () => { it("claims retained control when the deployment reports the complete flag set", async () => { const capabilities = vi.fn(async () => PUBLISHED_DOCUMENT_AS_READ); @@ -283,6 +352,16 @@ describe("Tangle deployment capability discovery", () => { continued: false, }); expect(claimed).not.toHaveProperty("retainedControl"); + // A partial document keeps every operation its remaining flags back, so + // the exposed operations follow the claims that survived the missing one. + expect(typeof environment.dispatch === "function").toBe( + claimed.streaming.detach, + ); + expect(typeof environment.session === "function").toBe( + claimed.streaming.detach || + claimed.streaming.replay || + claimed.sessions.continue, + ); expect(environment.session!(sessionId).cancelRun).toBeUndefined(); } }); @@ -329,20 +408,105 @@ describe("Tangle deployment capability discovery", () => { expect(deleted).toHaveBeenCalledTimes(1); }); - it("pairs the client-stage document against the sandbox surface it produces", async () => { - // The client-stage document is the only capability document a consumer - // reads, so it must describe the environment this provider builds. An - // SDK-backed client measures the linked SDK's full method surface, which - // leaves the deployment document as the single variable: run the repo's - // conformance suite over both answers a deployment can give. - for (const document of [null, RETAINED_DEPLOYMENT_DOCUMENT]) { - const { provider } = sdkBackedProvider(document); + it("propagates the caller's abort when the aborted read fails", async () => { + // The read fails and the caller's own abort is what failed it. A failed + // read claims nothing, but only about a deployment that was asked: this + // read reported on the caller, so the abort leaves the boundary instead + // of resolving into a fact and instead of reaching the warning channel. + const warned = vi.spyOn(console, "warn").mockImplementation(() => undefined); + try { + const controller = new AbortController(); + const box: SandboxInstanceLike = { + id: "sbx-aborted-read", + status: "running", + async *streamPrompt() {}, + capabilities: () => + new Promise((_resolve, reject) => { + // Both outcomes are live on the same read: the abort lands while + // the request is in flight, and the request then fails. + queueMicrotask(() => { + controller.abort(new Error("caller cancelled the read")); + reject(new Error("capability transport closed")); + }); + }), + }; + await expect( - runAgentEnvironmentProviderConformance({ - name: "tangle-deployment", - createProvider: () => provider, - }), - ).resolves.toMatchObject({ provider: "tangle-sandbox" }); + readDeploymentCapabilitySupport(box, { signal: controller.signal }), + ).rejects.toThrow(/caller cancelled the read/); + expect(warned).not.toHaveBeenCalled(); + } finally { + warned.mockRestore(); + } + }); + + it("pairs the client-stage document against the sandbox surface it produces", async () => { + // The two documents answer different questions, so the relation between + // them is a bound, not an equality: the client stage states this adapter's + // ceiling against a deployment that backs everything, and the sandbox + // stage states what one deployment reported. Three facts hold together. + // The sandbox stage never claims what the ceiling does not carry, or a + // caller who selected this provider on the ceiling would meet an operation + // the provider document never offered. The two stages agree on every claim + // the deployment does not decide, so a difference between them names a + // deployment fact and nothing else. The exposed operations follow the + // sandbox-stage document exactly, which is what the conformance suite + // checks against the document an environment publishes. + // + // Equality across both stages is the wrong assertion. The client stage + // runs before any sandbox exists, so a deployment that discloses nothing + // would drag the provider document down and refuse retained runs against + // every deployment, including the ones that back them. + for (const testCase of [ + { deployment: "undisclosed", document: null, backed: false }, + { + deployment: "retained", + document: RETAINED_DEPLOYMENT_DOCUMENT, + backed: true, + }, + ] as const) { + const { provider } = sdkBackedProvider(testCase.document); + const report = await runAgentEnvironmentProviderConformance({ + name: `tangle-${testCase.deployment}-deployment`, + createProvider: () => provider, + }); + const clientStage = report.capabilities; + const sandboxStage = report.environmentCapabilities; + + expect(report.provider).toBe("tangle-sandbox"); + expect(deploymentDecidedClaims(clientStage)).toEqual({ + detach: true, + replay: true, + turnIdempotency: true, + continued: true, + retainedControl: true, + }); + expect({ + deployment: testCase.deployment, + ...deploymentDecidedClaims(sandboxStage), + }).toEqual({ + deployment: testCase.deployment, + detach: testCase.backed, + replay: testCase.backed, + turnIdempotency: testCase.backed, + continued: testCase.backed, + retainedControl: testCase.backed, + }); + expect(claimsBeyond(sandboxStage, clientStage)).toEqual([]); + expect(claimsTheDeploymentDoesNotDecide(sandboxStage)).toEqual( + claimsTheDeploymentDoesNotDecide(clientStage), + ); + + const environment = await provider.create({ profile: { name: "worker" } }); + expect(environment.capabilities).toEqual(sandboxStage); + expect(typeof environment.dispatch === "function").toBe( + sandboxStage.streaming.detach, + ); + expect(typeof environment.session === "function").toBe( + sandboxStage.streaming.detach || + sandboxStage.streaming.replay || + sandboxStage.sessions.continue, + ); } });