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 {
--ivory: #faf9f5;
--slate: #141413;
--clay: #d97757;
--oat: #e3dacc;
--rust: #b04a3f;
--gray-500: #87867f;
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--wire: 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);
}
.loop {
width: 264px;
}
.loop svg {
display: block;
width: 100%;
height: auto;
}
.box {
fill: var(--ivory);
stroke: var(--oat);
stroke-width: 1.5;
}
.box-on {
fill: rgba(217, 119, 87, 0.1);
stroke: var(--clay);
stroke-width: 1.5;
opacity: 0;
}
.label {
font-size: 10px;
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 5s infinite;
animation-delay: var(--d);
}
.node .box-on {
animation: node-on 5s infinite;
animation-delay: var(--d);
}
.n-think {
--d: 0s;
transform-origin: 40px 38px;
}
.n-act {
--d: 0.3s;
transform-origin: 132px 38px;
}
.n-observe {
--d: 0.62s;
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 5s 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(--wire);
}
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(--wire);
}
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(--wire);
}
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(--wire);
}
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 5s infinite,
cycle-vis 5s infinite;
}
.err-fail {
fill: var(--rust);
opacity: 0;
animation:
err-swing 5s infinite,
fail-in 5s infinite;
}
.state,
.state-fail {
stroke: var(--ivory);
stroke-width: 2;
}
.state {
fill: var(--clay);
animation:
state-swing 5s infinite,
cycle-vis 5s infinite;
}
.state-fail {
fill: var(--rust);
opacity: 0;
animation:
state-swing 5s infinite,
fail-in 5s 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: 9px;
letter-spacing: 0.1em;
fill: var(--gray-500);
text-anchor: middle;
}
@media (prefers-reduced-motion: reduce) {
.node,
.node .box-on,
.token,
.err,
.err-fail,
.state,
.state-fail {
animation: none;
}
/* The halt, held: error at full width on the wrong side of the goal,
marker pinned at the far end, both in rust. The clay pair is the
mid-run state and has nothing to say once the run is over. */
.token,
.err,
.state {
opacity: 0;
}
.err-fail {
opacity: 1;
transform: scaleX(-1);
}
.state-fail {
opacity: 1;
transform: translateX(-92px);
}
}
</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.