From ce41e1c4ef53d638e47375161487701d408f7a3f Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 11:53:47 -0700 Subject: [PATCH] feat(agent-interface): add minimal Agent instance contracts --- .changeset/calm-agents-share-computers.md | 5 + packages/agent-interface/README.md | 28 ++- packages/agent-interface/package.json | 9 + .../src/agent-instance.test.ts | 136 +++++++++++ .../agent-interface/src/agent-instance.ts | 220 ++++++++++++++++++ packages/agent-interface/src/index.ts | 1 + 6 files changed, 396 insertions(+), 3 deletions(-) create mode 100644 .changeset/calm-agents-share-computers.md create mode 100644 packages/agent-interface/src/agent-instance.test.ts create mode 100644 packages/agent-interface/src/agent-instance.ts diff --git a/.changeset/calm-agents-share-computers.md b/.changeset/calm-agents-share-computers.md new file mode 100644 index 0000000..d56f6c6 --- /dev/null +++ b/.changeset/calm-agents-share-computers.md @@ -0,0 +1,5 @@ +--- +"@tangle-network/agent-interface": minor +--- + +Add minimal provider-neutral Agent instance contracts for optional managed Agents inside an existing execution environment, including workspace intent, public lifecycle records, profile identity, and idempotent stop acknowledgements. diff --git a/packages/agent-interface/README.md b/packages/agent-interface/README.md index cd5e625..530725b 100644 --- a/packages/agent-interface/README.md +++ b/packages/agent-interface/README.md @@ -1,10 +1,34 @@ # @tangle-network/agent-interface -Shared TypeScript types and zod schemas that define the contract between Tangle +Shared TypeScript types and Zod schemas that define the contract between Tangle agents, the sidecar, and provider adapters: capabilities, agent profiles, message parts, and harness descriptors. This is the canonical home for those shapes; higher-level packages import from here rather than redefining them. +## Agent instances + +`AgentProfile` describes behavior. `AgentInstanceSpec` describes one optional managed Agent inside an existing execution environment. The environment remains the computer and security boundary, so it may host zero, one, or many Agent instances. + +```ts +import type { AgentInstanceSpec } from "@tangle-network/agent-interface/agent-instance"; + +const planner = { + id: "planner", + profile: { + name: "planner", + harness: "opencode", + prompt: { systemPrompt: "Plan before editing." }, + }, + workspace: { mode: "shared" }, +} satisfies AgentInstanceSpec; +``` + +The portable contract owns only inline profile and harness selection, shared or isolated workspace intent, public lifecycle state, a provider-sanitized failure summary, and idempotent stop shapes. Credentials, HTTP routes, process identifiers, placement, billing, snapshots, local resource controls, grants, and fencing remain provider-private. + +`shared` means ordinary same-computer file visibility. It is not automatic merge behavior or tenant isolation. `isolated` asks the provider for a private writable view and explicit inspect or commit behavior. Providers must reject unsatisfied machine requirements rather than silently replacing or migrating a live environment. + +The public `AgentInstanceRecord` contains a credential-free profile identity, not the full profile or provider request. Existing session APIs can implement this contract without a new service: one instance maps to one managed session, compatible sessions may reuse a backend process, and stop maps to idempotent session deletion or process release. + ## Durable runs, interactions, and context `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. @@ -66,8 +90,6 @@ Omitting `interactions` and `nativeContinuation`, or leaving the three durable b `replace` means the provider deletes the harness's own system prompt and installs `prompt.systemPrompt`; `append` means it keeps that prompt and adds `prompt.appendSystemPrompt` to it. A provider that can only append must declare `replace: false` and refuse a profile carrying `systemPrompt`, because quietly appending a requested replacement leaves the instructions the caller asked to delete in force. - - ## Install ```bash diff --git a/packages/agent-interface/package.json b/packages/agent-interface/package.json index 512c5a5..1d5f700 100644 --- a/packages/agent-interface/package.json +++ b/packages/agent-interface/package.json @@ -19,6 +19,10 @@ "import": "./dist/agent-profile.js", "types": "./src/agent-profile.ts" }, + "./agent-instance": { + "import": "./dist/agent-instance.js", + "types": "./src/agent-instance.ts" + }, "./profile-snapshot": { "import": "./dist/agent-profile-snapshot.js", "types": "./src/agent-profile-snapshot.ts" @@ -70,6 +74,11 @@ "types": "./dist/agent-profile.d.ts", "default": "./dist/agent-profile.js" }, + "./agent-instance": { + "import": "./dist/agent-instance.js", + "types": "./dist/agent-instance.d.ts", + "default": "./dist/agent-instance.js" + }, "./profile-snapshot": { "import": "./dist/agent-profile-snapshot.js", "types": "./dist/agent-profile-snapshot.d.ts", diff --git a/packages/agent-interface/src/agent-instance.test.ts b/packages/agent-interface/src/agent-instance.test.ts new file mode 100644 index 0000000..c3eef3f --- /dev/null +++ b/packages/agent-interface/src/agent-instance.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + agentInstanceRecordSchema, + agentInstanceSpecSchema, + agentInstanceStopAcknowledgementSchema, + type AgentInstanceRecord, + type AgentInstanceSpec, +} from "./agent-instance.js"; + +describe("agent instance contracts", () => { + it("accepts a profile-less Agent", () => { + expect( + agentInstanceSpecSchema.parse({ + id: "default", + workspace: { mode: "shared" }, + }), + ).toEqual({ + id: "default", + workspace: { mode: "shared" }, + }); + }); + + it("accepts an inline profile, harness override, and isolated workspace", () => { + const spec = { + id: "reviewer", + profile: { + name: "reviewer", + harness: "opencode", + prompt: { appendSystemPrompt: "Review every claim." }, + }, + harness: "claude-code", + workspace: { mode: "isolated" }, + } satisfies AgentInstanceSpec; + + expect(agentInstanceSpecSchema.parse(spec)).toMatchObject({ + id: "reviewer", + profile: { name: "reviewer", harness: "opencode" }, + harness: "claude-code", + workspace: { mode: "isolated" }, + }); + expectTypeOf(spec).toMatchTypeOf(); + }); + + it("rejects provider and machine fields outside the portable contract", () => { + expect(() => + agentInstanceSpecSchema.parse({ + id: "planner", + machineShape: "profile-specific-vm", + }), + ).toThrow(); + + expect(() => + agentInstanceSpecSchema.parse({ + id: "planner", + metadata: { apiKey: "secret" }, + }), + ).toThrow(); + }); + + it("publishes only credential-free profile identity", () => { + const record = agentInstanceRecordSchema.parse({ + kind: "agent-instance", + schemaVersion: 1, + id: "planner", + profile: { + name: "planner", + digest: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + workspace: { mode: "shared" }, + status: "ready", + createdAtMs: 10, + updatedAtMs: 11, + }); + + expect(record.profile).toEqual({ + name: "planner", + digest: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }); + }); + + it("requires failure evidence only for failed records", () => { + const base = { + kind: "agent-instance" as const, + schemaVersion: 1 as const, + id: "reviewer", + workspace: { mode: "shared" as const }, + createdAtMs: 10, + updatedAtMs: 11, + }; + + expect(() => + agentInstanceRecordSchema.parse({ ...base, status: "failed" }), + ).toThrow(/failure reason/u); + + expect(() => + agentInstanceRecordSchema.parse({ + ...base, + status: "ready", + failure: { message: "not valid here" }, + }), + ).toThrow(/only for failed/u); + + const failed = agentInstanceRecordSchema.parse({ + ...base, + status: "failed", + failure: { code: "HARNESS_EXITED", message: "process exited" }, + }); + expectTypeOf(failed).toMatchTypeOf(); + }); + + it("rejects reversed timestamps and control characters", () => { + expect(() => + agentInstanceRecordSchema.parse({ + kind: "agent-instance", + schemaVersion: 1, + id: "planner", + name: "bad\nname", + workspace: { mode: "shared" }, + status: "ready", + createdAtMs: 20, + updatedAtMs: 10, + }), + ).toThrow(); + }); + + it("parses idempotent stop outcomes", () => { + expect( + agentInstanceStopAcknowledgementSchema.parse({ + agentId: "planner", + outcome: "already-stopped", + }), + ).toEqual({ agentId: "planner", outcome: "already-stopped" }); + }); +}); diff --git a/packages/agent-interface/src/agent-instance.ts b/packages/agent-interface/src/agent-instance.ts new file mode 100644 index 0000000..891eb89 --- /dev/null +++ b/packages/agent-interface/src/agent-instance.ts @@ -0,0 +1,220 @@ +import { z } from "zod"; +import type { Sha256Digest } from "./agent-candidate.js"; +import { + isWellFormedUnicode, + sha256DigestSchema, +} from "./agent-candidate-schema-common.js"; +import type { AgentProfile } from "./agent-profile.js"; +import type { HarnessType } from "./harness.js"; +import { harnessTypeSchema } from "./harness.js"; +import { agentProfileSchema } from "./profile-schema.js"; + +/** Lifecycle state of one managed Agent inside an execution environment. */ +export const AGENT_INSTANCE_STATUSES = [ + "starting", + "ready", + "busy", + "failed", + "stopped", +] as const; + +export type AgentInstanceStatus = (typeof AGENT_INSTANCE_STATUSES)[number]; + +/** + * How one Agent sees the provider-owned workspace. + * + * `shared` is ordinary same-computer visibility. `isolated` requests a private + * writable view with provider-defined inspect or commit behavior. Neither mode + * is a security boundary between mutually untrusted Agents. + */ +export const AGENT_INSTANCE_WORKSPACE_MODES = ["shared", "isolated"] as const; + +export type AgentInstanceWorkspaceMode = + (typeof AGENT_INSTANCE_WORKSPACE_MODES)[number]; + +export interface AgentInstanceWorkspace { + mode: AgentInstanceWorkspaceMode; +} + +/** + * Provider-neutral request to start one managed Agent inside an existing + * execution environment. + * + * Omitting `profile` asks the provider for its default Agent configuration. + * Omitting `workspace` selects the provider's documented default. A profile + * never implies another VM. + */ +export interface AgentInstanceSpec { + /** Stable caller-selected id or idempotency key, when supported. */ + id?: string; + /** Human-readable label; not immutable identity. */ + name?: string; + /** Exact portable profile for this Agent. */ + profile?: AgentProfile; + /** Optional execution override; otherwise the profile or provider decides. */ + harness?: HarnessType; + workspace?: AgentInstanceWorkspace; +} + +/** Credential-free identity of the profile bound to an Agent instance. */ +export interface AgentInstanceProfileIdentity { + name?: string; + digest: Sha256Digest; +} + +/** + * Public failure summary. Providers must remove credentials and private + * implementation details before publishing this value. + */ +export interface AgentInstanceFailure { + code?: string; + message: string; +} + +/** + * Portable snapshot of one managed Agent. + * + * The record deliberately excludes the full profile, provider request, + * credentials, grants, process ids, placement, and fencing state. + */ +export interface AgentInstanceRecord { + kind: "agent-instance"; + schemaVersion: 1; + id: string; + name?: string; + profile?: AgentInstanceProfileIdentity; + /** Effective harness after profile and caller override resolution. */ + harness?: HarnessType; + workspace: AgentInstanceWorkspace; + status: AgentInstanceStatus; + failure?: AgentInstanceFailure; + createdAtMs: number; + updatedAtMs: number; +} + +export interface AgentInstanceStopRequest { + agentId: string; + /** Provider-defined hard termination after graceful stop cannot complete. */ + force?: boolean; +} + +export interface AgentInstanceStopAcknowledgement { + agentId: string; + outcome: "stopped" | "already-stopped" | "not-found"; +} + +const identifierSchema = z + .string() + .min(1) + .max(128) + .regex( + /^[A-Za-z0-9](?:[A-Za-z0-9._:-]*[A-Za-z0-9])?$/, + "identifier must use visible alphanumeric, '.', '_', ':', or '-' characters", + ); + +const labelSchema = z + .string() + .min(1) + .max(256) + .refine( + (value) => + isWellFormedUnicode(value) && !/[\u0000-\u001f\u007f]/u.test(value), + "label must be valid Unicode without control characters", + ); + +const failureMessageSchema = z + .string() + .min(1) + .max(16_384) + .refine( + (value) => isWellFormedUnicode(value) && !value.includes("\0"), + "failure message must be valid Unicode without NUL", + ); + +const timestampSchema = z + .number() + .int() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER); + +export const agentInstanceStatusSchema = z.enum(AGENT_INSTANCE_STATUSES); + +export const agentInstanceWorkspaceModeSchema = z.enum( + AGENT_INSTANCE_WORKSPACE_MODES, +); + +export const agentInstanceWorkspaceSchema: z.ZodType = + z.strictObject({ + mode: agentInstanceWorkspaceModeSchema, + }); + +export const agentInstanceSpecSchema: z.ZodType = + z.strictObject({ + id: identifierSchema.optional(), + name: labelSchema.optional(), + profile: agentProfileSchema.optional(), + harness: harnessTypeSchema.optional(), + workspace: agentInstanceWorkspaceSchema.optional(), + }); + +export const agentInstanceProfileIdentitySchema: z.ZodType = + z.strictObject({ + name: labelSchema.optional(), + digest: sha256DigestSchema, + }); + +export const agentInstanceFailureSchema: z.ZodType = + z.strictObject({ + code: identifierSchema.optional(), + message: failureMessageSchema, + }); + +export const agentInstanceRecordSchema: z.ZodType = z + .strictObject({ + kind: z.literal("agent-instance"), + schemaVersion: z.literal(1), + id: identifierSchema, + name: labelSchema.optional(), + profile: agentInstanceProfileIdentitySchema.optional(), + harness: harnessTypeSchema.optional(), + workspace: agentInstanceWorkspaceSchema, + status: agentInstanceStatusSchema, + failure: agentInstanceFailureSchema.optional(), + createdAtMs: timestampSchema, + updatedAtMs: timestampSchema, + }) + .superRefine((record, context) => { + if (record.updatedAtMs < record.createdAtMs) { + context.addIssue({ + code: "custom", + path: ["updatedAtMs"], + message: "agent instance update cannot precede creation", + }); + } + if (record.status === "failed" && record.failure === undefined) { + context.addIssue({ + code: "custom", + path: ["failure"], + message: "failed agent instance requires a failure reason", + }); + } + if (record.status !== "failed" && record.failure !== undefined) { + context.addIssue({ + code: "custom", + path: ["failure"], + message: "failure reason is valid only for failed agent instances", + }); + } + }); + +export const agentInstanceStopRequestSchema: z.ZodType = + z.strictObject({ + agentId: identifierSchema, + force: z.boolean().optional(), + }); + +export const agentInstanceStopAcknowledgementSchema: z.ZodType = + z.strictObject({ + agentId: identifierSchema, + outcome: z.enum(["stopped", "already-stopped", "not-found"]), + }); diff --git a/packages/agent-interface/src/index.ts b/packages/agent-interface/src/index.ts index 062d2d7..10a954e 100644 --- a/packages/agent-interface/src/index.ts +++ b/packages/agent-interface/src/index.ts @@ -94,6 +94,7 @@ export * from "./agent-profile-improvement.js"; export * from "./agent-profile-improvement-schema.js"; export * from "./agent-execution-limits.js"; export * from "./agent-profile.js"; +export * from "./agent-instance.js"; export * from "./agent-profile-snapshot.js"; export * from "./agent-profile-activation.js"; export * from "./agent-profile-materialization.js";