Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-ravens-plan.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions packages/agent-interface/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -674,6 +689,7 @@ export interface SdkTraceContext {
export type SdkHostServices = {
memoryHost: SdkMemoryHost;
toolHost: SdkToolHost;
planHost: SdkPlanHost;
recorder: SdkRecorder;
providerConfig: ProviderConfig;
traceContext?: SdkTraceContext;
Expand Down
68 changes: 68 additions & 0 deletions packages/agent-interface/src/plan.test.ts
Original file line number Diff line number Diff line change
@@ -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" } });
});
});
106 changes: 106 additions & 0 deletions packages/agent-interface/src/plan.ts
Original file line number Diff line number Diff line change
@@ -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<typeof PlanProviderKindSchema>;

/**
* 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<typeof PlanProviderStateSchema>;

/**
* 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<typeof DurablePlanSchema>;

/** 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<typeof PlanSubmissionSchema>;

/** 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<typeof PlanDecisionSchema>;

/**
* 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<typeof PlanContinuationSchema>;

/** 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<DurablePlan>;
/** Publish a committed plan only after the provider turn has terminalized. */
confirm(planId: string): Promise<void>;
/** Compensate a committed plan when provider terminalization cannot be proven. */
withdraw(planId: string, reason: string): Promise<void>;
}
Loading