cloudflare / cloudflare/agents

Potential performance improvements found while profiling Think agent responses

Open
#1,942 0 comments 0 reactions 1 assignee Claimed by @threepointone View on GitHub
enhancement
Dominant language
TypeScript
Stars
5.6k
Forks
711
Avg merge
1d 20h
Merged PRs (30d)
53

Description

This is not a bug report, but while recording CPU and memory profiles as Think agents replied to messages, I found [this probable performance improvement](https://github.com/cloudflare/agents/issues/1938#issuecomment-4969529677). After reviewing the rest of the CPU profile, I found two more.

**Feel free to discard the proposed solution entirely, but I hope this data helps with investigations.**

Image

--- (ai report below) ---

## Problem

Sub-agent broadcasts repeatedly:

- Deserialize WebSocket attachments through `tryGetPartyServerMeta`.
- Parse the same URL through `parseSubAgentPath`.
- Convert exported class names through `camelCaseToKebabCase`.

The CPU profile attributed approximately **470 ms of sampled self CPU time** to these three functions:

| Function | Estimated self CPU |
|---|---:|
| `tryGetPartyServerMeta` | ~182 ms |
| `parseSubAgentPath` | ~157 ms |
| `camelCaseToKebabCase` | ~132 ms |

This work repeats for each connection during broadcasts.

## Hot Path

The dominant call path is:

```text
broadcast
→ _cf_broadcastToSubAgent
→ iterate hibernating connections
→ tryGetPartyServerMeta
→ _cf_subAgentTargetPath
→ _cf_subAgentPathFromOuterUri
→ parseSubAgentPath
→ resolveClassName
→ camelCaseToKebabCase
```

## Current Behavior

`parseSubAgentPath` currently performs:

```js
const parts = new URL(url).pathname.split("/").filter(Boolean);
```

For each parse, this:

- Constructs a `URL`.
- Splits the pathname into an array.
- Filters the array.
- Scans for the `sub` marker.
- Slices and joins the remaining segments.
- Scans every known class.
- Converts class names to kebab case during the scan.

Nested sub-agent paths cause progressively shorter versions of the same URL to be parsed again.

`tryGetPartyServerMeta` currently calls:

```js
WebSocket.prototype.deserializeAttachment.call(ws);
```

This bypasses PartyServer's existing `AttachmentCache`. Consequently, every hibernating connection scan deserializes each attachment again, and the lazy connection wrapper may deserialize it again on first access.

## Proposed Improvement

1. Cache the parsed sub-agent target per connection and invalidate it when the outer URI changes.
2. Precompute a `Map` instead of converting and scanning every exported class during each parse.
3. Reuse PartyServer’s attachment cache in `tryGetPartyServerMeta`.
4. Optionally reduce `parseSubAgentPath` allocations from `split`, `filter`, `slice`, and `join`.

The primary improvement should come from caching repeated routing work, not from micro-optimizing the string conversion alone.

1. Cache parsed targets per connection

```js
const subAgentTargets = new WeakMap();

function getSubAgentTarget(connection, outerUri, classLookup) {
const cached = subAgentTargets.get(connection);

if (cached?.outerUri === outerUri) {
return cached.target;
}

const target = parseSubAgentPath(outerUri, { classLookup });

subAgentTargets.set(connection, {
outerUri,
target,
});

return target;
}
```

This should provide the largest improvement because routing metadata is generally stable while broadcasts happen frequently.

2. Precompute the class lookup

Replace repeated `.find()` and case conversion with a map created once per stable `ctx.exports` value.

```js
function createSubAgentClassLookup(knownClasses) {
return new Map(
knownClasses.map((className) => [
camelCaseToKebabCase(className),
className,
]),
);
}
```

Lookup then becomes:

```js
const childClass = classLookup.get(classSegment);
```

This changes class resolution from approximately `O(exported classes)` per parsed level to `O(1)`.

Class-name collisions should be detected while creating the map.

3. Reduce parsing allocations

A regex-based scanner can avoid the intermediate arrays:

```js
const SUB_AGENT_PATH_REGEX =
/(?:^|\/)sub\/([^/]+)\/([^/]+)(?=\/|$)/g;

function parseSubAgentPath(url, options = {}) {
const pathname = new URL(url).pathname;

// Shared global regexes are stateful when used with exec().
SUB_AGENT_PATH_REGEX.lastIndex = 0;

let match;

while ((match = SUB_AGENT_PATH_REGEX.exec(pathname)) !== null) {
const [, classSegment, nameSegment] = match;
const childClass = options.classLookup?.get(classSegment);

if (!childClass) continue;

try {
return {
childClass,
childName: decodeURIComponent(nameSegment),
remainingPath:
pathname.slice(match.index + match[0].length) || "/",
};
} catch {
// Continue in case a later valid sub-agent segment exists.
}
}

return null;
}
```

This also preserves repeated and trailing slashes instead of collapsing them through `.filter(Boolean)`.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.