cloudflare / cloudflare/agents
Potential performance improvements found while profiling Think agent responses
- 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.**
--- (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
Assessment
This issue has not been assessed yet.