Skip to content

refactor(agent): introduce durable run core#289

Draft
robelest wants to merge 1 commit into
get-convex:mainfrom
robelest:robel/agentV2
Draft

refactor(agent): introduce durable run core#289
robelest wants to merge 1 commit into
get-convex:mainfrom
robelest:robel/agentV2

Conversation

@robelest

@robelest robelest commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

Agent V2 is a breaking refactor that makes Agent a Convex-native durable execution primitive.

Agent now owns threads, messages, runs, tools, approvals, usage, structured output, cancellation, and run event streams. @convex-dev/stream stores ordered run events. Apps compose auth, files, RAG, rate limits, billing, workflows, and provider SDKs outside Agent.

Install Preview

npm i https://pkg.pr.new/robelest/agent/@convex-dev/agent@f41be5f

Publint: https://publint.dev/pkg.pr.new/robelest/agent/@convex-dev/agent@f41be5f

Demo: https://tame-bloodhound-220.convex.site

Core API

const supportAgent = new Agent(components.agent, {
  name: "Support Agent",
  model: supportModel,
  output: v.optional(v.object({
    category: v.string(),
    confidence: v.number(),
  })),
});

Main namespaces:

agent.threads.create(...)
agent.threads.get(...)
agent.threads.list(...)
agent.threads.update(...)

agent.messages.save(...)
agent.messages.list(...)

agent.runs.start(...)
agent.runs.send(...)
agent.runs.execute(...)
agent.runs.cancel(...)
agent.runs.get(...)
agent.runs.list(...)
agent.runs.link(...)

agent.tool.list(...)
agent.tool.approve(...)
agent.tool.deny(...)

agent.events.read(...)
agent.events.readBatch(...)

agent.http(ctx, request, { runId })

Provider-Agnostic Models

Agent core no longer imports AI SDK types. Apps provide an AgentModel that yields Agent-owned events.

export const supportModel = defineAgentModel({
  async *execute(request) {
    yield { type: "text.delta", text: "Hello from Agent core." };
    yield {
      type: "usage",
      usage: { inputTokens: 10, outputTokens: 6, totalTokens: 16 },
    };
    yield { type: "done" };
  },
});

OpenRouter in the demo is app-owned provider composition, not core Agent API.

Durable Runs

Start creates durable intent. Execute advances the run in an action. The browser should call a mutation; provider work stays in scheduled/internal actions.

export const send = mutation({
  args: {
    threadId: v.string(),
    prompt: v.string(),
    clientKey: v.string(),
  },
  handler: async (ctx, args) => {
    const run = await supportAgent.runs.start(ctx, {
      threadId: args.threadId,
      prompt: args.prompt,
      key: `client-message:${args.clientKey}`,
    });

    await ctx.scheduler.runAfter(0, internal.support.executeRun, {
      runId: run.runId,
    });

    return run;
  },
});

export const executeRun = internalAction({
  args: { runId: v.string() },
  handler: async (ctx, { runId }) => {
    await supportAgent.runs.execute(ctx, { runId });
  },
});

Runs persist lifecycle, usage, output, result message IDs, cancellation, and stream correlation.

Tools And Approvals

Tools are Agent-native. Approval state is projected into bounded internal rows, so approval queries do not replay full streams.

const refundPayment = defineTool({
  description: "Refund a customer payment.",
  input: v.object({ paymentId: v.string() }),
  needsApproval: true,
  execute: async (input, context) => {
    if (context.signal?.aborted) {
      throw new Error("Canceled");
    }
    return { refunded: input.paymentId };
  },
});

await supportAgent.tool.approve(ctx, {
  runId,
  toolCallId,
});

await supportAgent.tool.deny(ctx, {
  runId,
  toolCallId,
  reason: "Needs manager review.",
});

React API

The primary React surface is useAgent. Stream stays internal to Agent users.

const agent = useAgent(
  {
    listMessages: api.support.listMessages,
    listRuns: api.support.listRuns,
    readRunEventsBatch: api.support.readRunEventsBatch,
    send: api.support.send,
    cancel: api.support.cancel,
    approveToolCall: api.support.approveToolCall,
    denyToolCall: api.support.denyToolCall,
  },
  { caseId },
  { initialNumMessages: 50 },
);

agent.timeline;
agent.status;
agent.activeRun;
agent.usage;
agent.usageTotal;
agent.output;

await agent.send({ prompt: "Help me with this issue." });
await agent.cancel({ runId: agent.activeRun.runId, reason: "User stopped" });

useAgent combines Convex realtime messages, paginated history, Stream-backed run events, optimistic sends, approvals, usage, output, and cancellation.

HTTP Run Streams

Agent serves run events through Stream’s HTTP protocol without exposing nested Stream component refs.

export const http = httpRouter();

http.route({
  path: "/agent/run",
  method: "GET",
  handler: httpAction(async (ctx, request) => {
    const runId = new URL(request.url).searchParams.get("runId");
    await authorizeRun(ctx, runId);
    return await supportAgent.http(ctx, request, { runId });
  }),
});

App-Owned Composition

Agent V2 deliberately moves cross-cutting product concerns out of the component. Agent should feel like an unopinionated primitive: it records durable agent work and exposes that work through runs, messages, tools, approvals, usage, output, and events. The app decides who may call it, what context to load, where files live, how retrieval works, when work is rate-limited, how workflows orchestrate it, and how usage becomes billing.

Auth/session stays in the app. Agent receives the already-authorized user/thread boundary.

const user = await requireUser(ctx);
const supportCase = await requireCaseAccess(ctx, args.caseId, user.userId);

const run = await supportAgent.runs.start(ctx, {
  threadId: supportCase.threadId,
  userId: user.userId,
  prompt: args.prompt,
  key: `client-message:${args.clientKey}`,
});

Files stay in app storage. Agent messages keep typed file references, but Agent does not own file tables, dedupe, refcounts, parsing, or cleanup.

const storageId = await ctx.storage.store(args.file);
const fileId = await ctx.db.insert("files", {
  storageId,
  ownerId: user.userId,
  filename: args.filename,
  mediaType: args.mediaType,
  text: extractedText,
});

await supportAgent.messages.save(ctx, {
  threadId: supportCase.threadId,
  messages: [{
    author: { type: "user", userId: user.userId },
    content: [{
      type: "file",
      fileId,
      mediaType: args.mediaType,
      filename: args.filename,
    }],
  }],
});

Context replaces Agent-owned memories/vector tables. Retrieval is an app policy that produces AgentContextBlock[] for runs.execute.

const fileContext: AgentContextLoader = async (ctx, { run }) => {
  const files = await loadFilesForRun(ctx, run.runId);
  return files.map((file) => ({
    type: "text",
    name: file.filename,
    text: file.text,
    metadata: { fileId: file._id, source: "file" },
  }));
};

await supportAgent.runs.execute(ctx, {
  runId,
  context: [fileContext],
});

RAG stays app-owned. The app can use @convex-dev/rag, OpenRouter embeddings, or any retriever, then pass search results as context.

const supportContext: AgentContextLoader = async (ctx, { promptMessage }) => {
  const results = await rag.search(ctx, {
    namespace: "support-docs",
    query: textFromMessage(promptMessage),
    limit: 5,
  });

  return results.map((result) => ({
    type: "text",
    name: result.title,
    text: result.text,
    metadata: { source: "support-rag", id: result.id },
  }));
};

Rate limits stay app-owned. The app checks quota before starting or executing Agent work.

const sendQuota = await rateLimiter.limit(ctx, "sendMessage", {
  key: user.userId,
});
if (!sendQuota.ok) {
  throw new ConvexError({
    code: "rateLimited",
    retryAfter: sendQuota.retryAfter,
  });
}

const run = await supportAgent.runs.start(ctx, {
  threadId: supportCase.threadId,
  userId: user.userId,
  prompt: args.prompt,
});

Workflows orchestrate Agent; Agent does not own workflow semantics. The app links external workflow IDs to runs when useful.

export const supportWorkflow = workflow.define({
  args: { runId: v.string() },
  handler: async (step, { runId }) => {
    await step.runAction(internal.support.executeRun, { runId });
  },
});

await supportAgent.runs.link(ctx, {
  runId,
  workflowId,
});

Billing stays app-owned. Agent records usage on the run; the app turns usage into invoices, credits, or ledger entries.

const run = await supportAgent.runs.get(ctx, { runId });

await ctx.db.insert("usageLedger", {
  userId: run.userId,
  runId: run.runId,
  inputTokens: run.usage?.inputTokens ?? 0,
  outputTokens: run.usage?.outputTokens ?? 0,
  totalTokens: run.usage?.totalTokens ?? 0,
  createdAt: Date.now(),
});

Demo

The example is now a support-case app:

  • per-tab demo session
  • app-owned file upload and text extraction
  • app-owned RAG using @convex-dev/rag
  • app-owned rate limiting
  • OpenRouter provider integration behind server env
  • live run streaming through useAgent
  • static hosting deployment

Validation

  • vp run typecheck
  • npm run lint
  • vp exec vitest run
  • npm run build
  • git diff --check
  • npm run deploy:demo:dev

All PR checks are currently green.

Notes

This PR is intentionally large. It is the clean-break Agent V2 branch, not a compatibility patch over the AI SDK/UIMessage-centered implementation.

Comment thread .github/workflows/preview.yml Fixed
Comment thread .github/workflows/preview.yml Fixed
@robelest robelest force-pushed the robel/agentV2 branch 2 times, most recently from 07d4c51 to 7126a06 Compare June 27, 2026 19:06
@pkg-pr-new

pkg-pr-new Bot commented Jun 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/get-convex/agent/@convex-dev/agent@6c47f29

commit: 6c47f29

@robelest robelest force-pushed the robel/agentV2 branch 3 times, most recently from d1e4447 to 1c7134f Compare June 29, 2026 15:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants