can1357/oh-my-pi · error · AgentBusyError

Agent is already processing. Use steer() or followUp() to qu

Error message

Agent is already processing. Use steer() or followUp() to queue messages, or wait for completion.

What it means

Agent.continue() throws AgentBusyError when this.#state.isStreaming is true, meaning a prompt/continuation loop is currently running (packages/agent/src/agent.ts:1216-1219). The Agent serializes runs: only one turn loop may be active at a time. The error message directs you to steer() or followUp() to inject messages into the running loop, or to await completion.

Source

Thrown at packages/agent/src/agent.ts:1218

		if (this.#abortController) signals.push(this.#abortController.signal);
		if (signal) signals.push(signal);
		if (this.#deadline !== undefined) {
			const delay = this.#deadline - Date.now();
			if (delay <= 0) {
				const controller = new AbortController();
				controller.abort(new DOMException("Deadline exceeded", "TimeoutError"));
				signals.push(controller.signal);
			} else {
				signals.push(AbortSignal.timeout(delay));
			}
		}
		if (signals.length === 0) return undefined;
		return signals.length === 1 ? signals[0] : AbortSignal.any(signals);
	}

	async continue(signal?: AbortSignal) {
		if (this.#state.isStreaming) {
			throw new AgentBusyError();
		}

		const { promise, resolve } = Promise.withResolvers<void>();
		this.#runningPrompt = promise;
		this.#resolveRunningPrompt = resolve;
		const continuationAbortController = new AbortController();
		this.#abortController = continuationAbortController;
		this.#state.isStreaming = true;
		this.#state.streamMessage = null;
		this.#state.error = undefined;

		try {
			const dequeueSignal = this.#continuationDequeueSignal(signal);
			const messages = this.#state.messages;
			if (messages.length === 0) {
				// An empty transcript has nothing to resume, but a queued steer/follow-up
				// must still be delivered as the opening turn — mirroring the assistant-tail
				// branch below. Throwing here leaves the message undeliverable, and idle-drain

View on GitHub (pinned to 9690622007)

Solutions

  1. Queue the message instead: use steer() (mid-run steering) or followUp() (next turn) rather than starting a new run.
  2. Await agent.waitForIdle() (or the promise returned by the in-flight prompt) before calling continue().
  3. Track run state at the call site with a mutex/queue so only one caller drives the Agent at a time.
  4. If you believe the agent is stuck, call agent.abort() first, wait for idle, then continue().

Example fix

// before
await agent.continue(); // throws AgentBusyError if a run is active

// after
await agent.waitForIdle();
// or, without waiting:
agent.followUp("take another look"); // queued into the running loop
Defensive patterns

Strategy: try-catch

Validate before calling

function isAgentIdle(agent: Agent): boolean {
  return !isStreaming(agent);
}
if (isAgentIdle(agent)) await agent.continue();

Try / catch

try {
  await agent.continue();
} catch (err) {
  if (err instanceof AgentBusyError) {
    agent.followUp(pendingMessage); // queue instead of racing
    return;
  }
  throw err;
}

Prevention

When it happens

Trigger: Calling agent.continue() (or prompt()) while a previous prompt()/continue() run has not finished — isStreaming is still true. Common with concurrent callers: an event handler and a retry timer both invoking continue(), or calling continue() inside a streaming event callback.

Common situations: Firing continue() on a timer or on every streamed event without checking idle state; retry logic that re-invokes continue() after an error while the original loop is still tearing down; UI button double-clicks; multiple async tasks sharing one Agent instance.

Related errors


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/d9dbb013cbc91608. Report an issue: GitHub.