diff --git a/.changeset/quiet-ravens-plan.md b/.changeset/quiet-ravens-plan.md new file mode 100644 index 0000000..6535091 --- /dev/null +++ b/.changeset/quiet-ravens-plan.md @@ -0,0 +1,5 @@ +--- +"@tangle-network/agent-interface": minor +--- + +Add the durable plan submission host, typed plan continuation, plan stream record, and required execution outcome contract. diff --git a/packages/agent-interface/src/index.ts b/packages/agent-interface/src/index.ts index 68de3e4..7bc7a4c 100644 --- a/packages/agent-interface/src/index.ts +++ b/packages/agent-interface/src/index.ts @@ -6,7 +6,14 @@ */ import type { InteractionRequest, InteractionResponse } from "./interaction.js"; +import type { + AgentExecutionOutcome, + DurablePlan, + PlanContinuation, + SdkPlanHost, +} from "./plan.js"; export type * from "./environment-provider.js"; +export * from "./plan.js"; // Capabilities describe what a provider supports export type BackendCapabilities = { @@ -320,6 +327,11 @@ export type StreamEvent = type: "interaction.cancel"; id: string; reason?: string; + } + /** A durable plan was committed. This event is observational, not a live ask. */ + | { + type: "plan.submitted"; + plan: DurablePlan; }; export type ToolInvocation = { @@ -373,9 +385,12 @@ export type AgentExecutionInput = { * client-initiated retries of the same intent. */ turnId?: string; + /** Server-owned continuation of a previously committed plan decision. */ + planContinuation?: PlanContinuation; }; export type AgentExecutionResult = { + outcome: AgentExecutionOutcome; text: string; toolInvocations: ToolInvocation[]; reasoning?: string[]; @@ -674,6 +689,7 @@ export interface SdkTraceContext { export type SdkHostServices = { memoryHost: SdkMemoryHost; toolHost: SdkToolHost; + planHost: SdkPlanHost; recorder: SdkRecorder; providerConfig: ProviderConfig; traceContext?: SdkTraceContext; diff --git a/packages/agent-interface/src/plan.test.ts b/packages/agent-interface/src/plan.test.ts new file mode 100644 index 0000000..2f4b68b --- /dev/null +++ b/packages/agent-interface/src/plan.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + AgentExecutionOutcomeSchema, + PlanContinuationSchema, + PlanSubmissionSchema, +} from "./plan.js"; + +const codexState = { + kind: "codex" as const, + version: 1 as const, + threadId: "thread-1", +}; + +const plan = { + id: "plan-1", + revision: 1, + body: "Implement the approved design", + submittedAt: "2026-07-10T00:00:00.000Z", +}; + +describe("durable plan contract", () => { + it("requires a full non-empty body and a closed provider state", () => { + expect( + PlanSubmissionSchema.safeParse({ + body: " ", + sourceToolCallId: "tool-1", + providerState: codexState, + }).success, + ).toBe(false); + expect( + PlanSubmissionSchema.safeParse({ + body: "Plan", + sourceToolCallId: "tool-1", + providerState: { kind: "factory-droids", version: 1 }, + }).success, + ).toBe(false); + }); + + it("requires feedback when a rejected plan starts its revision turn", () => { + expect( + PlanContinuationSchema.safeParse({ + version: 1, + plan, + sourceToolCallId: "tool-1", + decision: { outcome: "rejected", feedback: " " }, + providerState: codexState, + }).success, + ).toBe(false); + }); + + it("accepts a typed provider continuation and terminal outcome", () => { + expect( + PlanContinuationSchema.parse({ + version: 1, + plan, + sourceToolCallId: "tool-1", + decision: { outcome: "approved" }, + providerState: codexState, + }), + ).toMatchObject({ decision: { outcome: "approved" } }); + expect( + AgentExecutionOutcomeSchema.parse({ + type: "awaiting_plan_decision", + plan, + }), + ).toMatchObject({ type: "awaiting_plan_decision", plan: { id: "plan-1" } }); + }); +}); diff --git a/packages/agent-interface/src/plan.ts b/packages/agent-interface/src/plan.ts new file mode 100644 index 0000000..8c2d87b --- /dev/null +++ b/packages/agent-interface/src/plan.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; + +/** Harnesses with an enforceable durable-plan continuation contract. */ +export const PlanProviderKindSchema = z.enum([ + "claude-code", + "codex", + "opencode", +]); +export type PlanProviderKind = z.infer; + +/** + * Credential-free provider correlation committed with the plan. This union is + * deliberately closed: adding plan support for another harness requires an + * explicit resume contract instead of smuggling opaque metadata through. + */ +export const PlanProviderStateSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("claude-code"), + version: z.literal(1), + nativeSessionId: z.string().min(1), + }).strict(), + z.object({ + kind: z.literal("codex"), + version: z.literal(1), + threadId: z.string().min(1), + }).strict(), + z.object({ + kind: z.literal("opencode"), + version: z.literal(1), + }).strict(), +]); +export type PlanProviderState = z.infer; + +/** + * A plan committed by the runtime before the planning turn is allowed to end. + * The full body is part of the contract so consumers never reconstruct it from + * rendered chat text. + */ +export const DurablePlanSchema = z.object({ + id: z.string().min(1), + revision: z.number().int().positive(), + title: z.string().trim().min(1).optional(), + body: z.string().trim().min(1), + submittedAt: z.string().datetime({ offset: true }), +}).strict(); +export type DurablePlan = z.infer; + +/** Provider-originated data required to commit a plan exactly once. */ +export const PlanSubmissionSchema = z.object({ + title: z.string().trim().min(1).optional(), + body: z.string().trim().min(1), + /** Stable provider tool-call identity reused when a lost acknowledgement retries. */ + sourceToolCallId: z.string().min(1), + /** Credential-free correlation required to resume the deferred turn. */ + providerState: PlanProviderStateSchema, +}).strict(); +export type PlanSubmission = z.infer; + +/** The human verdict that starts the next turn. */ +export const PlanDecisionSchema = z.discriminatedUnion("outcome", [ + z.object({ outcome: z.literal("approved") }).strict(), + z.object({ + outcome: z.literal("rejected"), + feedback: z.string().trim().min(1), + }).strict(), +]); +export type PlanDecision = z.infer; + +/** + * Typed server-originated continuation. Adapters consume this instead of + * inferring approval from a free-form user message. + */ +export const PlanContinuationSchema = z.object({ + version: z.literal(1), + plan: DurablePlanSchema, + sourceToolCallId: z.string().min(1), + decision: PlanDecisionSchema, + /** Exact payload previously committed with the plan submission. */ + providerState: PlanProviderStateSchema, +}).strict(); +export type PlanContinuation = z.infer; + +/** Terminal meaning of a successfully completed adapter invocation. */ +export const AgentExecutionOutcomeSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("completed") }).strict(), + z.object({ + type: z.literal("awaiting_plan_decision"), + plan: DurablePlanSchema, + }).strict(), +]); +export type AgentExecutionOutcome = z.infer< + typeof AgentExecutionOutcomeSchema +>; + +/** + * Per-turn host command used by providers to commit a plan. Implementations + * bind project, host-session, and turn identity outside provider-controlled + * input and must resolve only after the durable write commits. + */ +export interface SdkPlanHost { + submit(submission: PlanSubmission): Promise; + /** Publish a committed plan only after the provider turn has terminalized. */ + confirm(planId: string): Promise; + /** Compensate a committed plan when provider terminalization cannot be proven. */ + withdraw(planId: string, reason: string): Promise; +}