Back to the library

Agent loop, converging

Variant: Converging

Think, act, observe, around again. The payload rides the wire and each node lights as it arrives; the error bar updates on observe rather than on act, because that is when the agent actually learns anything. Each correction overshoots by about a third of the last, so four laps land it and the loop exits instead of spinning. Reach for it when a diagram has to show that a loop terminates.

controldiagramllmloop

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, converging</title>
    <style>
      :root {
        --ivory: #faf9f5;
        --slate: #141413;
        --clay: #d97757;
        --oat: #e3dacc;
        --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);
      }

      /* One keyframe pair drives all three nodes; the delay is the only
         difference between them, so the think/act/observe beat is set by the
         geometry rather than by three hand-tuned timelines. Four pulses in 5s,
         then the fifth second is deliberately empty — the loop has exited. */
      .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;
      }

      /* Three full laps, then a fourth that never takes the return leg: the
         exit is the whole point, so it is the only lap allowed to break the
         pattern. Corners stay linear — this is 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%,
        78% {
          transform: translate(184px, 0px);
          opacity: 1;
        }
        82%,
        100% {
          transform: translate(184px, 0px);
          opacity: 0;
        }
      }

      .track {
        fill: var(--oat);
      }

      .err {
        fill: var(--clay);
        transform-origin: 132px 114px;
        animation: err-swing 5s infinite;
      }

      .state {
        fill: var(--clay);
        stroke: var(--ivory);
        stroke-width: 2;
        animation: state-swing 5s infinite;
      }

      /* The readout moves when observe fires, not when act fires — the world
         changed a beat earlier, the agent only finds out here. Each correction
         overshoots by about a third of the last one, so four laps is genuinely
         enough to land it. */
      @keyframes err-swing {
        0% {
          transform: scaleX(-1);
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        1.2%,
        12.4% {
          transform: scaleX(-1);
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        16.4%,
        32.4% {
          transform: scaleX(0.38);
          animation-timing-function: var(--ease-out);
        }
        36.4%,
        52.4% {
          transform: scaleX(-0.14);
          animation-timing-function: var(--ease-out);
        }
        56.4%,
        72.4% {
          transform: scaleX(0.05);
          animation-timing-function: var(--ease-out);
        }
        76.4%,
        92% {
          transform: scaleX(0.012);
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        97%,
        100% {
          transform: scaleX(0.012);
          opacity: 0;
        }
      }

      @keyframes state-swing {
        0% {
          transform: translateX(-92px);
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        1.2%,
        12.4% {
          transform: translateX(-92px);
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        16.4%,
        32.4% {
          transform: translateX(35px);
          animation-timing-function: var(--ease-out);
        }
        36.4%,
        52.4% {
          transform: translateX(-13px);
          animation-timing-function: var(--ease-out);
        }
        56.4%,
        72.4% {
          transform: translateX(5px);
          animation-timing-function: var(--ease-out);
        }
        76.4%,
        92% {
          transform: translateX(1px);
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        97%,
        100% {
          transform: translateX(1px);
          opacity: 0;
        }
      }

      .goal {
        stroke: var(--slate);
        stroke-width: 2;
      }

      .goal-on {
        stroke: var(--clay);
        stroke-width: 4;
        opacity: 0;
        animation: goal-hit 5s infinite;
      }

      @keyframes goal-hit {
        0%,
        76.4% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        78%,
        92% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        97%,
        100% {
          opacity: 0;
        }
      }

      .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,
        .state,
        .goal-on {
          animation: none;
        }

        /* The reduced view has to hold the *conclusion*, not a random frame of
           it: the marker parked on the goal, the goal lit, and an error bar
           that has shrunk to nothing. Killing the marker while leaving the
           full-width error bar swinging — which is what this used to do —
           removes the explanation and keeps the movement, exactly backwards. */
        .token {
          opacity: 0;
        }

        .err {
          transform: scaleX(0.012);
        }

        .state {
          opacity: 1;
          transform: translateX(1px);
        }

        .goal-on {
          opacity: 1;
        }
      }
    </style>
  </head>
  <body>
    <div
      class="loop"
      role="img"
      aria-label="An agent loop of think, act and observe running four laps; each correction overshoots the goal by less than the last until the error lands on the goal and the loop exits"
    >
      <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" />
        <circle class="state" cx="132" cy="114" r="5" />
        <line class="goal" x1="132" y1="104" x2="132" y2="124" />
        <line class="goal-on" 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.