diff --git a/.changeset/lucky-pans-clap.md b/.changeset/lucky-pans-clap.md new file mode 100644 index 0000000..4453559 --- /dev/null +++ b/.changeset/lucky-pans-clap.md @@ -0,0 +1,17 @@ +--- +"@tangle-network/agent-provider-tangle": minor +--- + +Derive retained control from deployment capability discovery, and publish the result on the environment. + +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`; `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. + +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/.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 a998c61..c24e69d 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,49 @@ 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. +## Two capability documents + +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. -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. -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. +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: + +| Deployment flag | Claims it gates | +| --- | --- | +| `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. +`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. + +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. + +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/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..3b53dc9 --- /dev/null +++ b/packages/agent-provider-tangle/src/deployment-capabilities.test.ts @@ -0,0 +1,542 @@ +import { describe, expect, it, vi } from "vitest"; +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 { AgentEnvironmentCapabilities } from "@tangle-network/agent-interface/environment-provider"; +import { + createTangleProvider, + type SandboxClientLike, + type SandboxInstanceLike, + type SandboxRuntimeCapabilityDocument, +} from "./index.js"; +import { + deploymentCapabilitySupport, + readDeploymentCapabilitySupport, + 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 + * 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; + +/** + * 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: retainedSessionHandle, + 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 }; +} + +/** + * 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 }; +} + +/** + * 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); + 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, 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).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 } = deployedProvider({}); + const environment = await provider.create({ profile: { name: "worker" } }); + + expect(environment.dispatch).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 } = deployedProvider({ + capabilities, + status: "stopped", + }); + const environment = await provider.get!("sbx-deployment"); + + expect(capabilities).not.toHaveBeenCalled(); + expect(environment!.dispatch).toBeUndefined(); + expect(environment!.session).toBeUndefined(); + }); + + 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; + + 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"); + // 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(); + } + }); + + 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"); + }, + }); + + 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("propagates the caller's own abort instead of claiming nothing", async () => { + const controller = new AbortController(); + const { provider, deleted } = deployedProvider({ + 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" }, signal: controller.signal }), + ).rejects.toThrow(/caller cancelled/); + expect(deleted).toHaveBeenCalledTimes(1); + }); + + 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( + 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, + ); + } + }); + + 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({ + exactDispatch: true, + canonicalCancellation: true, + eventReplay: true, + executionScopedStatus: true, + }); + 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 5737777..c482032 100644 --- a/packages/agent-provider-tangle/src/index.test.ts +++ b/packages/agent-provider-tangle/src/index.test.ts @@ -19,6 +19,8 @@ import { tokenUsageFromData } from "./tangle-result-values.js"; import { controlRefForTurn, executionIdForTurn, + retainedDeployment, + retainedSessionHandle, TANGLE_PROVIDER, } from "./retained-control-test-helpers.js"; @@ -286,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 }, }); @@ -340,7 +342,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 +392,7 @@ describe("createTangleProvider", () => { }, exec: async () => ({ exitCode: 0, stdout: "ok\n", stderr: "" }), delete: async () => {}, - }; + }); const client: SandboxClientLike = { async create(options, requestOptions) { createOptions = options; @@ -464,7 +466,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 +489,7 @@ describe("createTangleProvider", () => { }, interrupt, }), - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -552,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 }, }); @@ -639,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 }, }); @@ -661,7 +663,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 +686,7 @@ describe("createTangleProvider", () => { }, interrupt: async () => ({ cancelled: false }), }), - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -708,14 +710,15 @@ 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", }), - }; + session: retainedSessionHandle, + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -733,7 +736,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 +758,7 @@ describe("createTangleProvider", () => { }, interrupt, }), - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -882,7 +885,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 +904,7 @@ describe("createTangleProvider", () => { }; }, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -976,7 +979,7 @@ describe("createTangleProvider", () => { }), interrupt: async () => ({ cancelled: true }), }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-unstable-events", async *streamPrompt() { yield { @@ -997,7 +1000,7 @@ describe("createTangleProvider", () => { }; }, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -1045,7 +1048,7 @@ describe("createTangleProvider", () => { }), interrupt: async () => ({ cancelled: true }), }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-competing-event", async *streamPrompt() { yield { @@ -1059,7 +1062,7 @@ describe("createTangleProvider", () => { } as SandboxEvent; }, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -1100,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 }, }); @@ -1143,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 }, }); @@ -1218,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 0c10165..92a1170 100644 --- a/packages/agent-provider-tangle/src/leaf-modules.test.ts +++ b/packages/agent-provider-tangle/src/leaf-modules.test.ts @@ -8,6 +8,11 @@ import { defaultTangleSandboxCapabilities, sandboxCapabilitySupport, } from "./tangle-capabilities.js"; +import { deploymentCapabilitySupport } from "./tangle-deployment-capabilities.js"; +import { + RETAINED_DEPLOYMENT_DOCUMENT, + retainedDeployment, +} from "./retained-control-test-helpers.js"; import { assertBoundedJson, awaitWithSignal, @@ -114,6 +119,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 +178,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 +195,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 +238,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, @@ -414,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() {}, @@ -422,8 +449,8 @@ describe("Tangle split leaf modules", () => { read: async () => readPending.promise, 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(); @@ -462,12 +489,12 @@ 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 = 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..e6cbbc7 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,78 @@ import { sessionPromptExecutionId, sessionPromptSessionId, } from "./tangle-session-control.js"; +import type { + SandboxInstanceLike, + SandboxRuntimeCapabilityDocument, + SandboxSessionLike, +} 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 }, +}; + +/** + * 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, + }; +} + +/** + * 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 ecae18b..a31f32b 100644 --- a/packages/agent-provider-tangle/src/retained-control.test.ts +++ b/packages/agent-provider-tangle/src/retained-control.test.ts @@ -23,6 +23,8 @@ import { retainedSessionControlRef } from "./tangle-session-control.js"; import { controlRefForTurn, executionIdForTurn, + retainedDeployment, + retainedSessionHandle, sessionPromptSessionId, TANGLE_PROVIDER as PROVIDER, } from "./retained-control-test-helpers.js"; @@ -111,32 +113,7 @@ 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 = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-wrapper-grant", async *streamPrompt() {}, dispatchPrompt: async (_message, options) => ({ @@ -147,8 +124,8 @@ describe("Tangle retained control", () => { alreadyExisted: false, dispatched: true, }), - session: (id) => capableSession(id), - }; + session: retainedSessionHandle, + }); const provider = createTangleProvider({ client: { create: async () => box, @@ -204,7 +181,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 +193,7 @@ describe("Tangle retained control", () => { const { cancelRun: _cancelRun, ...withoutCancelRun } = session; return withoutCancelRun as SandboxSessionLike; }, - }; + }); const provider = createTangleProvider({ client: { create: async () => box, @@ -278,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 }, }); @@ -374,7 +351,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 +376,7 @@ describe("Tangle retained control", () => { }; }, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: sdkShapedClient({ create: async () => box, @@ -492,7 +469,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 +479,8 @@ describe("Tangle retained control", () => { alreadyExisted: true, dispatched: false, }), - }; + session: retainedSessionHandle, + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -521,7 +499,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 +519,8 @@ describe("Tangle retained control", () => { status: "running", }; }, - }; + session: retainedSessionHandle, + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -616,7 +595,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 +607,8 @@ describe("Tangle retained control", () => { status: "running", }; }, - }; + session: retainedSessionHandle, + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -675,7 +655,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 +667,8 @@ describe("Tangle retained control", () => { status: "running", }; }, - }; + session: retainedSessionHandle, + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -720,7 +701,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 +723,7 @@ describe("Tangle retained control", () => { }, interrupt, }), - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -808,7 +789,7 @@ describe("Tangle retained control", () => { }, interrupt, }; - const box: SandboxInstanceLike = { + const box: SandboxInstanceLike = retainedDeployment({ id: "sbx-duplicate-dispatch", async *streamPrompt() {}, dispatchPrompt: async () => { @@ -816,7 +797,7 @@ describe("Tangle retained control", () => { return dispatchResult.promise; }, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -873,7 +854,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 +870,7 @@ describe("Tangle retained control", () => { }; }, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -1004,7 +985,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 +1022,7 @@ describe("Tangle retained control", () => { }; }, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: { create: async () => box }, }); @@ -1103,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 }, }); @@ -1153,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 }, }); @@ -1207,7 +1188,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 +1201,7 @@ describe("Tangle retained control", () => { }; }, session: () => sandboxSession, - }; + }); const provider = createTangleProvider({ client: sdkShapedClient({ create: async () => box, @@ -1316,7 +1297,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 +1337,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..d82f587 100644 --- a/packages/agent-provider-tangle/src/tangle-capabilities.ts +++ b/packages/agent-provider-tangle/src/tangle-capabilities.ts @@ -9,15 +9,20 @@ import type { SandboxInstanceLike, SandboxSessionLike, } from "./tangle-types.js"; +import { + ADAPTER_CEILING_DEPLOYMENT, + deploymentBacksRetainedControl, +} 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 +52,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 +77,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 +131,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, @@ -147,13 +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`. 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`. + * 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, @@ -174,18 +183,23 @@ 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: + * 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 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 +207,35 @@ 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. 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, + support: SandboxCapabilitySupport, + deployment: DeploymentCapabilitySupport, +): AgentEnvironmentCapabilities { + const supportsRetainedControl = tangleRetainedControlSupported( + declared, + support, + deployment, + ); + const supportsDetach = + 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. @@ -201,9 +243,12 @@ export function narrowedTangleCapabilities( ...declared, streaming: { ...declared.streaming, - detach: support.dispatchPrompt ? declared.streaming.detach : false, - replay: support.session ? declared.streaming.replay : false, - turnIdempotency: support.session + detach: supportsDetach ? declared.streaming.detach : false, + replay: + support.session && deployment.eventReplay + ? declared.streaming.replay + : false, + turnIdempotency: deployment.exactDispatch ? declared.streaming.turnIdempotency : false, }, @@ -241,20 +286,48 @@ 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. + * 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, client: SandboxClientLike, ): AgentEnvironmentCapabilities { - return narrowedTangleCapabilities(declared, clientCapabilitySupport(client)); + return narrowedTangleCapabilities( + declared, + clientCapabilitySupport(client), + ADAPTER_CEILING_DEPLOYMENT, + ); } -/** Narrow a declared capability document to what this Sandbox instance backs. */ +/** + * 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. + */ 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..bd74ce8 --- /dev/null +++ b/packages/agent-provider-tangle/src/tangle-deployment-capabilities.ts @@ -0,0 +1,146 @@ +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 run operations this adapter + * builds on top of a sandbox. + * + * 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 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 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, +}; + +/** + * 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. + * + * 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 UNPROVEN_DEPLOYMENT; + return { + 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. Every outcome but the caller's own abort resolves to a fact set. + * + * 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. + * + * 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 UNPROVEN_DEPLOYMENT; + if (statusFromUnknown(box.status) !== "running") return UNPROVEN_DEPLOYMENT; + options?.signal?.throwIfAborted(); + 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 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.exactDispatch && + deployment.canonicalCancellation && + deployment.eventReplay && + deployment.executionScopedStatus + ); +} 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..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, @@ -30,7 +31,12 @@ import { statusFromUnknown, } from "./tangle-environment-values.js"; import { execResultFromSandboxExecResult } from "./tangle-result-values.js"; -import { capabilitiesForSandbox, sandboxCapabilitySupport } from "./tangle-capabilities.js"; +import { + capabilitiesForSandbox, + frozenCapabilityDocument, + sandboxCapabilitySupport, +} from "./tangle-capabilities.js"; +import { readDeploymentCapabilitySupport } from "./tangle-deployment-capabilities.js"; import { awaitWithSignal, assertBoundedJson, @@ -47,12 +53,30 @@ 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. 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, 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 +86,15 @@ export function sandboxInstanceAsEnvironment( assertBoundedJson(box.metadata); } const support = sandboxCapabilitySupport(box, client); - const capabilities = capabilitiesForSandbox(declaredCapabilities, support); + const deployment = await readDeploymentCapabilitySupport(box, operation); + 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) @@ -87,6 +119,7 @@ export 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); @@ -169,11 +202,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..2ef2509 100644 --- a/packages/agent-provider-tangle/src/tangle-types.ts +++ b/packages/agent-provider-tangle/src/tangle-types.ts @@ -49,6 +49,47 @@ 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 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 { + schema?: number; + agentInterface?: string; + sidecarVersion?: string; + image?: string; + 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; + }; +} + export interface SandboxProcessStatusLike { pid: number; running: boolean; @@ -116,6 +157,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/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/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..48dda55 100644 --- a/scripts/fixtures/tangle-control-consumer.test.ts +++ b/scripts/fixtures/tangle-control-consumer.test.ts @@ -5,12 +5,14 @@ import { describe, expect, it, vi } from "vitest"; import { SandboxInstance, type SandboxEvent, + type SandboxRuntimeCapabilities, type TangleSandboxClient, } from "@tangle-network/sandbox"; import { createTangleProvider, type SandboxClientLike, type SandboxInstanceLike, + type SandboxRuntimeCapabilityDocument, type SandboxSessionLike, } from "@tangle-network/agent-provider-tangle"; @@ -20,6 +22,32 @@ function acceptPublicTangleClient(client: TangleSandboxClient): SandboxClientLik void acceptPublicTangleClient; +/** + * 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", + 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: {}, +}; +const DEPLOYMENT_CAPABILITIES_AS_READ: SandboxRuntimeCapabilityDocument = + DEPLOYMENT_CAPABILITIES; + +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 +61,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 }, @@ -63,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 () => { @@ -148,6 +200,8 @@ describe("packed Tangle exact-session control", () => { }; const box: SandboxInstanceLike = { id: "sandbox-1", + status: "running", + capabilities: async () => DEPLOYMENT_CAPABILITIES_AS_READ, async *streamPrompt(_message, options) { eventSelector(options); const events = [ @@ -236,6 +290,8 @@ describe("packed Tangle exact-session control", () => { }; const box: SandboxInstanceLike = { id: "sandbox-unproven", + status: "running", + capabilities: async () => DEPLOYMENT_CAPABILITIES_AS_READ, 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", }, },