<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>TensorUp</title>
    <link>https://tensorup.ai/</link>
    <atom:link href="https://tensorup.ai/rss.xml" rel="self" type="application/rss+xml"/>
    <description>Deep dives on how modern AI works: models, agents and the engineering around them. What ships, what breaks, and why.</description>
    <language>en-gb</language>
    <lastBuildDate>Tue, 27 Oct 2026 12:30:00 GMT</lastBuildDate>
    <item>
      <title>Why Your Agent Loops Forever: Put the Review Step Outside the Executor</title>
      <link>https://tensorup.ai/posts/why-your-agent-loops-forever-put-the-review-step-outside-the-executor/</link>
      <guid isPermaLink="true">https://tensorup.ai/posts/why-your-agent-loops-forever-put-the-review-step-outside-the-executor/</guid>
      <pubDate>Tue, 27 Oct 2026 12:30:00 GMT</pubDate>
      <description>An agent loops when nothing in the loop can decide success or failure; a deterministic check on system state, reporting only to the planner, gives it an exit</description>
      <category>agents</category>
      <dc:creator>Ajay Kumar</dc:creator>
      <content:encoded><![CDATA[<p>An executor agent calls the same tool for the 60th time. The system it operates on has not changed since the second call. The model re-plans, picks the same call, and the context grows until the provider rejects the request. Nothing in the loop could tell it that this call cannot change anything.</p>
<p>The system under execution cannot change with that tool call, yet every call returns success. The executor receives a successful tool result each time, so from inside the loop the tool looks like it is working.</p>
<p>My position: agents loop because there is no definitive check of success or failure. The fix is a deterministic check of the goal against the state of the system, run by a review step that sits outside both planner and executor and reports only to the planner.</p>
<h2>Why the ReAct Loop Has No Exit</h2>
<p>A ReAct-style agent loop ends when the model stops asking for tools. Strands Agents describes its loop exactly that way: call the model, run any tools it requests, call the model again, and repeat until it produces a final response [1]. In Strands 1.62.0 no turn or token limit applies unless you pass one [1][2]. The OpenAI Agents SDK defaults to 10 turns and raises <code>MaxTurnsExceeded</code> [8]; LangGraph defaults to 1,000 steps and raises <code>GraphRecursionError</code> [9]. Either way, the loop's only exit is the model's own decision to stop, plus whatever budget you remembered to set.</p>
<p>Repeated identical calls are a measured failure, not an anecdote. One study counted instances where an agent called the same tool with identical arguments more than once. With step-by-step planning, Gemini-3-Flash did so in 45.5% of multi-objective HotpotQA instances and GPT-5-mini in 39.5% [10]. The repeats followed a failed or empty tool result [10].</p>
<p>On live websites, 34.2% of gpt-5-nano's actions produced no change in the page at all, and 10.4% of its failures came from repeating one action more than three times [12]. Across 1,794 Terminal-Bench trajectories, failed runs lasted a median of 27 steps, and "keeps repeating the same approach" accounted for 29% of all wasted steps [11].</p>
<p>Running out of context does not stop the loop either. When Strands catches a <code>ContextWindowOverflowException</code>, <code>Agent</code> asks its conversation manager to reduce the context and runs the cycle again [4]. The default <code>SlidingWindowConversationManager</code> keeps 40 messages and pins none, so the first message to be trimmed is the original task [5]. The agent keeps calling tools after it has lost the instruction that told it when to stop.</p>
<h2>What a Review Step Is</h2>
<p>A <strong>review step</strong> is the component that decides whether the goal is met. Most agent designs leave that decision to the model that is doing the work, and the evidence says this is the weakest place for it.</p>
<p>Anthropic reports that agents "reliably skew positive when grading their own work" and that separating the doer from the judge is a strong lever [14]. In a 54-cycle pilot study, an agent that judged its own progress claimed improvement every cycle, while 56% of cycles showed no measured gain or a decline [13]. A stronger in-band judge still accepted real regressions; the effect disappeared when success could be verified from the artifact itself [13]. On Terminal-Bench, 26% of failed trajectories fabricated success [11].</p>
<blockquote>
<p>In my opinion, a review step is a deterministic check of the goal against the state of the system. It belongs to neither the planner nor the executor. It reports to the planner and never talks to the executor.</p>
</blockquote>
<p>The definition puts the evidence in the environment. Anthropic's eval guidance makes the same distinction: the outcome is the final state of the environment, so a flight-booking agent passes when a reservation exists in the database, not when its message says the flight is booked [15]. A code-based check is cheap and reproducible; its cost is brittleness when a valid result does not match the expected pattern [15].</p>
<figure><img src="https://dqtx7b5s7fie0.cloudfront.net/posts/why-your-agent-loops-forever-put-the-review-step-outside-the-executor/images/diagram.svg" alt="The executor's tool loop is bounded from inside by NoProgressGuard and Limits, while GoalCheck reads the ServiceEnv state after each executor run and sends its findings only back to the planner, never to the executor."><figcaption>Figure 1 · Where each check sits: the guard and limits inside the executor, GoalCheck outside it, reporting to the planner</figcaption></figure>
<h2>How the Planner/Executor Split Places Each Check</h2>
<p>The sample runs on Strands Agents 1.62.0. <code>ServiceEnv</code> is a service that is down because its <code>db_url</code> points at a decommissioned database. <code>restart_service</code> always returns "restart ok" and changes nothing the goal can see. It is the tool from the hook: it succeeds, and nothing moves. Read the diagram from the top.</p>
<p><code>Orchestrator.run()</code> owns the round budget, <code>max_rounds</code>. Each round it gives the planner the goal, the current state snapshot and the reviewer's findings from the previous round.</p>
<p>The <strong>planner</strong> is a Strands <code>Agent</code> with no tools. It writes a numbered plan and executes nothing.</p>
<p>The <strong>executor</strong> is a fresh Strands <code>Agent</code> each round, with the service tools, two bounds and nothing else in its history. Its first message is the plan text, so no reviewer output and no previous round's transcript ever reach it. The first bound is <code>limits=Limits(turns=8, total_tokens=40_000)</code> on every call, which ends the run with <code>stop_reason="limit_turns"</code> or <code>"limit_total_tokens"</code> instead of an exception [3]. The second is <code>NoProgressGuard</code>, a <code>BeforeToolCallEvent</code> hook:</p>
<pre><code class="language-python"># src/loopstop/guard.py
    def _before_tool_call(self, event: BeforeToolCallEvent) -> None:
        name = event.tool_use["name"]
        args = json.dumps(event.tool_use["input"], sort_keys=True)
        key = (name, args, self._fingerprint())
        if key not in self._seen:
            self._seen.add(key)
            return
        self.tripped = f"{name}({args}) repeated with no state change"
        logger.warning("no_progress tool=%s args=%s state=%s", name, args, key[2])
        event.cancel_tool = self.tripped
        event.agent.cancel(after_current_tools=True)
</code></pre>
<p>The guard keys every call on its name, its arguments and a hash of the environment state. A deterministic tool called with the same input from the same state cannot produce a new outcome, so the second such call is pure cost. Setting <code>cancel_tool</code> replaces the call with an error result [6], and <code>agent.cancel(after_current_tools=True)</code> exits the loop before the next model call [4]. The executor never gets a turn to reason about the cancellation. In the test that scripts the restart loop, the run ends after two model calls and one restart.</p>
<p><code>GoalCheck.review()</code> is the review step. It compares two snapshots of <code>ServiceEnv</code> and nothing else:</p>
<pre><code class="language-python"># src/loopstop/review.py
    def review(self, before: dict[str, Any], after: dict[str, Any]) -> Verdict:
        reasons = self.unmet(after)
        if not reasons:
            return Verdict("pass")
        if after == before:
            return Verdict("stalled", ("no state change this round", *reasons))
        return Verdict("fail", tuple(reasons))
</code></pre>
<p>On a pass, the run ends. A round that changed nothing ends it as <code>stalled</code>, whatever the executor said it did. On a fail, the orchestrator sends the reasons, such as <code>service unhealthy with db_url=...</code>, back to the planner for the next round. The full sample is in the <a href="https://github.com/tensorupai/tensorup-examples/tree/main/why-agents-loop-forever">tensorup-examples repo</a>. Its 13 offline tests (<code>uv run pytest -q</code>) cover the restart loop, a transcript that claims success the state does not show, both limits, the round budget, and an executor whose second round starts from the plan alone.</p>
<p>I also ran it live against <code>gpt-6-luna</code> through the OpenAI Responses API, on an earlier version of the sample whose error message named the new database only as "db-new". In round 1 the executor scaled the service to three replicas and set <code>db_url</code> to <code>postgres://db-new:5432/orders</code>, a reasonable guess that was wrong, and <code>GoalCheck</code> returned <code>fail</code>. In round 2 it called <code>get_status</code> again from the same state, <code>NoProgressGuard</code> cancelled the repeat, and the run ended <code>stalled</code> after 11 model calls. The guard and the review step stopped a task the agent could not solve; they could not make it solvable. The current sample's error message names the full URL, and with it the same run passed in round 1 after 8 model calls.</p>
<p>The split works because each part sees a different thing: the planner sees state and findings, the executor sees a plan, and the review step sees only state. The next question is what the alternatives cost.</p>
<h2>Design Decisions and Trade-offs</h2>
<p><strong>Turn limits alone.</strong> A cap stops the bill, and Strands' <code>Limits</code> stops gracefully with the message history intact [3]. It does not say whether the run failed or was about to succeed, and choosing the number is guesswork. In the sample the cap is a backstop and the guard is the detector.</p>
<p><strong>Strands' <code>GoalLoop</code> plugin.</strong> Strands ships a goal plugin that validates the agent's response after each invocation [7]. It accepts a code validator or a natural-language goal judged by a second agent.</p>
<p>When validation fails, the plugin feeds the feedback back into the same agent as a new user message. That makes the executor its own reviewer's audience, which is the coupling this design avoids. Its <code>max_attempts</code> and <code>timeout</code> both default to infinity, and it warns that execution is then unbounded [7]. The default judge reads the transcript, with each tool result cut to 500 characters [7].</p>
<p><strong>An LLM judge instead of code.</strong> A model-based grader handles open-ended goals that no assertion can express, at the cost of being non-deterministic and needing calibration against human graders [15]. For a goal you can state as a predicate over state (a healthy service with three replicas, say), code is the better reviewer. Anthropic's long-running agents work made features start as "failing" in a JSON file that agents could change only by flipping a <code>passes</code> field after testing [16].</p>
<p><strong>Stopping on the first stall.</strong> The orchestrator ends the run after one round with no state change, rather than letting the planner try again. A planner given the same state and the same findings tends to produce the same plan. The cost is that a stall caused by a transient fault ends a run that a retry would have rescued.</p>
<h2>Where It Breaks</h2>
<p><strong>The same call from the same state.</strong> Symptom: tool-call count climbs while every result is a success. Cause: the tool cannot change the state any further, and the loop has no way to notice [10][12]. Fix: fingerprint the goal-relevant state around each call and stop on a repeat, as <code>NoProgressGuard</code> does. Run tools sequentially: Strands' default <code>ConcurrentToolExecutor</code> runs a batch in parallel, and the fingerprint must not change under the guard [4].</p>
<p><strong>A done check that reads the transcript.</strong> Symptom: the run reports success and the system is still broken. Cause: the judge sees the agent's claims, and failing agents fabricate success late in a run [11][13]. Fix: check the environment, as <code>GoalCheck</code> does; the sample's test with a "service is healthy" message and no state change ends <code>stalled</code>.</p>
<p><strong>A fingerprint that is too coarse or too fine.</strong> Symptom: the guard stops a polling tool on its second call, or never fires at all. Cause: the fingerprint holds something that changes on every call (a timestamp, a restart counter), or misses something that matters. Fix: hash only the fields the goal is defined on, which is why <code>restarts</code> sits outside <code>ServiceEnv.snapshot()</code>, and give polling tools an exemption or a time field.</p>
<h2>Checklist</h2>
<ul class="contains-task-list">
<li class="task-list-item"><input type="checkbox" disabled> Write the success condition as code over system state, not over the agent's final message.</li>
<li class="task-list-item"><input type="checkbox" disabled> Pass <code>limits</code> with <code>turns</code> and <code>total_tokens</code> on every Strands executor call [3].</li>
<li class="task-list-item"><input type="checkbox" disabled> Fingerprint goal-relevant state before each tool call and cancel a repeat from the same state.</li>
<li class="task-list-item"><input type="checkbox" disabled> Run the executor's tools sequentially when a hook reads shared state.</li>
<li class="task-list-item"><input type="checkbox" disabled> Send review findings to the planner only; start each executor run from the plan alone.</li>
<li class="task-list-item"><input type="checkbox" disabled> Cap planner rounds and end the run on a round with no state change.</li>
<li class="task-list-item"><input type="checkbox" disabled> Pin the task message (<code>pin_first</code>) if you rely on the sliding window manager [5].</li>
<li class="task-list-item"><input type="checkbox" disabled> Log the stop reason of every executor run and every verdict.</li>
</ul>
<p>My verdict: an agent that cannot fail will loop, and the only reliable way to let it fail is a check that does not depend on the model's opinion of its own work. In my case the agent kept running for hours, through 50 to 100 tool calls, until it filled the context window and threw an exception, on a task that should have passed or failed within a few seconds.</p>
<p>What does your agent use to decide it is done?</p>
<h2>References</h2>
<ol>
<li>Strands Agents, <a href="https://strandsagents.com/docs/user-guide/sdk/agents/agent-loop/">Agent Loop</a>, docs, updated 2026-10-21.</li>
<li>Strands Agents, <a href="https://github.com/strands-agents/sdk-python/blob/python/v1.62.0/strands-py/src/strands/event_loop/event_loop.py"><code>event_loop/event_loop.py</code></a>, GitHub, python/v1.62.0, 2026-10-21.</li>
<li>Strands Agents, <a href="https://github.com/strands-agents/sdk-python/blob/python/v1.62.0/strands-py/src/strands/types/agent.py"><code>types/agent.py</code> (<code>Limits</code>)</a>, GitHub, python/v1.62.0, last changed 2026-10-16.</li>
<li>Strands Agents, <a href="https://github.com/strands-agents/sdk-python/blob/python/v1.62.0/strands-py/src/strands/agent/agent.py"><code>agent/agent.py</code></a>, GitHub, python/v1.62.0, last changed 2026-10-16.</li>
<li>Strands Agents, <a href="https://github.com/strands-agents/sdk-python/blob/python/v1.62.0/strands-py/src/strands/agent/conversation_manager/sliding_window_conversation_manager.py"><code>sliding_window_conversation_manager.py</code></a>, GitHub, python/v1.62.0, last changed 2026-06-16.</li>
<li>Strands Agents, <a href="https://github.com/strands-agents/sdk-python/blob/python/v1.62.0/strands-py/src/strands/hooks/events.py"><code>hooks/events.py</code></a>, GitHub, python/v1.62.0, last changed 2026-09-25.</li>
<li>Strands Agents, <a href="https://github.com/strands-agents/sdk-python/tree/python/v1.62.0/strands-py/src/strands/vended_plugins/goal"><code>vended_plugins/goal/</code></a> (<code>GoalLoop</code>), GitHub, python/v1.62.0, last changed 2026-09-30.</li>
<li>OpenAI, <a href="https://github.com/openai/openai-agents-python/blob/v0.24.0/src/agents/run_config.py"><code>run_config.py</code></a> (<code>DEFAULT_MAX_TURNS</code>), OpenAI Agents SDK, GitHub, v0.24.0, 2026-10-15.</li>
<li>LangChain, <a href="https://docs.langchain.com/oss/python/langgraph/graph-api">LangGraph Graph API: recursion limit</a>, docs, accessed 2026-10-25.</li>
<li><a href="https://arxiv.org/abs/2605.08477">Do Agents Need to Plan Step-by-Step? Rethinking Planning Horizon in Data-Centric Tool Calling</a>, arXiv, 2026-05-08.</li>
<li><a href="https://arxiv.org/abs/2607.09510">Failure as a Process: An Anatomy of CLI Coding Agent Trajectories</a>, arXiv, 2026-07-10.</li>
<li><a href="https://arxiv.org/abs/2603.14248">Why Do LLM-based Web Agents Fail? A Hierarchical Planning Perspective</a>, arXiv, 2026-03-15.</li>
<li>Park and Choi, <a href="https://arxiv.org/abs/2607.25152">When Do Agent Loops Mistake Stagnation for Progress?</a>, arXiv, v2 2026-09-29.</li>
<li>Rajasekaran, Anthropic, <a href="https://www.anthropic.com/engineering/harness-design-long-running-apps">Harness design for long-running application development</a>, 2026-03-24.</li>
<li>Anthropic, <a href="https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents">Demystifying evals for AI agents</a>, 2026-01-09.</li>
<li>Anthropic, <a href="https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents">Effective harnesses for long-running agents</a>, 2025-11-26.</li>
</ol>]]></content:encoded>
    </item>
    <item>
      <title>A Prompt Is a Layered Program: Mapping Prompt Engineering Techniques to the Layer They Change</title>
      <link>https://tensorup.ai/posts/a-prompt-is-a-layered-program-mapping-prompt-engineering-techniques-to-the-layer/</link>
      <guid isPermaLink="true">https://tensorup.ai/posts/a-prompt-is-a-layered-program-mapping-prompt-engineering-techniques-to-the-layer/</guid>
      <pubDate>Sat, 24 Oct 2026 10:28:11 GMT</pubDate>
      <description>Every prompting technique edits one layer of the request, and every model or provider switch breaks the layers whose delivery differs</description>
      <category>engineering</category>
      <dc:creator>Ajay Kumar</dc:creator>
      <content:encoded><![CDATA[<p>You move a working ticket-triage feature from OpenAI to Claude through LiteLLM, and the only thing you change is the model id. Every request succeeds. A week later, support agents report summaries that run past the 120 characters the UI was built for. The Pydantic model behind the output still says <code>max_length=120</code>.</p>
<p>Nothing in the prompt text changed. What changed is how one layer of the prompt was delivered. Anthropic's structured outputs reject <code>maxLength</code> with a 400, so LiteLLM 1.106.0 removes it from the JSON Schema and moves the limit into the field description as a note [4][6]. Gemini accepts the keyword and silently ignores it [5]. Both paths turn a hard constraint into a suggestion.</p>
<p>My position is that a prompt is a layered program, and each prompt engineering technique targets exactly one layer. Once you know which layer a technique touches, you know what will break when the model or provider underneath it changes.</p>
<h2>Why One Tuned String Breaks on the Next Model</h2>
<p>The common approach treats a prompt as a single string, tuned by trial and error against one model until the outputs look right. That tuning fits one model's quirks. PromptBridge measured the cost on HumanEval: the best prompt for GPT-5 scored 68.70% when reused on Llama-3.1-70B-Instruct, against 79.47% for Llama's own best prompt [1]. Moving to a stronger model does not avoid it either; a prompt engineered for GPT-4o reached 92.27% on o3, below the 98.37% that o3 achieves with a prompt optimized for it [1]. Pure formatting sensitivity has shrunk on current closed-weight models, which show little accuracy loss when asked for JSON, but open-weight models still pay that cost [7].</p>
<p>OpenAI's own guidance points the same way. It recommends pinning production applications to dated snapshots and building evals, since different models may need different prompts [2]. A string tuned for one snapshot is a program compiled for one target, with no record of which parts depended on that target.</p>
<p>The fix starts with naming the parts.</p>
<h2>What a Layered Prompt Program Is</h2>
<p>A <strong>prompt program</strong> is the set of inputs that define one model call, kept as separate layers, each of which travels through a different slot of the API request. There are six:</p>
<ul>
<li><strong>L1 instructions:</strong> the rules for every request. OpenAI sends them as a <code>developer</code> or <code>system</code> message [2]; Anthropic takes <code>system</code> as a top-level request parameter, outside the message list [3].</li>
<li><strong>L2 tools:</strong> tool schemas in the <code>tools</code> field, with optional strict validation [13][4].</li>
<li><strong>L3 examples:</strong> demonstrations, sent as prior user and assistant turns or as tagged blocks [3].</li>
<li><strong>L4 context:</strong> retrieved documents. Anthropic and Google both recommend placing long documents above the query [3][10].</li>
<li><strong>L5 task:</strong> what this particular request asks for.</li>
<li><strong>L6 output contract:</strong> the schema the answer must satisfy, sent as <code>response_format</code> on OpenAI [13], <code>output_config.format</code> on Anthropic [4], and a schema subset on Gemini [5].</li>
</ul>
<p>Request configuration such as <code>temperature</code> or reasoning effort is not a prompt layer, but it breaks on model upgrades in the same way, so the sample treats it alongside the layers.</p>
<blockquote>
<p>In my opinion, the useful definition of a prompt engineering technique is the layer it edits. If you cannot say which part of the request a technique changes, you cannot say what will break when the model changes.</p>
</blockquote>
<h2>Which Layer Each Technique Changes</h2>
<p>Mapping the well-known techniques to layers shows that most of them edit exactly one, and that two of the most cited ones are not prompt edits at all. The evidence column uses only studies and provider guidance from the past year, measured on current models.</p>
<table>
<thead>
<tr>
<th>Technique</th>
<th>Layer</th>
<th>What it changes</th>
<th>Recent evidence</th>
</tr>
</thead>
<tbody>
<tr>
<td>Role or system prompting</td>
<td>L1</td>
<td>The standing rules</td>
<td>When system, user and tool instructions conflict, compliance ranged from 98.2% to 20.5% across 37 models [8]</td>
</tr>
<tr>
<td>Few-shot</td>
<td>L3</td>
<td>Adds demonstrations</td>
<td>Anthropic recommends 3–5 examples and says they shape how Claude reasons in its thinking blocks [3]</td>
</tr>
<tr>
<td>Chain-of-thought trigger</td>
<td>L5</td>
<td>One reasoning instruction in the task</td>
<td>+3.92 to +4.69 points over direct prompting without a reasoning budget; no more than 0.51 points with reasoning enabled [9]</td>
</tr>
<tr>
<td>Context before the query</td>
<td>L4</td>
<td>Where documents sit</td>
<td>Anthropic reports up to 30% better responses with the query at the end, in its own tests [3]</td>
</tr>
<tr>
<td>Interleaved reasoning and tool calls (ReAct)</td>
<td>L2 + message history</td>
<td>Reasoning kept between tool calls</td>
<td>MiniMax-M2 on Tau²: 87 with prior reasoning kept, 64 with it discarded (vendor-reported) [11]</td>
</tr>
<tr>
<td>Structured outputs</td>
<td>L6</td>
<td>Decoding constrained to a schema</td>
<td>Near-perfect schema compliance across 21 models, but the best exact value accuracy on text was 83.0% [12]</td>
</tr>
<tr>
<td>Self-consistency</td>
<td>None: sampling around the program</td>
<td>Majority vote over several samples</td>
<td>None of 280 method, dataset and model combinations beat plain majority voting; reasoning makes wrong answers agree more often [16]</td>
</tr>
<tr>
<td>Self-refinement</td>
<td>None: a loop around the program</td>
<td>The model critiques and rewrites its answer</td>
<td>GPT-5.2 on GPQA Diamond stayed at 75.3% (13 answers fixed, 13 broken); Claude Sonnet 4.5 on HotpotQA rose from 45.5% to 59.7% exact match [17]</td>
</tr>
</tbody>
</table>
<p>The table also explains why some techniques age badly. A chain-of-thought trigger lives in L5, and ReasonLab found it worth about 4 points without a reasoning budget and nothing measurable once reasoning is enabled, while enabling reasoning itself was worth up to 12.74 points [9]. Google's Gemini 3 guide tells you to replace chain-of-thought prompt engineering with a high thinking level and simpler prompts [18]. Anthropic's current guide warns that emphatic "CRITICAL: You MUST" phrasing written for older models now overtriggers [3]. Instructions that once improved results became instructions you must delete on upgrade.</p>
<figure><img src="https://dqtx7b5s7fie0.cloudfront.net/posts/a-prompt-is-a-layered-program-mapping-prompt-engineering-techniques-to-the-layer/images/diagram.svg" alt="Six provider-neutral prompt layers flow into compile_request(), which uses a per-model ModelProfile to place each layer in its request slot or raise an error, and the output contract is checked again by Pydantic after the provider responds."><figcaption>Figure 1 · How each prompt layer reaches the provider, and where the output contract is checked.</figcaption></figure>
<h2>How the Compiler Places Each Layer</h2>
<p>The sample keeps the six layers in a frozen <code>PromptProgram</code> dataclass and compiles them per model. Read the diagram from top to bottom.</p>
<p><code>PromptProgram</code> owns the layers and nothing else. It holds no provider name or request syntax, so the same program can target any model.</p>
<p><code>ModelProfile</code> owns every provider difference the code knows about. It records whether the model can enforce a JSON schema and whether it accepts <code>temperature</code>. It also lists the schema keywords to strip, and its optional <code>instruction_override</code> replaces L1 for that model. <code>profile_from_litellm()</code> fills the capability flags from LiteLLM's bundled model map, using <code>supports_response_schema()</code> and <code>get_supported_openai_params()</code> [6].</p>
<p><code>compile_request()</code> takes both and emits the keyword arguments for LiteLLM's <code>completion()</code>. Each layer goes to its own slot:</p>
<pre><code class="language-python"># src/promptlayers/compile.py
def compile_request(
    program: PromptProgram, profile: ModelProfile, *, temperature: float | None = None
) -> dict[str, Any]:
    # L1: LiteLLM moves a "system" message to Anthropic's top-level system field.
    instructions = profile.instruction_override or program.instructions
    messages: list[dict[str, Any]] = [{"role": "system", "content": instructions}]
    for ex in program.examples:  # L3: demonstrations as prior turns
        messages.append({"role": "user", "content": ex.input})
        messages.append({"role": "assistant", "content": ex.output})
    docs = "".join(f"&#x3C;document>\n{doc}\n&#x3C;/document>\n" for doc in program.context)
    messages.append({"role": "user", "content": f"{docs}&#x3C;task>\n{program.task}\n&#x3C;/task>"})  # L4, L5

    request: dict[str, Any] = {"model": profile.model, "messages": messages}
    if program.tools:  # L2
        request["tools"] = list(program.tools)
</code></pre>
<p>The function uses the <code>system</code> role for L1 because LiteLLM's Anthropic transform moves <code>system</code> messages into Anthropic's top-level <code>system</code> field [6]. Context goes above the task inside one user message, which follows the placement advice in both Anthropic's and Google's guides [3][10]. L6 becomes a strict <code>json_schema</code> with <code>additionalProperties: false</code> on every object and the non-portable keywords removed. When the profile says a layer cannot be carried, the function raises <code>UnsupportedLayerError</code> instead of sending a weaker request.</p>
<p><code>run()</code> sends the request through <code>LiteLLMModel</code>, which calls <code>litellm.completion()</code> with <code>drop_params=False</code>, a timeout and a retry count. It then validates the raw text against the full Pydantic model, including the constraints that never reached the provider. A failed validation is appended as feedback and retried within <code>max_attempts</code>; when the budget runs out, <code>run()</code> raises <code>ContractError</code>. A reply that contains tool calls raises <code>ToolCallError</code>, because this sample has no tool loop, and a tool call is not a malformed answer.</p>
<p>The full sample is in the <a href="https://github.com/tensorupai/tensorup-examples/tree/main/prompt-is-a-layered-program">tensorup-examples repo</a>; <code>uv run pytest -q</code> in its folder runs 17 offline tests that cover each layer's placement and every failure mode below.</p>
<h2>Design Decisions and Trade-offs</h2>
<p><strong>LiteLLM as the translation layer.</strong> LiteLLM accepts OpenAI-shaped requests and translates them per provider, including the system-message move and the schema filtering described above [6]. Per-provider SDK adapters give full control over each request at the cost of one adapter per provider to write and keep current. I chose LiteLLM because it gives agent framework SDKs one provider-agnostic model class: Strands Agents, for example, takes a <code>LiteLLMModel</code>, and the LiteLLM model id decides the provider [19]. It also exposes prompt caching for OpenAI, Anthropic and Gemini through the same interface, which matters for token cost [20]. Check the cache counters in the response's usage object, though: LiteLLM's docs warn that caching is skipped without an error when the input is below the provider's minimum, which is 1,024 tokens on OpenAI [20].</p>
<p><strong>A profile you can override.</strong> LiteLLM's model map is a convenient starting point, but it is a third-party description of each provider. <code>profile_from_litellm()</code> therefore accepts overrides for any flag:</p>
<pre><code class="language-python"># src/promptlayers/profiles.py
    params = litellm.get_supported_openai_params(model=model) or []
    detected = {
        "supports_json_schema": litellm.supports_response_schema(model=model),
        "supports_temperature": "temperature" in params,
        # Send only the portable schema subset everywhere; Pydantic enforces the rest.
        "rejected_schema_keywords": NON_PORTABLE_KEYWORDS,
    }
    return ModelProfile(model=model, **{**detected, **overrides})  # type: ignore[arg-type]
</code></pre>
<p><strong>The portable schema subset everywhere.</strong> The sample strips <code>minimum</code>, <code>maximum</code>, <code>minLength</code> and <code>maxLength</code> for every provider, including OpenAI. The cost is that OpenAI no longer enforces those bounds while decoding, so a violation costs one extra call through the retry loop. In return, the schema on the wire is identical for every provider, and one validator enforces the full contract on the client. Anthropic's Python SDK makes the same choice: it removes unsupported constraints and validates responses against the original schema [4].</p>
<p><strong>Fail loud over <code>drop_params</code>.</strong> LiteLLM can silently remove parameters a provider does not support when <code>drop_params=True</code> [14]. That keeps requests from failing, and it also hides configuration that no longer has any effect. The sample keeps LiteLLM's default of raising and adds its own compile-time check, so the failure appears in CI rather than in production output.</p>
<h2>Where It Breaks</h2>
<p><strong>Silently dropped parameters.</strong> Symptom: a model change that should have altered nothing makes outputs noticeably more varied. Cause: <code>drop_params=True</code> removed <code>temperature</code>, which OpenAI's guide for its latest models says must be removed whenever reasoning effort is not <code>none</code> [15][14]. Fix: leave <code>drop_params</code> off, record <code>supports_temperature</code> per model, and let <code>compile_request()</code> raise <code>UnsupportedLayerError</code>.</p>
<p><strong>An output contract that became advisory.</strong> Symptom: values outside the declared range reach downstream code, with no errors logged. Cause: the provider or the translation layer removed the constraint from the schema; LiteLLM does this for Anthropic, and Gemini ignores <code>minLength</code> and <code>maxLength</code> [6][5]. Fix: treat the wire schema as a decoding hint and validate the raw output with the full Pydantic model on every response, with a bounded retry.</p>
<p><strong>Instructions tuned for the previous model.</strong> Symptom: after an upgrade, the model overthinks, re-verifies its own answers or follows an emphatic rule too literally. Cause: L1 and L5 still carry chain-of-thought triggers and emphatic phrasing written for an older model [3][18]. Fix: pin the snapshot [2], keep a per-model <code>instruction_override</code>, and run the same eval set against the new snapshot before switching.</p>
<h2>Checklist</h2>
<ul class="contains-task-list">
<li class="task-list-item"><input type="checkbox" disabled> Store instructions, tools, examples, context, task and output schema as separate fields, never as one string.</li>
<li class="task-list-item"><input type="checkbox" disabled> Pin every production model to a dated snapshot [2].</li>
<li class="task-list-item"><input type="checkbox" disabled> Record each model's capabilities in one profile object and keep provider checks out of prompt code.</li>
<li class="task-list-item"><input type="checkbox" disabled> Turn off <code>drop_params</code> and fail at compile time on unsupported parameters.</li>
<li class="task-list-item"><input type="checkbox" disabled> Send a portable schema subset and validate every response with the full schema on the client.</li>
<li class="task-list-item"><input type="checkbox" disabled> Remove chain-of-thought triggers from prompts that target models with reasoning enabled [9][18].</li>
<li class="task-list-item"><input type="checkbox" disabled> Bound retries with an attempt budget and raise a named error when it runs out.</li>
<li class="task-list-item"><input type="checkbox" disabled> Run the same eval set against a new model or provider before switching traffic.</li>
</ul>
<p>My verdict: treat a prompt as source code with a build step. Write the layers once, keep every provider difference in one profile, and make the compiler fail loudly when a model cannot carry a layer. I learned this when a model upgrade and a provider switch broke multi-provider support that had looked finished, and the fix was structural: separating the layers, never rewording the prompt.</p>
<p>Which layer broke first the last time you switched models or providers?</p>
<h2>References</h2>
<ol>
<li>Wang et al., <a href="https://arxiv.org/abs/2512.01420">PromptBridge: Cross-Model Prompt Transfer for Large Language Models</a>, arXiv, 2025-12-01.</li>
<li>OpenAI, <a href="https://developers.openai.com/api/docs/guides/text">Text generation guide</a>, accessed 2026-10-23.</li>
<li>Anthropic, <a href="https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices">Prompting best practices</a>, accessed 2026-10-23.</li>
<li>Anthropic, <a href="https://platform.claude.com/docs/en/build-with-claude/structured-outputs">Structured outputs</a>, accessed 2026-10-23.</li>
<li>Google, <a href="https://ai.google.dev/gemini-api/docs/structured-output">Gemini API structured outputs</a>, updated 2026-10-21.</li>
<li>LiteLLM, <a href="https://github.com/BerriAI/litellm/blob/v1.106.0/litellm/llms/anthropic/chat/transformation.py"><code>litellm/llms/anthropic/chat/transformation.py</code></a>, v1.106.0, 2026-10-18.</li>
<li>Lee, D'Antoni and Berg-Kirkpatrick, <a href="https://arxiv.org/abs/2604.03616">The Format Tax</a>, arXiv, 2026-04-04.</li>
<li><a href="https://arxiv.org/abs/2607.25987">IH-Benchmark: A Conflict-Centered Benchmark for Instruction-Hierarchy Robustness in LLM Applications</a>, arXiv, 2026-07-28.</li>
<li><a href="https://arxiv.org/abs/2607.14109">ReasonLab: A Controlled and Auditable Evaluation of Prompting Techniques for Multiple-Choice Question Answering</a>, arXiv, v2 2026-09-22.</li>
<li>Google, <a href="https://ai.google.dev/gemini-api/docs/long-context">Gemini API long context</a>, updated 2026-10-15.</li>
<li>MiniMax, <a href="https://www.minimax.io/news/why-is-interleaved-thinking-important-for-m2">Interleaved Thinking Unlocks Reliable MiniMax-M2 Agentic Capability</a>, 2025-11-03.</li>
<li><a href="https://arxiv.org/abs/2604.25359">The Structured Output Benchmark: A Multi-Source Benchmark for Evaluating Structured Output</a>, arXiv, 2026-04-28.</li>
<li>OpenAI, <a href="https://developers.openai.com/api/docs/guides/structured-outputs">Structured model outputs</a>, accessed 2026-10-23.</li>
<li>LiteLLM, <a href="https://docs.litellm.ai/docs/completion/drop_params">drop_params</a>, updated 2026-10-21.</li>
<li>OpenAI, <a href="https://developers.openai.com/api/docs/guides/latest-model">Using GPT-6 (latest model guide)</a>, accessed 2026-10-23.</li>
<li>Althoubi, <a href="https://arxiv.org/abs/2609.32035">Reasoning Concentrates Errors, and Self-Consistency Never Notices</a>, arXiv, 2026-09-25.</li>
<li>Yin, Ken and Stremmel, <a href="https://arxiv.org/abs/2606.21724">Denoising Iterative Self-Correction: Structured Verification Loops for Reliable LLM Reasoning</a>, arXiv, 2026-06-19.</li>
<li>Google, <a href="https://ai.google.dev/gemini-api/docs/gemini-3">Gemini 3 developer guide</a>, updated 2026-10-09.</li>
<li>Strands Agents, <a href="https://github.com/strands-agents/sdk-python/blob/main/strands-py/src/strands/models/litellm.py"><code>strands-py/src/strands/models/litellm.py</code></a> (<code>LiteLLMModel</code>), last changed 2026-10-16.</li>
<li>LiteLLM, <a href="https://docs.litellm.ai/docs/completion/prompt_caching">Prompt caching</a>, updated 2026-10-21.</li>
</ol>]]></content:encoded>
    </item>
  </channel>
</rss>
