Skip to content

Bug: tailAgentToolRun ReadableStream trips cross-DO I/O in same-isolate facet #1445

@harshil1712

Description

@harshil1712

Versions in production: agents@0.12.1 · partyserver@0.5.5 · compatibility_date = "2026-01-28" · compatibility_flags = ["nodejs_compat"].

TL;DR: #1410 fixed _cf_initAsFacet but left the same cross-DO I/O bug in runAgentTooltailAgentToolRun. When a parent and child facet share an isolate, the native ReadableStream returned by tailAgentToolRun() triggers "Cannot perform I/O on behalf of a different Durable Object (I/O type: Native)".

Summary

PR #1393 / #1410 fixed the cross-DO DurableObjectId IoContext binding in _cf_initAsFacet. That fix allowed facet bootstrapping to succeed. However, the subsequent step inside runAgentTool — live-streaming tool output via tailAgentToolRun — still creates a native ReadableStream in the child facet and passes it back to the parent. The parent then calls .getReader() and .read() on it, which the runtime rejects when both DOs share the same isolate.

This is a different code path from #1410:

Exact error

Error: Cannot perform I/O on behalf of a different Durable Object.
I/O objects (such as streams, request/response bodies, and others) created
in the context of one Durable Object cannot be accessed from a different
Durable Object in the same isolate. This is a limitation of Cloudflare
Workers which allows us to improve overall performance.
(I/O type: Native)

Reproduction

Minimal parent that delegates to a Think subclass facet via runAgentTool:

// Parent Agent
class AgentClaw extends Think<Env> {
  async runSubAgent() {
    const result = await this.runAgentTool(SubAgentFacet, {
      input: {
        task: "Write a haiku about clouds",
        system_prompt: "You are a poet."
      }
    });
    return result;
  }
}

// Child facet
class SubAgentFacet extends Think<Env> {
  // default Think.tailAgentToolRun returns a native ReadableStream
}

When runAgentTool executes:

  1. subAgent(SubAgentFacet, runId) boots the facet ✅ (since Bug: _cf_initAsFacet throws "Cannot perform I/O on behalf of a different Durable Object" on first this.name read after the #1393 / 0.11.6 fix #1410)
  2. adapter.startAgentToolRun() injects the message and starts inference ✅
  3. adapter.tailAgentToolRun() is called in the child
  4. Child returns a native ReadableStream
  5. Parent calls stream.getReader() in _forwardAgentToolStream ❌ → throws

The call path in agents:

https://github.com/cloudflare/agents/blob/main/packages/agents/src/index.ts#L3339-L3341

if (adapter.tailAgentToolRun) {
  const stream = await adapter.tailAgentToolRun(runId, { afterSequence: -1 });
  sequence = await this._forwardAgentToolStream(stream, ...);
}

https://github.com/cloudflare/agents/blob/main/packages/agents/src/index.ts#L3564

const reader = stream.getReader();

Suggested fix

The runAgentTool orchestrator should detect when the stream read fails with a cross-DO I/O error and automatically fall back to getAgentToolChunks — which returns plain serializable objects that don't carry native I/O handles.

Alternatively, Think.tailAgentToolRun could return a wrapper that streams over RPC calls instead of a native ReadableStream, similar to how _cf_initAsFacet was fixed by moving from stub.fetch() to RPC.

Affected versions

  • agents@0.12.0
  • agents@0.12.1
  • @cloudflare/think@0.5.0
  • @cloudflare/think@0.5.1

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions