diff --git a/.changeset/exact-retained-control.md b/.changeset/exact-retained-control.md new file mode 100644 index 0000000..beeee5e --- /dev/null +++ b/.changeset/exact-retained-control.md @@ -0,0 +1,5 @@ +--- +"@tangle-network/agent-interface": minor +--- + +Define explicit retained-run capability proof and digest-bound, retry-safe cancellation requests and acknowledgements. diff --git a/packages/agent-interface/README.md b/packages/agent-interface/README.md index 55c02b7..57f1973 100644 --- a/packages/agent-interface/README.md +++ b/packages/agent-interface/README.md @@ -7,8 +7,11 @@ shapes; higher-level packages import from here rather than redefining them. ## Durable runs, interactions, and context -`AgentRunControlRef` identifies a retained run without depending on a live JavaScript object. +`AgentRunControlRef` identifies a retained run without depending on a live JavaScript object and may carry the provider's admission digest so reconstruction can reject changed-input reuse. `RuntimeEventEnvelope` adds stable run, event, sequence, cursor, and timestamp fields around the existing `StreamEvent` union, and its runtime schema validates every canonical event variant. +Providers advertise `retainedControl` only when exact run, result, event, cancellation, replay, detach, turn, and session identity are all implemented together. +`AgentSession.cancelRun()` accepts a canonical request digest bound to one operation and `AgentRunControlRef`, so a caller can safely repeat the same cancellation after losing the first acknowledgement. +Its acknowledgement repeats the operation, digest, and run coordinates and distinguishes a known cancellation effect from conflict or unknown state. An environment advertises `interactions` only when it can originate and answer typed requests. `AgentEnvironmentCapabilitiesSchema` strictly validates the complete capability document at runtime, including all-or-nothing durable branching declarations. diff --git a/packages/agent-interface/src/environment-provider.test.ts b/packages/agent-interface/src/environment-provider.test.ts index c724260..d762c4d 100644 --- a/packages/agent-interface/src/environment-provider.test.ts +++ b/packages/agent-interface/src/environment-provider.test.ts @@ -100,6 +100,34 @@ describe("AgentEnvironmentCapabilitiesSchema", () => { ).toThrow(/requires session continuation/); }); + it("advertises retained run control only with every identity guarantee", () => { + const retainedControl = { + exactRunIdentity: true, + resultIdentity: true, + eventIdentity: true, + cancellationIdempotency: true, + }; + expect( + AgentEnvironmentCapabilitiesSchema.parse({ + ...capabilities, + retainedControl, + }), + ).toMatchObject({ retainedControl }); + expect(() => + AgentEnvironmentCapabilitiesSchema.parse({ + ...capabilities, + retainedControl: { ...retainedControl, resultIdentity: false }, + }), + ).toThrow(/retained control requires exact run/); + expect(() => + AgentEnvironmentCapabilitiesSchema.parse({ + ...capabilities, + streaming: { ...capabilities.streaming, detach: false }, + retainedControl, + }), + ).toThrow(/retained control requires exact run/); + }); + it("rejects duplicate open capability values", () => { expect(() => AgentEnvironmentCapabilitiesSchema.parse({ diff --git a/packages/agent-interface/src/environment-provider.ts b/packages/agent-interface/src/environment-provider.ts index 948dc84..b178afb 100644 --- a/packages/agent-interface/src/environment-provider.ts +++ b/packages/agent-interface/src/environment-provider.ts @@ -52,6 +52,8 @@ import { type NativeContextContinuationTurn, } from "./portable-context.js"; import { + type AgentRunCancellationAcknowledgement, + type AgentRunCancellationRequest, AgentRunControlRefSchema, CanonicalStreamEventSchema, type AgentRunControlRef, @@ -489,6 +491,11 @@ export interface AgentSession { request: NativeContextContinuationRequest, options: AgentNativeContextContinuationOptions, ): Promise; + /** Retry-safe cancellation bound to one exact run and caller operation. */ + cancelRun?( + request: AgentRunCancellationRequest, + options?: { signal?: AbortSignal }, + ): Promise; cancel(): Promise; } @@ -539,6 +546,13 @@ export interface AgentEnvironmentCapabilities { list: boolean; messages: boolean; }; + /** Present only when every retained-run identity and retry promise is implemented. */ + retainedControl?: { + exactRunIdentity: boolean; + resultIdentity: boolean; + eventIdentity: boolean; + cancellationIdempotency: boolean; + }; /** Absent when verified, retry-safe same-session continuation is unsupported. */ nativeContinuation?: { /** Boundary comparison and operation admission are one atomic provider action. */ @@ -590,6 +604,14 @@ export const AgentEnvironmentCapabilitiesSchema = z list: z.boolean(), messages: z.boolean(), }), + retainedControl: z + .strictObject({ + exactRunIdentity: z.boolean(), + resultIdentity: z.boolean(), + eventIdentity: z.boolean(), + cancellationIdempotency: z.boolean(), + }) + .optional(), nativeContinuation: z .strictObject({ atomicBoundary: z.boolean(), @@ -622,6 +644,24 @@ export const AgentEnvironmentCapabilitiesSchema = z .optional(), }) .superRefine((capabilities, refinement) => { + if ( + capabilities.retainedControl !== undefined && + (!capabilities.retainedControl.exactRunIdentity || + !capabilities.retainedControl.resultIdentity || + !capabilities.retainedControl.eventIdentity || + !capabilities.retainedControl.cancellationIdempotency || + !capabilities.streaming.replay || + !capabilities.streaming.detach || + !capabilities.streaming.turnIdempotency || + !capabilities.sessions.continue) + ) { + refinement.addIssue({ + code: "custom", + path: ["retainedControl"], + message: + "retained control requires exact run, result, event, cancellation, replay, detach, turn, and session identity together", + }); + } if ( capabilities.nativeContinuation !== undefined && (!capabilities.nativeContinuation.atomicBoundary || diff --git a/packages/agent-interface/src/runtime-control.test.ts b/packages/agent-interface/src/runtime-control.test.ts index 9274ab5..9f011f2 100644 --- a/packages/agent-interface/src/runtime-control.test.ts +++ b/packages/agent-interface/src/runtime-control.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vitest"; import { + AgentRunCancellationAcknowledgementSchema, + AgentRunCancellationRequestSchema, AgentRunControlRefSchema, CanonicalStreamEventSchema, RuntimeEventEnvelopeSchema, + agentRunCancellationAcknowledgementMatchesRequest, + agentRunCancellationRequestDigest, } from "./runtime-control.js"; describe("durable run control", () => { @@ -13,12 +17,51 @@ describe("durable run control", () => { environmentId: "local-1", sessionId: "session-1", executionId: "execution-1", + requestDigest: `sha256:${"a".repeat(64)}`, }; expect(AgentRunControlRefSchema.parse(reference)).toEqual(reference); expect(() => AgentRunControlRefSchema.parse({ ...reference, provider: " cli-bridge" }), ).toThrow(/outer whitespace/); }); + + it("binds a retry-safe cancellation to one exact run and request digest", () => { + const material = { + operationId: "cancel-1", + run: { + runId: "run-1", + provider: "cli-bridge", + environmentId: "local-1", + sessionId: "session-1", + executionId: "execution-1", + }, + reason: "user requested stop", + }; + const request = AgentRunCancellationRequestSchema.parse({ + ...material, + requestDigest: agentRunCancellationRequestDigest(material), + }); + const acknowledgement = AgentRunCancellationAcknowledgementSchema.parse({ + operationId: request.operationId, + requestDigest: request.requestDigest, + run: request.run, + status: "accepted", + effect: "cancel_requested", + }); + expect( + agentRunCancellationAcknowledgementMatchesRequest(request, acknowledgement), + ).toBe(true); + expect(() => + AgentRunCancellationRequestSchema.parse({ ...request, reason: "changed" }), + ).toThrow(/digest/); + expect(() => + AgentRunCancellationAcknowledgementSchema.parse({ + ...acknowledgement, + status: "accepted", + effect: "unknown", + }), + ).toThrow(/certainty/); + }); }); describe("runtime event envelope", () => { diff --git a/packages/agent-interface/src/runtime-control.ts b/packages/agent-interface/src/runtime-control.ts index c36254c..5345b3c 100644 --- a/packages/agent-interface/src/runtime-control.ts +++ b/packages/agent-interface/src/runtime-control.ts @@ -1,5 +1,10 @@ import { z } from "zod"; import type { StreamEvent } from "./index.js"; +import { + canonicalCandidateDigest, + sha256DigestSchema, +} from "./agent-candidate-schema-common.js"; +import type { Sha256Digest } from "./agent-candidate.js"; import { InteractionRequestSchema } from "./interaction.js"; import { DurablePlanSchema } from "./plan.js"; @@ -16,6 +21,8 @@ export interface AgentRunControlRef { environmentId: string; sessionId?: string; executionId?: string; + /** Provider admission digest for detecting changed-input run reuse. */ + requestDigest?: Sha256Digest; } export const AgentRunControlRefSchema = z.strictObject({ @@ -24,8 +31,103 @@ export const AgentRunControlRefSchema = z.strictObject({ environmentId: stableIdSchema, sessionId: stableIdSchema.optional(), executionId: stableIdSchema.optional(), + requestDigest: sha256DigestSchema.optional(), }) satisfies z.ZodType; +export type AgentRunCancellationEffect = + | "cancel_requested" + | "cancelled" + | "not_live" + | "unknown"; + +export interface AgentRunCancellationRequestMaterial { + operationId: string; + run: AgentRunControlRef; + reason?: string; +} + +export interface AgentRunCancellationRequest + extends AgentRunCancellationRequestMaterial { + requestDigest: Sha256Digest; +} + +export function agentRunCancellationRequestDigest( + request: AgentRunCancellationRequestMaterial, +): Sha256Digest { + return canonicalCandidateDigest({ + operationId: request.operationId, + run: AgentRunControlRefSchema.parse(request.run), + ...(request.reason === undefined ? {} : { reason: request.reason }), + }); +} + +export const AgentRunCancellationRequestSchema = z + .strictObject({ + operationId: stableIdSchema, + requestDigest: sha256DigestSchema, + run: AgentRunControlRefSchema, + reason: z.string().min(1).max(2_048).optional(), + }) + .superRefine((request, refinement) => { + if (request.requestDigest !== agentRunCancellationRequestDigest(request)) { + refinement.addIssue({ + code: "custom", + path: ["requestDigest"], + message: "run cancellation request digest does not match its content", + }); + } + }) satisfies z.ZodType; + +export interface AgentRunCancellationAcknowledgement { + operationId: string; + requestDigest: Sha256Digest; + run: AgentRunControlRef; + status: "accepted" | "replayed" | "conflict" | "unknown"; + effect: AgentRunCancellationEffect; + message?: string; + retryable?: boolean; +} + +export const AgentRunCancellationAcknowledgementSchema = z + .strictObject({ + operationId: stableIdSchema, + requestDigest: sha256DigestSchema, + run: AgentRunControlRefSchema, + status: z.enum(["accepted", "replayed", "conflict", "unknown"]), + effect: z.enum(["cancel_requested", "cancelled", "not_live", "unknown"]), + message: z.string().min(1).optional(), + retryable: z.boolean().optional(), + }) + .superRefine((acknowledgement, refinement) => { + const known = acknowledgement.effect !== "unknown"; + if ( + ((acknowledgement.status === "accepted" || acknowledgement.status === "replayed") && !known) || + ((acknowledgement.status === "conflict" || acknowledgement.status === "unknown") && known) + ) { + refinement.addIssue({ + code: "custom", + path: ["effect"], + message: "cancellation status and effect certainty do not agree", + }); + } + }) satisfies z.ZodType; + +export function agentRunCancellationAcknowledgementMatchesRequest( + request: AgentRunCancellationRequest, + acknowledgement: AgentRunCancellationAcknowledgement, +): boolean { + const exactRequest = AgentRunCancellationRequestSchema.safeParse(request); + const exactAcknowledgement = + AgentRunCancellationAcknowledgementSchema.safeParse(acknowledgement); + if (!exactRequest.success || !exactAcknowledgement.success) return false; + return ( + exactAcknowledgement.data.operationId === exactRequest.data.operationId && + exactAcknowledgement.data.requestDigest === exactRequest.data.requestDigest && + canonicalCandidateDigest(exactAcknowledgement.data.run) === + canonicalCandidateDigest(exactRequest.data.run) + ); +} + const unknownRecordSchema = z.record(z.string(), z.unknown()); const partBase = { id: stableIdSchema,