Skip to content

Handling long running agents #199

@wantpinow

Description

@wantpinow

Hi team, love the component and Convex more generally.

I'm building an application with a long running agent that sometimes takes a very long time to process a single user message in a thread (think cursor doing lots of tool calls). As a result, I occasionally hit the 10 minute action timeout.

First question: is there an existing/recommended solution to allow agents to run for > 10 minutes already? Given the 10 minute action constraint I assumed there was none.

If there isn't an existing solution, I have the following working prototype that could be used to facilitate this sort of behavior:


The code below shows a pattern for running agents beyond the 10-minute timeout by chaining actions together and continuing message threads mid-stream. The key idea:

  1. Stream the agent response and track step completions
  2. When approaching the time limit, schedule a continuation action with the same threadId and break out of the stream
  3. The continuation action cleans up any pending messages left from the interrupted stream, then calls streamText without a prompt to resume from the thread's last state

I've set maxActionTimeSeconds to 4 but in reality it would be something like 8 minutes.

import { components, internal } from "./_generated/api";
import {
  Agent,
  createThread,
  createTool,
  listMessages,
} from "@convex-dev/agent";
import { internalAction } from "./_generated/server";
import { z } from "zod";
import { v } from "convex/values";

export const test = createTool({
  description: "Run the test tool",
  args: z.object({ query: z.number().describe("The number to test") }),
  handler: async (_ctx, args, _options): Promise<string> => {
    return `test ${args.query}: result: ${Math.floor(Math.random() * 100)}`;
  },
});

const agent = new Agent(components.agent, {
  name: "My Agent",
  languageModel: "openai/gpt-4o-mini",
  instructions:
    "You are a testing agent. You must follow the users instructions exactly. You can only use the test tool. You **MUST NOT** use parallel tool calls at any point. You must only call the test tool sequentially.",
  tools: { test },
  maxSteps: 50,
});

const maxActionTimeSeconds = 10;

export const runThread = internalAction({
  args: v.object({
    threadId: v.optional(v.string()),
  }),
  handler: async (ctx, args) => {
    const startTime = Date.now();

    const prompt =
      "Test the test tool with the numbers 1-50. Then give me the total result of all the tests.";

    let threadId = args.threadId;
    console.log(`Running thread with id ${threadId ?? "none"}...`);
    const isContinuing = threadId !== undefined;
    if (!threadId) {
      // create a new thread
      threadId = await createThread(ctx, components.agent);
    } else {
      // continue an existing thread....

      // if we break a thread (below), we get a pending message:
      // { content: [], role: "assistant" }
      // so we delete it
      const messages = await listMessages(ctx, components.agent, {
        threadId,
        paginationOpts: { cursor: null, numItems: 1000 },
        statuses: ["pending"],
      });
      const pendingMessages = messages.page.filter(
        (message) => message.status === "pending"
      );
      await agent.deleteMessages(ctx, {
        messageIds: pendingMessages.map((message) => message._id),
      });
    }

    const result = await agent.streamText(
      ctx,
      { threadId },
      {
        // if we're continuing a thread, we start from the last step
        prompt: isContinuing ? undefined : prompt,
        model: "openai/gpt-4o-mini",
      },
      { saveStreamDeltas: { returnImmediately: true } }
    );

    for await (const streamPart of result.fullStream) {
      if (streamPart.type === "finish-step") {
        if (Date.now() - startTime > maxActionTimeSeconds * 1000) {
          // break out of the loop, continue the thread in another action
          await ctx.scheduler.runAfter(0, internal.agent.runThread, {
            threadId,
          });
          break;
        }
      }
    }

    return { threadId };
  },
});

I've run this myself and it seems to work - looking through the messages table I see the thread has been processed in the right way.


I'm going to keep testing this for my own use case but I was wondering if something like this is on your radar. It would feel magical if the component could do this out of the box without the user having to worry about these timeouts. If you think this could be done in a seamless way I'd be happy to collaborate.

Note: the big caveat here is that this approach only works if there is a steady stream of short lived tool calls. If one tool call (such as a tool call invoking a subagent) takes a long time, then things start to break.

Interested to hear your thoughts :)

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions