Skip to content

Latest commit

 

History

History
788 lines (635 loc) · 25.6 KB

File metadata and controls

788 lines (635 loc) · 25.6 KB

Migrating from @virtuals-protocol/acp-node to @virtuals-protocol/acp-node-v2

This is a breaking rewrite, not a drop-in upgrade. The v2 SDK replaces the imperative callback model with an event-driven architecture built around AcpAgent and JobSession.

What's new in v2:

  • Event-driven architecture (replaces phase-based callbacks)
  • Pluggable transports (SSE or WebSocket)
  • Multi-chain support (multiple chains per agent)
  • First-class LLM tool integration (availableTools(), toMessages(), executeTool())
  • Solana support
  • Privy-managed wallets (replaces local private key signing)
  • signTypedData on IEvmProviderAdapter (enables v1 protocol compatibility for EIP-712 auth)

Upgrade Your Agent on the UI

Before updating your code, upgrade your agent on app.virtuals.io. Legacy agents will show a "Deprecated Legacy ACP, Upgrade Now" banner. Click Upgrade Now to migrate your agent -- this will generate a new wallet for your agent.

After upgrading, go to your agent's page and open the Signers tab to:

  1. Copy your Wallet ID
  2. Click + Add Signer to generate a signer private key
  3. Click Copy Key to save your signer private key

You will need the walletId and signerPrivateKey to initialize the PrivyAlchemyEvmProviderAdapter in v2.


Installation

# Old
npm install @virtuals-protocol/acp-node

# New
npm install @virtuals-protocol/acp-node-v2

New peer dependencies: viem, @account-kit/infra.


Concept Mapping

v1 (@virtuals-protocol/acp-node) v2 (@virtuals-protocol/acp-node-v2) Notes
AcpClient AcpAgent Main entry point
AcpContractClientV2 PrivyAlchemyEvmProviderAdapter Provider is now separate from client
onNewTask / onEvaluate callbacks agent.on("entry", handler) Single unified event handler
AcpJob (with phase numbers) JobSession (with derived status) Session wraps job + conversation history
Phases: REQUEST / NEGOTIATION / TRANSACTION / EVALUATION / COMPLETED / REJECTED Events: job.created / budget.set / job.funded / job.submitted / job.completed / job.rejected Status derived from event stream
Fare / FareAmount / FareBigInt AssetToken AssetToken.usdc(amount, chainId)
acpClient.browseAgents() agent.browseAgents() Returns AcpAgentDetail[] with offerings
offering.initiateJob() agent.createJobFromOffering() Validates requirement, creates job, sends first message
memo.sign() Not needed Signing handled internally
Config objects (baseAcpConfigV2, etc.) Auto-configured defaults Override via contractAddresses param if needed
Single chain per client Multi-chain per agent agent.createJob(chainId, ...)
PriceType.SUBSCRIPTION on offering entry.packageId on requirement message Buyer opts in per-job, not offering-level
acpClient.getSubscriptionPaymentRequirement(client, provider, offeringName) agent.isSubscriptionActive(chainId, client, provider, packageId) Tier metadata now comes from agent.getMe().subscriptions
job.createPayableRequirement(meta, PAYABLE_REQUEST_SUBSCRIPTION, fare, provider, expiredAt, duration) session.setBudgetWithSubscription(token, duration, packageId) Replaces the payable-memo subscription flow

Wallet Provider Change

All EVM agents now use Privy-managed wallets via PrivyAlchemyEvmProviderAdapter. You will need a walletId and signerPrivateKey from the Virtuals UI -- see Upgrade Your Agent on the UI above.


Initialization

Before (v1)

import AcpClient, {
  AcpContractClientV2,
  baseAcpX402ConfigV2,
} from "@virtuals-protocol/acp-node";

const acpClient = new AcpClient({
  acpContractClient: await AcpContractClientV2.build(
    WHITELISTED_WALLET_PRIVATE_KEY, // dev wallet private key
    ENTITY_ID,                       // entity ID for session key
    AGENT_WALLET_ADDRESS,            // agent wallet address
    baseAcpX402ConfigV2              // config object
  ),
  onNewTask: async (job, memoToSign) => { /* ... */ },
  onEvaluate: async (job) => { /* ... */ },
});

After (v2)

import { AcpAgent, PrivyAlchemyEvmProviderAdapter } from "@virtuals-protocol/acp-node-v2";
import { base } from "@account-kit/infra";

const agent = await AcpAgent.create({
  provider: await PrivyAlchemyEvmProviderAdapter.create({
    walletAddress: "0xAgentWalletAddress",
    walletId: "your-privy-wallet-id",
    chains: [base], // multi-chain support
    signerPrivateKey: "your-privy-signer-private-key",
  }),
});

Event Handling

Before (v1)

Two separate callbacks with phase-based branching:

const acpClient = new AcpClient({
  acpContractClient: /* ... */,
  onNewTask: async (job: AcpJob, memoToSign?: AcpMemo) => {
    if (job.phase === AcpJobPhases.REQUEST && memoToSign?.nextPhase === AcpJobPhases.NEGOTIATION) {
      // New job request arrived -- accept or reject
      await job.accept("Accepted");
      await job.createRequirement("Please pay to proceed");
    } else if (job.phase === AcpJobPhases.NEGOTIATION && memoToSign?.nextPhase === AcpJobPhases.TRANSACTION) {
      // Seller set requirement -- pay
      await job.payAndAcceptRequirement();
    } else if (job.phase === AcpJobPhases.TRANSACTION && memoToSign?.nextPhase === AcpJobPhases.EVALUATION) {
      // Payment received -- deliver
      await job.deliver({ type: "url", value: "https://example.com" });
    } else if (job.phase === AcpJobPhases.COMPLETED) {
      // Job done
      console.log("Deliverable:", await job.getDeliverable());
    }
  },
  onEvaluate: async (job: AcpJob) => {
    await job.evaluate(true, "Approved");
  },
});

After (v2)

Single event handler with event-type switching:

agent.on("entry", async (session, entry) => {
  if (entry.kind === "system") {
    switch (entry.event.type) {
      case "job.created":
        // Provider: new job arrived
        await session.setBudget(AssetToken.usdc(0.1, session.chainId));
        break;

      case "budget.set":
        // Client: budget proposed, fund the job
        await session.fund(AssetToken.usdc(0.1, session.chainId));
        break;

      case "job.funded":
        // Provider: payment received, submit deliverable
        await session.submit("https://example.com");
        break;

      case "job.submitted":
        // Evaluator: deliverable ready, approve or reject
        await session.complete("Approved");
        break;

      case "job.completed":
        console.log("Job done!");
        break;

      case "job.rejected":
        console.log("Job rejected");
        break;
    }
  }

  if (entry.kind === "message") {
    console.log(`${entry.from}: ${entry.content}`);
  }
});

Phase-to-Event Mapping

v1 Phase v2 Event Who acts
REQUEST (new job) job.created Provider
NEGOTIATION (requirement set) budget.set Client
TRANSACTION (payment received) job.funded Provider
EVALUATION (deliverable submitted) job.submitted Evaluator
COMPLETED job.completed --
REJECTED job.rejected --

Job Creation

Before (v1)

// Search for agents, pick an offering, initiate job
const agents = await acpClient.browseAgents("meme seller", {
  sortBy: [AcpAgentSort.SUCCESSFUL_JOB_COUNT],
  topK: 5,
  graduationStatus: AcpGraduationStatus.ALL,
  onlineStatus: AcpOnlineStatus.ALL,
});

const offering = agents[0].jobOfferings[0];
const jobId = await offering.initiateJob(
  { requirement: "I want a cat meme" },
  EVALUATOR_ADDRESS
);

After (v2)

import { AgentSort } from "@virtuals-protocol/acp-node-v2";

// Browse for agents
const agents = await agent.browseAgents("meme seller", {
  sortBy: [AgentSort.SUCCESSFUL_JOB_COUNT, AgentSort.SUCCESS_RATE],
  topK: 5,
  showHidden: true,
});

// Pick agent and offering
const chosenAgent = agents[0];
const offering = chosenAgent.offerings[0];

// Create job from offering (validates requirement, creates job, sends first message)
// expiredAt is auto-calculated from offering.slaMinutes
const jobId = await agent.createJobFromOffering(
  base.id,
  offering,
  chosenAgent.walletAddress,
  { key: "I want a cat meme" }, // requirement data matching offering schema
  { evaluatorAddress: await agent.getAddress() }
);

Note: createJobFromOffering validates requirement data against the offering's JSON schema (if defined), creates the job on-chain (using createFundTransferJob when offering.requiredFunds is true), sends the first message with the requirement, and auto-calculates expiredAt from offering.slaMinutes.


Job Actions

Action v1 v2
Set price/budget job.accept() + job.createRequirement() session.setBudget(AssetToken.usdc(amount, chainId))
Pay/fund job.payAndAcceptRequirement() session.fund(AssetToken.usdc(amount, chainId)) or session.fund() (auto from budget)
Submit deliverable job.deliver({ type: "url", value: "..." }) session.submit("deliverable content")
Approve job.evaluate(true, "reason") session.complete("reason")
Reject job.reject("reason") or job.evaluate(false, "reason") session.reject("reason")
Send message N/A (via memos) session.sendMessage("text", "contentType?")

Payment / Token Handling

Before (v1)

import { Fare, FareAmount } from "@virtuals-protocol/acp-node";
// Payment handled implicitly via offering price + payAndAcceptRequirement()

After (v2)

import { AssetToken } from "@virtuals-protocol/acp-node-v2";

// USDC with auto-resolved address and decimals per chain
const token = AssetToken.usdc(0.1, base.id);

// From raw on-chain amount
const raw = AssetToken.usdcFromRaw(100000n, base.id);

// Custom token
const custom = AssetToken.create("0xTokenAddress", "SYMBOL", 18, 1.5);

Full Buyer Example

Before (v1)

import AcpClient, {
  AcpContractClientV2,
  AcpJobPhases,
  AcpJob,
  AcpMemo,
  AcpAgentSort,
  AcpGraduationStatus,
  AcpOnlineStatus,
  baseAcpX402ConfigV2,
} from "@virtuals-protocol/acp-node";

async function buyer() {
  const acpClient = new AcpClient({
    acpContractClient: await AcpContractClientV2.build(
      WHITELISTED_WALLET_PRIVATE_KEY,
      BUYER_ENTITY_ID,
      BUYER_AGENT_WALLET_ADDRESS,
      baseAcpX402ConfigV2
    ),
    onNewTask: async (job: AcpJob, memoToSign?: AcpMemo) => {
      if (
        job.phase === AcpJobPhases.NEGOTIATION &&
        memoToSign?.nextPhase === AcpJobPhases.TRANSACTION
      ) {
        await job.payAndAcceptRequirement();
      } else if (job.phase === AcpJobPhases.COMPLETED) {
        console.log("Deliverable:", await job.getDeliverable());
      } else if (job.phase === AcpJobPhases.REJECTED) {
        console.log("Job rejected");
      }
    },
  });

  const agents = await acpClient.browseAgents("keyword", {
    sortBy: [AcpAgentSort.SUCCESSFUL_JOB_COUNT],
    topK: 5,
    graduationStatus: AcpGraduationStatus.ALL,
    onlineStatus: AcpOnlineStatus.ALL,
  });

  const offering = agents[0].jobOfferings[0];
  const jobId = await offering.initiateJob({ key: "value" });
  console.log(`Job ${jobId} initiated`);
}

After (v2)

import { AcpAgent, PrivyAlchemyEvmProviderAdapter, AssetToken, AgentSort } from "@virtuals-protocol/acp-node-v2";
import type { JobSession, JobRoomEntry } from "@virtuals-protocol/acp-node-v2";
import { base } from "@account-kit/infra";

async function buyer() {
  const agent = await AcpAgent.create({
    provider: await PrivyAlchemyEvmProviderAdapter.create({
      walletAddress: BUYER_WALLET_ADDRESS,
      walletId: WALLET_ID,
      signerPrivateKey: SIGNER_PRIVATE_KEY,
      chains: [base],
    }),
  });

  const buyerAddress = await agent.getAddress();

  agent.on("entry", async (session: JobSession, entry: JobRoomEntry) => {
    if (entry.kind === "system") {
      switch (entry.event.type) {
        case "budget.set":
          await session.fund(AssetToken.usdc(0.1, session.chainId));
          break;

        case "job.submitted":
          await session.complete("Looks good");
          break;

        case "job.completed":
          console.log("Job done!");
          await agent.stop();
          break;
      }
    }
  });

  await agent.start();

  // Browse for agents and pick an offering
  const agents = await agent.browseAgents("meme seller", {
    sortBy: [AgentSort.SUCCESSFUL_JOB_COUNT],
    topK: 5,
  });
  const chosenAgent = agents[0];
  const offering = chosenAgent.offerings[0];

  // Create job from offering (validates, creates job, sends first message)
  // expiredAt is auto-calculated from offering.slaMinutes
  const jobId = await agent.createJobFromOffering(
    base.id,
    offering,
    chosenAgent.walletAddress,
    { key: "I want a cat meme" },
    { evaluatorAddress: buyerAddress }
  );

  console.log(`Job ${jobId} created`);
}

Full Seller Example

Before (v1)

import AcpClient, {
  AcpContractClientV2,
  AcpJob,
  AcpJobPhases,
  AcpMemo,
  DeliverablePayload,
} from "@virtuals-protocol/acp-node";

async function seller() {
  new AcpClient({
    acpContractClient: await AcpContractClientV2.build(
      WHITELISTED_WALLET_PRIVATE_KEY,
      SELLER_ENTITY_ID,
      SELLER_AGENT_WALLET_ADDRESS
    ),
    onNewTask: async (job: AcpJob, memoToSign?: AcpMemo) => {
      if (
        job.phase === AcpJobPhases.REQUEST &&
        memoToSign?.nextPhase === AcpJobPhases.NEGOTIATION
      ) {
        await job.accept("Accepted");
        await job.createRequirement("Please pay to proceed");
      } else if (
        job.phase === AcpJobPhases.TRANSACTION &&
        memoToSign?.nextPhase === AcpJobPhases.EVALUATION
      ) {
        const deliverable: DeliverablePayload = {
          type: "url",
          value: "https://example.com",
        };
        await job.deliver(deliverable);
      }
    },
  });
}

After (v2)

import { AcpAgent, PrivyAlchemyEvmProviderAdapter, AssetToken } from "@virtuals-protocol/acp-node-v2";
import type { JobSession, JobRoomEntry } from "@virtuals-protocol/acp-node-v2";
import { base } from "@account-kit/infra";

async function seller() {
  const agent = await AcpAgent.create({
    provider: await PrivyAlchemyEvmProviderAdapter.create({
      walletAddress: SELLER_WALLET_ADDRESS,
      walletId: WALLET_ID,
      signerPrivateKey: SIGNER_PRIVATE_KEY,
      chains: [base],
    }),
  });

  agent.on("entry", async (session: JobSession, entry: JobRoomEntry) => {
    if (entry.kind === "system") {
      switch (entry.event.type) {
        case "job.created":
          console.log(`New job ${session.jobId}`);
          break;

        case "job.funded":
          await session.sendMessage("Working on it...");
          await session.submit("https://example.com");
          break;

        case "job.completed":
          console.log(`Job ${session.jobId} completed!`);
          break;
      }
    }

    // Handle the buyer's first message containing the requirement
    if (entry.kind === "message" && entry.contentType === "requirement" && session.status === "open") {
      const requirement = JSON.parse(entry.content);
      const offeringName = session.job?.description; // set by createJobFromOffering
      console.log(`Requirement for "${offeringName}":`, requirement);
      await session.sendMessage("I can handle this.");
      await session.setBudget(AssetToken.usdc(0.1, session.chainId));
    }
  });

  await agent.start(() => {
    console.log("Listening for jobs...");
  });
}

Evaluator Migration

In v1, evaluators had a dedicated onEvaluate callback:

// v1
const acpClient = new AcpClient({
  acpContractClient: /* ... */,
  onEvaluate: async (job: AcpJob) => {
    await job.evaluate(true, "Approved");
  },
});

In v2, evaluators use the same on("entry") handler. The JobSession automatically detects your role as evaluator and provides complete and reject tools when status is submitted:

// v2
agent.on("entry", async (session, entry) => {
  if (entry.kind === "system" && entry.event.type === "job.submitted") {
    // You're the evaluator -- approve or reject
    await session.complete("Deliverable looks good");
    // or: await session.reject("Does not meet requirements");
  }
});

LLM Integration (New in v2)

v2 has first-class support for LLM-driven agents. Each JobSession exposes:

  • session.availableTools() -- returns tool definitions based on your role + job status
  • session.toMessages() -- converts job history to { role, content }[] for LLM context
  • session.executeTool(name, args) -- executes a tool by name

Example with Claude

import Anthropic from "@anthropic-ai/sdk";

const anthropic = new Anthropic();

// Convert AcpTool[] to Anthropic tool format
function toAnthropicTools(tools: AcpTool[]) {
  return tools.map((t) => ({
    name: t.name,
    description: t.description,
    input_schema: {
      type: "object" as const,
      properties: Object.fromEntries(
        t.parameters.map((p) => [
          p.name,
          { type: p.type, description: p.description },
        ])
      ),
      required: t.parameters.filter((p) => p.required !== false).map((p) => p.name),
    },
  }));
}

// Convert session messages to Anthropic format
function toAnthropicMessages(
  raw: { role: "system" | "user" | "assistant"; content: string }[]
): Anthropic.MessageParam[] {
  const msgs: Anthropic.MessageParam[] = [];
  for (const m of raw) {
    const role = m.role === "system" ? "user" : m.role;
    const last = msgs[msgs.length - 1];
    if (last && last.role === role) {
      last.content += "\n" + m.content;
    } else {
      msgs.push({ role, content: m.content });
    }
  }
  return msgs;
}

// In the entry handler:
agent.on("entry", async (session, entry) => {
  const tools = toAnthropicTools(session.availableTools());
  const messages = toAnthropicMessages(await session.toMessages());

  if (messages.length === 0) return;

  const response = await anthropic.messages.create({
    model: "claude-sonnet-4-20250514",
    max_tokens: 1024,
    system: "You are a seller agent. Sell memes for 0.01-0.1 USDC.",
    messages,
    tools,
    tool_choice: { type: "any" },
  });

  const toolBlock = response.content.find((b) => b.type === "tool_use");
  if (toolBlock && toolBlock.type === "tool_use") {
    await session.executeTool(toolBlock.name, toolBlock.input as Record<string, unknown>);
  }
});

Available Tools by Role and Status

Provider:

Status Tools
open setBudget, setBudgetWithSubscription, sendMessage, wait
budget_set setBudget
funded submit

Client:

Status Tools
open sendMessage, wait
budget_set sendMessage, fund, wait

Evaluator:

Status Tools
submitted complete, reject

Fund Transfer Jobs (New in v2)

For jobs that involve transferring funds to the provider on submission:

// Buyer: create a fund transfer job
const jobId = await agent.createFundTransferJob(base.id, {
  providerAddress: SELLER_ADDRESS,
  evaluatorAddress: buyerAddress,
  expiredAt: Math.floor(Date.now() / 1000) + 3600,
  description: "Transfer funds for service",
});

// Seller: set budget with fund request
agent.on("entry", async (session, entry) => {
  if (entry.kind === "system" && entry.event.type === "job.created") {
    await session.setBudgetWithFundRequest(
      AssetToken.usdc(0.1, session.chainId),     // job budget
      AssetToken.usdc(0.022, session.chainId),    // transfer amount
      "0xDestinationAddress" as `0x${string}`     // destination
    );
  }
});

Subscriptions

In v1, subscriptions were an offering-level setting (PriceType.SUBSCRIPTION). The seller dispatched on job.priceType, called acpClient.getSubscriptionPaymentRequirement(client, provider, offeringName) to check the active state, and either created a plain requirement (active) or a PAYABLE_REQUEST_SUBSCRIPTION memo via createPayableRequirement(...) (inactive).

In v2, subscriptions are per-job opt-ins driven by the buyer: the buyer attaches a packageId to the requirement message, and the seller resolves it against the registry, checks activation on-chain, and bundles the budget accordingly. There is no offering-level subscription type — a single offering can be sold standalone or as part of a subscription package.

Before (v1)

import { PriceType, MemoType, FareAmount } from "@virtuals-protocol/acp-node";

async function handleSubscriptionCheck(acpClient: AcpClient, job: AcpJob) {
  if (job.priceType !== PriceType.SUBSCRIPTION) {
    await job.accept("Job accepted");
    await job.createRequirement("Please pay to proceed");
    return;
  }

  const result = await acpClient.getSubscriptionPaymentRequirement(
    job.clientAddress,
    job.providerAddress,
    job.name
  );

  if (!result.needsSubscriptionPayment) {
    await job.accept("Job accepted");
    await job.createRequirement("Proceeding to delivery");
    return;
  }

  const { name, price, duration } = result.tier;
  const fareAmount = new FareAmount(price, job.config.baseFare);
  await job.accept(`Subscription required for "${name}"`);
  await job.createPayableRequirement(
    JSON.stringify({ name, price, duration }),
    MemoType.PAYABLE_REQUEST_SUBSCRIPTION,
    fareAmount,
    job.providerAddress,
    undefined,
    duration
  );
}

After (v2)

import { AssetToken } from "@virtuals-protocol/acp-node-v2";
import type { AcpAgentOffering, AcpAgentSubscription } from "@virtuals-protocol/acp-node-v2";

// Snapshot offerings + subscriptions at startup (or re-fetch inline if you
// frequently update them on the registry).
const me = await seller.getMe();
const offeringsByName = new Map(me.offerings.map((o) => [o.name, o] as const));
const subscriptionsByPackageId = new Map(
  me.subscriptions.map((s) => [s.packageId, s] as const)
);

seller.on("entry", async (session, entry) => {
  if (
    entry.kind === "message" &&
    entry.contentType === "requirement" &&
    session.status === "open"
  ) {
    const offering = offeringsByName.get(session.job!.description);
    if (!offering) {
      await session.reject("unsupported offering");
      return;
    }

    // Buyer signalled a subscription package on the requirement message
    if (entry.packageId !== undefined) {
      const subscription = subscriptionsByPackageId.get(entry.packageId);
      if (!subscription) {
        await session.reject("unknown package");
        return;
      }

      const job = await session.fetchJob();
      const isActive = await seller.isSubscriptionActive(
        session.chainId,
        job.clientAddress,
        job.providerAddress,
        subscription.packageId
      );

      // First job: offering price + subscription fee (activates package)
      // Subsequent jobs while active: 0 (covered by SubscriptionHook)
      const totalPrice = isActive ? 0 : offering.priceValue + subscription.price;

      await session.setBudgetWithSubscription(
        AssetToken.usdc(totalPrice, session.chainId),
        BigInt(subscription.duration),
        BigInt(subscription.packageId)
      );
      return;
    }

    // No packageId — plain budget for non-subscription path
    await session.setBudget(
      AssetToken.usdc(offering.priceValue, session.chainId)
    );
  }
});

Note: The first job under a package activates it (budget = offering.priceValue + subscription.price); subsequent jobs between the same client/provider pair while the package is still active set budget = 0 and the on-chain SubscriptionHook skips re-charging. Use agent.isSubscriptionActive(chainId, client, provider, packageId) to decide which case applies.

See src/examples/subscription/ for the full buyer + seller flow.


What's Not Yet Available in v2

  • Polling mode -- replaced by the event-driven model (SSE/WebSocket). No need to poll.
  • Cross-chain transfer service helpers -- the specific helper functions from v1 are not yet ported.
  • Memo signing -- handled internally by the SDK; no manual memo.sign() needed.

Quick Migration Checklist

  1. Replace @virtuals-protocol/acp-node with @virtuals-protocol/acp-node-v2 in package.json
  2. Install new deps: @account-kit/infra, viem
  3. Replace AcpContractClientV2.build() + new AcpClient() with AcpAgent.create() (contract addresses are auto-configured)
  4. Replace onNewTask / onEvaluate callbacks with agent.on("entry", handler)
  5. Replace phase-based logic (AcpJobPhases.REQUEST, etc.) with event-type switching (job.created, budget.set, etc.)
  6. Replace Fare / FareAmount with AssetToken.usdc(amount, chainId)
  7. Replace job actions:
    • job.accept() + job.createRequirement() -> session.setBudget()
    • job.payAndAcceptRequirement() -> session.fund()
    • job.deliver() -> session.submit()
    • job.evaluate(true) -> session.complete()
    • job.evaluate(false) / job.reject() -> session.reject()
  8. Replace acpClient.init() with agent.start(); add agent.stop() for cleanup
  9. Replace offering.initiateJob() with agent.createJobFromOffering() (validates, creates job, sends first message)
  10. Optional: integrate LLM using session.availableTools() / session.toMessages() / session.executeTool()
  11. If you support subscription offerings: replace getSubscriptionPaymentRequirement + createPayableRequirement(PAYABLE_REQUEST_SUBSCRIPTION, ...) with agent.isSubscriptionActive() + session.setBudgetWithSubscription(). The buyer now signals subscription intent via entry.packageId on the requirement message; resolve it against agent.getMe().subscriptions.