Repository navigation
feat(agents): agents/tools/testing toolbox + deployed pi harness OOM/CPU/wall-time e2e - #2421
mattzcarey wants to merge 3 commits into
Conversation
🦋 Changeset detectedLatest commit: 71035dd The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
🟢 agents import sizes: 2 entry points changed, no growth
Changed exports (10)
How this worksEach runtime export is bundled on its own, minified, and gzipped. Changes smaller than 100 B, or smaller than 1% and 1 KiB, are ignored. Growth over 10% or 5 KiB is marked 🔴. This report is informational and does not fail CI. The workflow artifact contains every measurement. Compared |
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
createTestingTools() returns AI SDK tools for breaking an agent's Durable Object on purpose: sleep, get_current_time, oom (allocate until the 128 MB isolate limit), and burn_cpu (spin without yielding until the CPU limit). testingPrompts/longRunningPrompt drive a 30-minute sleep loop and the destructive tools.
…i harness e2e agents/tools/testing is now dependency-free: fillMemory (fill a buffer until the isolate OOMs), burnCpu (hash a fetch() body of generateBytes() until the CPU limit), sleep, currentTime, and mid-turn prompts that end in a checkable secondary task. agents/tools/testing/ai wraps them as AI SDK tools. examples/next/harnesses/pi gains a deployed e2e suite, run nightly: an OOM and a CPU-limit kill mid-turn, and a 30-minute turn past the alarm's 15-minute wall time. Each agent reports to a separate results object, so the suite never wakes the agent and recovery must come from the harness's wake alarm.
a43d449 to
23b69b7
Compare
| export function createTestingTools( | ||
| options: CreateTestingToolsOptions = {} | ||
| ): TestingTools { | ||
| const bytes = options.bytes ?? (async () => generateBytes()); |
There was a problem hiding this comment.
🔴 Local CPU injection blocks cancellation
Locally, the default burn_cpu stream never yields to an abort scheduled by a timer. generateBytes pulls synchronously, so the endless read loop monopolizes microtasks and wedges the worker.
Learn more
The default AI SDK tool uses an in-process endless stream. Its pull callback enqueues immediately in generateBytes, and burnCpu loops on resolved reads without giving timers another turn. A signal aborted by a timer cannot fire during the loop. On local workerd, there is no CPU limit to terminate it.
Example: In a local test, call createTestingTools().burn_cpu.execute with a signal scheduled to abort after one second. The timeout never runs; the tool never rejects and the test runner remains blocked.
Recommended fix: Make the default stream periodically yield to a macrotask, or require an asynchronous byte source for the AI SDK default. Verify that a scheduled abort can execute with no external bytes callback and that deployed CPU consumption remains sufficient to trigger the limit.
Was this helpful? React with 👍 or 👎 to provide feedback.
| for (const byte of value) { | ||
| for (let round = 0; round < BURN_ROUNDS_PER_BYTE; round++) { | ||
| checksum = Math.imul(checksum ^ byte, 0x01000193); | ||
| } |
There was a problem hiding this comment.
🟡 CPU checksum differs from FNV-1a
For nonempty bodies, burnCpu hashes each byte 32 times instead of once. Its reported checksum differs from the documented FNV-1a hash, breaking comparisons with expected hashes.
Learn more
The exported BurnCpuResult describes checksum as an FNV-1a hash of the byte stream. FNV-1a processes each input byte once; the extra rounds change its value even for a one-byte body. The CPU-load objective can remain independent of the returned checksum.
Example: For a response containing one byte 0x00, conventional FNV-1a updates the initial value once. This implementation updates it 32 times, so its result does not match the expected hash.
Recommended fix: Maintain a separate standard FNV-1a checksum updated once per byte and a separate accumulator for the extra CPU rounds, or rename and document the returned value as a nonstandard stress-test checksum. Add a known-vector test.
Was this helpful? React with 👍 or 👎 to provide feedback.
| response: Response, | ||
| signal?: AbortSignal | ||
| ): Promise<BurnCpuResult> { | ||
| signal?.throwIfAborted(); |
There was a problem hiding this comment.
🟡 Pre-aborted CPU streams remain open
With an already aborted signal, burnCpu rejects before canceling response.body. A streamed response stays open despite the cancellation contract.
Learn more
burnCpu promises to cancel the response body on abort. Its initial abort check runs before obtaining a reader or installing its abort listener, so it leaves an already open streaming response uncanceled.
Example: Fetch an endless /bytes response, abort the signal, then call burnCpu(response, signal). The call rejects immediately, but nothing cancels the fetched response body.
Recommended fix: When the signal is already aborted, cancel response.body before rejecting, preserving the abort reason. Cover the pre-aborted case with a stream whose cancel callback records invocation.
Was this helpful? React with 👍 or 👎 to provide feedback.
| export function createTestingTools( | ||
| options: CreateTestingToolsOptions = {} | ||
| ): TestingTools { | ||
| const bytes = options.bytes ?? (async () => generateBytes()); |
There was a problem hiding this comment.
🔍 Description and toolbox API diverge
The description advertises a root-level AI adapter, get_current_time, and bounded local crash tools. The implementation uses a separate adapter subpath, current_time, and unbounded local fault injection. Update the description to match the published API.
Was this helpful? React with 👍 or 👎 to provide feedback.
…harness Local: crash the object mid-stream with abortAllDurableObjects(); the wake alarm resumes it and pi regenerates the answer, keeping the streamed part as an aborted message. Deployed: ctx.abort() while the model streams, ctx.abort() 15s into a replay-safe sleep, and a redeploy 15s into the same sleep. Each agent reports progress to ChaosResults so the suite can time the interruption without waking the agent.
| await waitForProgress(agent, "generating", 3 * MINUTE); | ||
| await kill(agent); |
| await waitForProgress(agent, "generating", 3 * MINUTE); | ||
| await kill(agent); |
Adds
agents/tools/testing, a toolbox for breaking an agent's Durable Object mid-turn, and a nightly deployed e2e suite that uses it against the pi harness (examples/next/harnesses/pi).The toolbox
agents/tools/testinghas no dependencies, so any harness can wrap it in its own tool format.agents/tools/testing/aiwraps it as AI SDK tools (sleep,current_time,oom,burn_cpu).burnCpuhashes each byte 32 times, so it stays CPU-bound even though every read waits on I/O. CPU spent between reads counts toward the limit.longRunningPrompt()loopssleep 60/current_timeuntil 30 minutes pass.crashPrompt("oom" | "burn_cpu")callscurrent_time, the crash tool once, then the secondary task. Both end withsecondaryTask: one morecurrent_timecall andANSWER: 893.Deployed pi harness e2e (nightly)
sequenceDiagram participant T as deployed.test.ts participant W as Worker (throwaway) participant A as PiChaosAgent (pi harness) participant R as ChaosResults T->>W: POST /e2e/:agent/submit W->>A: submit(prompt) Note over A: current_time → crash / reset / redeploy<br/>wake alarm restarts it, pi recovers<br/>current_time → ANSWER: 893 A->>R: progress (generating, tool starts) and final status loop every 10s T->>W: GET /e2e/:agent/result W->>R: get() endctx.abort()(a runtime reset) while the model streams and 15 s into a replay-safesleep 60, and a redeploy at the same point.harness.test.tsadds the deterministic local version:abortAllDurableObjects()mid-stream.killrequest. Polling the agent would start a new instance and resume pi, so the client would do the recovery. The agent pushes progress and its outcome toChaosResultsinstead, so recovery has to come from the harness's wake alarm.storage.sync()before the crash. A second call would be another memory-limit strike toward Lifecycle sealing the object's recovery jobs.burn_cpureads from the Worker's own/bytesroute throughctx.exports.default.e2e-deployed-pi-harnessjob innightly.yml(75 min timeout).PI_E2E_SCENARIOS=oom,burn_cpuruns a subset.sleepandcurrent_timemoved tosrc/tools.tsso the e2e Worker reuses them.Results from runs against a real account:
current_time,oom⚠ interrupted,current_time,ANSWER: 893current_time,burn_cpu⚠ interrupted,current_time,ANSWER: 893ctx.abort()while the model streams)current_time,ANSWER: 893ctx.abort()15 s intosleep 60)sleep, rerun with its memoized deadline,current_time,ANSWER: 893sleep 60)sleep 60,current_time), thencurrent_time,ANSWER: 893wrangler tailon theburn_cpurun shows the alarm invocation stopped at 32.5 s CPU (the 30 s actor top-up plus 2.5 s rollover). Each recovery came from the next 30 s heartbeat alarm.