Back to the library
Agent loop, thrashing
Variant: Thrashing
The same three boxes with the gain too high. Every correction overshoots further than the one before, the swing alternates sides and grows, and on the fourth lap it slams into the rail and the step budget runs out — rust, then a dead beat exactly one lap long. Worth showing beside the converging variant: the structure is identical, which is the whole point.
errordiagramllmloop
The source
A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Agentic loop, thrashing</title>
<style>
:root {
/* Neutrals — fixed, never remixed. */
--ivory: #faf9f5;
--paper: #ffffff;
--oat: #e3dacc;
--slate: #141413;
/* Knobs — the remix API. A knob exists iff declared here and used below. */
--hue: 38.8;
--lift: 0;
--chroma: 1;
--speed: 1;
/* Accents — derived from the knobs with the family's offsets baked in,
so one hue roll moves them together. Defaults reproduce the original hex. */
--clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
--clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
--olive: oklch(
calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
);
--rust: oklch(
calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
);
/* The caption gray sits off the neutral ramp — no mix of ivory and
slate lands on it — so it stays a literal. */
--_muted: #87867f;
/* Clock — every duration in the document runs off --t, and the node
delays divide by the same --speed, so the think/act/observe beat
keeps its spacing under any pace roll. */
--cycle: 5s;
--t: calc(var(--cycle) / var(--speed));
--_calm: calc(9s / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-accent: cubic-bezier(0.65, 0, 0.35, 1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--ivory);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
color: var(--slate);
}
/* Fluid rather than a hard 264px: the whole diagram is one SVG, so letting
the container take the stage width scales the 10-11px labels up with it
instead of leaving them sub-legible on a phone. Caps at 400px so a wide
stage cannot blow it up, and at 100% so a narrow one can never clip it. */
.loop {
width: min(400px, 100%);
}
.loop svg {
display: block;
width: 100%;
height: auto;
}
.box {
fill: var(--ivory);
stroke: var(--oat);
stroke-width: 1.5;
}
.box-on {
fill: color-mix(in srgb, var(--clay) 10%, transparent);
stroke: var(--clay);
stroke-width: 1.5;
opacity: 0;
}
.label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.07em;
fill: var(--slate);
text-anchor: middle;
}
.wire {
fill: none;
stroke: var(--oat);
stroke-width: 1.5;
}
.head {
fill: var(--oat);
}
/* Four laps of 1s, then a full second of nothing. The cycle is 5s so the
dead beat at the end is the same length as a lap — you are meant to
notice the lap that does not happen. */
.node {
animation: node-lift var(--t) infinite;
animation-delay: var(--_d);
}
.node .box-on {
animation: node-on var(--t) infinite;
animation-delay: var(--_d);
}
.n-think {
--_d: 0s;
transform-origin: 40px 38px;
}
.n-act {
--_d: calc(0.3s / var(--speed));
transform-origin: 132px 38px;
}
.n-observe {
--_d: calc(0.62s / var(--speed));
transform-origin: 224px 38px;
}
@keyframes node-on {
0% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
1.6%,
2.8% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
4.4% {
opacity: 0;
}
20% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
21.6%,
22.8% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
24.4% {
opacity: 0;
}
40% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
41.6%,
42.8% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
44.4% {
opacity: 0;
}
60% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
61.6%,
62.8% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
64.4%,
100% {
opacity: 0;
}
}
@keyframes node-lift {
0% {
transform: scale(1);
animation-timing-function: var(--ease-out);
}
1.6%,
2.8% {
transform: scale(1.03);
animation-timing-function: var(--ease-out);
}
4.4% {
transform: scale(1);
}
20% {
transform: scale(1);
animation-timing-function: var(--ease-out);
}
21.6%,
22.8% {
transform: scale(1.03);
animation-timing-function: var(--ease-out);
}
24.4% {
transform: scale(1);
}
40% {
transform: scale(1);
animation-timing-function: var(--ease-out);
}
41.6%,
42.8% {
transform: scale(1.03);
animation-timing-function: var(--ease-out);
}
44.4% {
transform: scale(1);
}
60% {
transform: scale(1);
animation-timing-function: var(--ease-out);
}
61.6%,
62.8% {
transform: scale(1.03);
animation-timing-function: var(--ease-out);
}
64.4%,
100% {
transform: scale(1);
}
}
.token {
fill: var(--clay);
animation: token var(--t) linear infinite;
}
/* Corners stay linear — a signal on a wire, not an object with weight.
The return leg gets 210ms for its 184px rather than 100ms: a forward
hop covers 92px in 180ms, so the way back is still visibly the quick
leg without crossing into a skipped frame. */
@keyframes token {
0% {
transform: translate(0px, 0px);
opacity: 0;
}
1.2% {
opacity: 1;
}
2.4% {
transform: translate(0px, 0px);
animation-timing-function: var(--ease-accent);
}
6%,
8.8% {
transform: translate(92px, 0px);
}
12.4%,
14% {
transform: translate(184px, 0px);
}
15% {
transform: translate(184px, 34px);
}
19.2% {
transform: translate(0px, 34px);
animation-timing-function: var(--ease-out);
}
20%,
22.4% {
transform: translate(0px, 0px);
animation-timing-function: var(--ease-accent);
}
26%,
28.8% {
transform: translate(92px, 0px);
}
32.4%,
34% {
transform: translate(184px, 0px);
}
35% {
transform: translate(184px, 34px);
}
39.2% {
transform: translate(0px, 34px);
animation-timing-function: var(--ease-out);
}
40%,
42.4% {
transform: translate(0px, 0px);
animation-timing-function: var(--ease-accent);
}
46%,
48.8% {
transform: translate(92px, 0px);
}
52.4%,
54% {
transform: translate(184px, 0px);
}
55% {
transform: translate(184px, 34px);
}
59.2% {
transform: translate(0px, 34px);
animation-timing-function: var(--ease-out);
}
60%,
62.4% {
transform: translate(0px, 0px);
animation-timing-function: var(--ease-accent);
}
66%,
68.8% {
transform: translate(92px, 0px);
}
72.4%,
74% {
transform: translate(184px, 0px);
}
75% {
transform: translate(184px, 34px);
}
79.2% {
transform: translate(0px, 34px);
animation-timing-function: var(--ease-out);
}
80%,
84% {
transform: translate(0px, 0px);
opacity: 1;
}
88%,
100% {
transform: translate(0px, 0px);
opacity: 0;
}
}
.track {
fill: var(--oat);
}
.err,
.err-fail {
transform-origin: 132px 114px;
}
.err {
fill: var(--clay);
animation:
err-swing var(--t) infinite,
cycle-vis var(--t) infinite;
}
.err-fail {
fill: var(--rust);
opacity: 0;
animation:
err-swing var(--t) infinite,
fail-in var(--t) infinite;
}
.state,
.state-fail {
stroke: var(--ivory);
stroke-width: 2;
}
.state {
fill: var(--clay);
animation:
state-swing var(--t) infinite,
cycle-vis var(--t) infinite;
}
.state-fail {
fill: var(--rust);
opacity: 0;
animation:
state-swing var(--t) infinite,
fail-in var(--t) infinite;
}
/* Every correction overshoots the last by about a third more, so the
swing grows instead of settling — a gain above one, which is what
thrashing actually is. The fourth lands on the rail and stops there. */
@keyframes err-swing {
0%,
12.4% {
transform: scaleX(-0.42);
animation-timing-function: var(--ease-out);
}
16.4%,
32.4% {
transform: scaleX(0.6);
animation-timing-function: var(--ease-out);
}
36.4%,
52.4% {
transform: scaleX(-0.85);
animation-timing-function: var(--ease-out);
}
56.4%,
72.4% {
transform: scaleX(1);
animation-timing-function: var(--ease-out);
}
76.4%,
100% {
transform: scaleX(-1);
}
}
@keyframes state-swing {
0%,
12.4% {
transform: translateX(-39px);
animation-timing-function: var(--ease-out);
}
16.4%,
32.4% {
transform: translateX(55px);
animation-timing-function: var(--ease-out);
}
36.4%,
52.4% {
transform: translateX(-78px);
animation-timing-function: var(--ease-out);
}
56.4%,
72.4% {
transform: translateX(92px);
animation-timing-function: var(--ease-out);
}
76.4%,
100% {
transform: translateX(-92px);
}
}
@keyframes cycle-vis {
0% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
2%,
94% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
97%,
100% {
opacity: 0;
}
}
/* Rust arrives on the halt, not before: the loop was fine-looking right
up until it ran out of steps. */
@keyframes fail-in {
0%,
80% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
82%,
94% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
98%,
100% {
opacity: 0;
}
}
.goal {
stroke: var(--slate);
stroke-width: 2;
}
.caption {
font-size: 10px;
letter-spacing: 0.1em;
fill: var(--_muted);
text-anchor: middle;
}
@media (prefers-reduced-motion: reduce) {
/* Gentler, not absent. The halt is still what is held — error at full
width on the wrong side of the goal, marker pinned at the far rail,
both in rust, with the mid-run clay pair dropped because it has
nothing to say once the run is over — but it breathes rather than
freezing. All three steps light in sequence on the same slow 9s
crossfade the other two variants use; the loop keeps turning while
the readout stays stuck out at the rail, which is the argument.
Opacity only — no swing, no travel, no token. */
.node,
.token,
.err,
.state {
animation: none;
}
.node .box-on {
animation: node-on-reduced var(--_calm) infinite;
animation-delay: var(--_r);
}
.n-think {
--_r: 0s;
}
.n-act {
--_r: calc(3s / var(--speed));
}
.n-observe {
--_r: calc(6s / var(--speed));
}
@keyframes node-on-reduced {
0%,
2% {
opacity: 0;
}
12%,
24% {
opacity: 0.9;
}
34%,
100% {
opacity: 0;
}
}
/* The readout keeps its terminal transform and breathes in place, so
the size and side of the final error stay legible at any moment. */
@keyframes readout-breathe-reduced {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
.token,
.err,
.state {
opacity: 0;
}
.err-fail {
opacity: 1;
transform: scaleX(-1);
animation: readout-breathe-reduced var(--_calm) ease-in-out infinite;
}
.state-fail {
opacity: 1;
transform: translateX(-92px);
animation: readout-breathe-reduced var(--_calm) ease-in-out infinite;
}
}
</style>
</head>
<body>
<div
class="loop"
role="img"
aria-label="An agent loop of think, act and observe running four laps; each correction overshoots further than the last until the loop hits its step limit and halts"
>
<svg viewBox="0 0 264 144">
<line class="wire" x1="76" y1="48" x2="90" y2="48" />
<polygon class="head" points="90,44.5 96,48 90,51.5" />
<line class="wire" x1="168" y1="48" x2="182" y2="48" />
<polygon class="head" points="182,44.5 188,48 182,51.5" />
<path class="wire" d="M224 58 V74 Q224 82 216 82 H48 Q40 82 40 74 V65" />
<polygon class="head" points="36.5,65 40,59 43.5,65" />
<g class="node n-think">
<rect class="box" x="6" y="18" width="68" height="40" rx="8" />
<rect class="box-on" x="6" y="18" width="68" height="40" rx="8" />
<text class="label" x="40" y="35">THINK</text>
</g>
<g class="node n-act">
<rect class="box" x="98" y="18" width="68" height="40" rx="8" />
<rect class="box-on" x="98" y="18" width="68" height="40" rx="8" />
<text class="label" x="132" y="35">ACT</text>
</g>
<g class="node n-observe">
<rect class="box" x="190" y="18" width="68" height="40" rx="8" />
<rect class="box-on" x="190" y="18" width="68" height="40" rx="8" />
<text class="label" x="224" y="35">OBSERVE</text>
</g>
<circle class="token" cx="40" cy="48" r="4.5" />
<rect class="track" x="40" y="112" width="184" height="4" rx="2" />
<rect class="err" x="132" y="111" width="92" height="6" rx="3" />
<rect class="err-fail" x="132" y="111" width="92" height="6" rx="3" />
<circle class="state" cx="132" cy="114" r="5" />
<circle class="state-fail" cx="132" cy="114" r="5" />
<line class="goal" x1="132" y1="104" x2="132" y2="124" />
<text class="caption" x="132" y="138">GOAL</text>
</svg>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.