All ArticlesAI & Agents•October 26, 2026•9 min read

The Agent Stack Just Grew Up: What the Latest AI & Agent News Means for Your Architecture

Agentic AI has quietly crossed from prototype demos into distributed systems engineering. Here's what the latest shifts in protocols, identity, and observability mean for the architecture you're shipping this quarter — and the code to get there.

Abstract 3D representation of a colorful molecular structure, showcasing vibrant hues and artistic design.Photo by Google DeepMind on Pexels

The Big Story: Agents Graduated From Demo to Distributed System

The headline in AI & Agents right now isn't a benchmark score. It's that the ecosystem has stopped arguing about whether autonomous agents work and started standardizing the plumbing around them: tool protocols, delegated identity, execution sandboxing, and telemetry. That is the moment a technology stops being a demo and becomes an architecture problem — with the same failure modes we've solved before in microservices, RPC, and batch pipelines.

If you are a senior engineer or architect being asked to "add agents" to a production system, the risk profile has shifted. You will no longer be graded on prompt cleverness. You will be graded on whether your agent runtime is bounded, observable, authorized, and testable. This article walks through the four architectural shifts driving that change, then gives you a reference implementation you can adapt.

The Four Shifts Reshaping Agent Architecture

1. Tool calling became a protocol, not a prompt trick

The Model Context Protocol (MCP) moved tool integration out of bespoke JSON-in-a-system-prompt hacks and into a versioned, transport-agnostic contract. The practical consequence for architecture: your tools are now a separately deployable service with its own lifecycle, not a switch statement inside your agent process. That means version negotiation, capability discovery, and deprecation policy — the same discipline you apply to any internal API.

The design pattern that survives contact with production is a narrow, typed tool surface. Advertise the smallest set of tools the current actor is authorized to see, validate every argument against a schema before execution, and return structured errors the model can actually recover from.

2. The loop is a state machine, not a while (true)

Every incident I've seen with a runaway agent traces back to an unbounded loop. Production agents need hard caps on steps, tool invocations, wall-clock time, and token spend — enforced in code, not in the prompt. The model is a fallible planner; your runtime is the adult supervision.

3. Identity and delegation: OAuth for non-human actors

Agents act on behalf of users, tenants, and services simultaneously. The confused deputy problem is now a first-class security concern: an agent holding a broad service token will happily use it to satisfy a prompt-injected instruction. RFC 8707 (Resource Indicators) and RFC 9728 (Protected Resource Metadata) exist precisely so an agent's token is scoped to one downstream resource, and RFC 9449 (DPoP) lets you bind tokens to a key so a leaked token isn't a permanent skeleton key.

4. Observability standardized around gen_ai.*

OpenTelemetry's GenAI semantic conventions give you a vendor-neutral way to trace prompts, tool calls, token usage, and model metadata. If your agent emits no spans, you don't have an agent — you have an incident generator with a chat UI.

Reference Architecture: A Bounded, Observable Agent Runtime

code
┌──────────────┐   gen_ai spans   ┌──────────────┐
│  API / UI    ├─────────────────▶│  Agent Runtime│
└──────────────┘                  │  (budgeted)   │
                                  └───────┬───────┘
         ┌────────────────────────────────┼─────────────────┐
         ▼                                ▼                 ▼
  ┌─────────────┐                 ┌──────────────┐   ┌──────────────┐
  │ Model Client│                 │ Tool Registry│   │ MCP Gateway  │
  │ (retry/OTel)│                 │ (zod + scope)│   │ (OAuth 8707) │
  └─────────────┘                 └──────┬───────┘   └──────┬───────┘
                                         ▼                  ▼
                                  ┌──────────────────────────────┐
                                  │ Sandboxed tool workers       │
                                  │ (egress allowlist, no net)   │
                                  └──────────────────────────────┘

The typed tool contract

ts
// src/agent/contracts.ts
import { z } from "zod";

export type ToolRisk = "read" | "write" | "destructive";

export interface ToolContext {
  runId: string;
  tenantId: string;
  actor: { sub: string; scopes: ReadonlySet<string> };
  signal: AbortSignal;
}

export interface ToolDefinition<S extends z.ZodTypeAny, R extends z.ZodTypeAny> {
  name: string;
  description: string;
  risk: ToolRisk;
  input: S;
  output: R;
  timeoutMs: number;
  requiredScope: string;
  execute(args: z.infer<S>, ctx: ToolContext): Promise<z.infer<R>>;
}

export class ToolError extends Error {
  constructor(
    message: string,
    readonly code: string,
    readonly retryable: boolean,
    readonly hint?: string,
  ) {
    super(message);
    this.name = "ToolError";
  }
}

Registry: authorization before advertisement

ts
// src/agent/registry.ts
import { z } from "zod";
import { ToolError, type ToolContext, type ToolDefinition } from "./contracts";

const MAX_OBSERVATION_BYTES = 8_000;

function truncate(s: string, limit: number): string {
  return s.length <= limit ? s : `${s.slice(0, limit)}…[truncated ${s.length - limit} chars]`;
}

export class ToolRegistry {
  private readonly tools = new Map<string, ToolDefinition<any, any>>();

  register<S extends z.ZodTypeAny, R extends z.ZodTypeAny>(tool: ToolDefinition<S, R>): void {
    if (this.tools.has(tool.name)) throw new Error(`Duplicate tool registration: ${tool.name}`);
    this.tools.set(tool.name, tool);
  }

  /** Only tools the actor may use are ever shown to the model. */
  manifestFor(ctx: ToolContext) {
    return [...this.tools.values()]
      .filter((t) => ctx.actor.scopes.has(t.requiredScope))
      .map((t) => ({ name: t.name, description: t.description, input_schema: z.toJSONSchema(t.input) }));
  }

  async invoke(name: string, rawArgs: unknown, ctx: ToolContext): Promise<string> {
    const tool = this.tools.get(name);
    if (!tool) return JSON.stringify({ error: "unknown_tool", tool: name });
    if (!ctx.actor.scopes.has(tool.requiredScope)) {
      return JSON.stringify({ error: "insufficient_scope", tool: name, required: tool.requiredScope });
    }

    const parsed = tool.input.safeParse(rawArgs);
    if (!parsed.success) {
      return JSON.stringify({ error: "invalid_arguments", issues: parsed.error.issues });
    }

    const signal = AbortSignal.any([ctx.signal, AbortSignal.timeout(tool.timeoutMs)]);
    try {
      const result = await tool.execute(parsed.data, { ...ctx, signal });
      return truncate(JSON.stringify(tool.output.parse(result)), MAX_OBSERVATION_BYTES);
    } catch (err) {
      // Never throw into the loop: a structured observation lets the model recover.
      const e = err instanceof ToolError ? err : new ToolError("tool_failed", "unhandled", false);
      return JSON.stringify({ error: e.code, message: e.message, retryable: e.retryable, hint: e.hint });
    }
  }
}

The bounded loop

ts
// src/agent/loop.ts
import { trace, SpanStatusCode } from "@opentelemetry/api";
import type { ToolRegistry } from "./registry";
import type { ToolContext } from "./contracts";

const tracer = trace.getTracer("agent-runtime", "1.0.0");

export interface RunBudget {
  maxSteps: number;
  maxToolCalls: number;
  maxWallClockMs: number;
}

export interface AgentRunResult {
  status: "completed" | "budget_exhausted" | "aborted";
  finalText?: string;
  steps: number;
  toolCalls: number;
}

export async function runAgent(
  model: ModelClient,
  registry: ToolRegistry,
  ctx: ToolContext,
  budget: RunBudget,
): Promise<AgentRunResult> {
  return tracer.startActiveSpan("invoke_agent", async (span) => {
    span.setAttributes({
      "gen_ai.operation.name": "invoke_agent",
      "gen_ai.agent.name": "support-triage",
      "agent.run_id": ctx.runId,
    });

    const deadline = Date.now() + budget.maxWallClockMs;
    const messages = await buildPrompt(ctx);
    let toolCalls = 0;

    const done = (r: AgentRunResult): AgentRunResult => {
      span.setAttributes({ "agent.status": r.status, "agent.steps": r.steps, "agent.tool_calls": r.toolCalls });
      span.setStatus({ code: r.status === "completed" ? SpanStatusCode.OK : SpanStatusCode.ERROR });
      span.end();
      return r;
    };

    try {
      for (let step = 1; step <= budget.maxSteps; step++) {
        if (Date.now() > deadline) return done({ status: "budget_exhausted", steps: step - 1, toolCalls });

        const res = await model.complete({ messages, tools: registry.manifestFor(ctx), signal: ctx.signal });
        span.addEvent("gen_ai.usage", { input_tokens: res.usage.inputTokens, output_tokens: res.usage.outputTokens });
        messages.push(res.message);

        if (res.stopReason !== "tool_use") {
          return done({ status: "completed", finalText: res.text, steps: step, toolCalls });
        }

        for (const call of res.toolCalls) {
          toolCalls += 1;
          if (toolCalls > budget.maxToolCalls) {
            return done({ status: "budget_exhausted", steps: step, toolCalls });
          }
          const observation = await tracer.startActiveSpan(
            `execute_tool ${call.name}`,
            { attributes: { "gen_ai.tool.name": call.name, "gen_ai.tool.call_id": call.id } },
            async (toolSpan) => {
              const out = await registry.invoke(call.name, call.input, ctx);
              toolSpan.end();
              return out;
            },
          );
          messages.push({ role: "tool", toolCallId: call.id, content: observation });
        }
      }
      return done({ status: "budget_exhausted", steps: budget.maxSteps, toolCalls });
    } catch (err) {
      span.recordException(err as Error);
      return done({ status: "aborted", steps: 0, toolCalls });
    }
  });
}

Connecting a remote MCP server with a resource-scoped token

ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

// Audience-bound token (RFC 8707): this token is useless against any other resource.
const transport = new StreamableHTTPClientTransport(new URL("https://tools.internal/mcp"), {
  requestInit: { headers: { Authorization: `Bearer ${await mintToken({ aud: "https://tools.internal/mcp", scopes: ["tickets:read"] })}` } },
});

const client = new Client({ name: "agent-runtime", version: "1.0.0" }, { capabilities: {} });
await client.connect(transport);
const { tools } = await client.listTools(); // pin and diff this list in CI

Security: The Threat Model You Inherit With Every Tool

Every tool you expose widens the trust boundary. Treat tool descriptions as untrusted input — MCP servers can change their descriptions between sessions (the "rug pull"), and remote servers can be compromised. OWASP's LLM Top 10 lists prompt injection and excessive agency as the top risks precisely because agents compose untrusted text with privileged actions.

  • Sandbox tool workers. Run each MCP server in its own process with an egress allowlist (Kubernetes NetworkPolicy, gVisor, or a Firecracker microVM). No default-allow networking.
  • Gate destructive tools. Intersect risk: "destructive" with a human approval step, or require a second actor's scope.
  • Idempotency keys on write tools. Model retries are real; duplicate refunds are worse.
  • Never let tool output re-enter the system prompt. Keep observations in the tool role, delimited, and labeled as untrusted data.

Evaluation: Testing a Non-Deterministic System

You cannot unit-test model output, but you can regression-test behavior. Record real traces (input, tool sequence, final args) and replay them against a candidate model or prompt. Assert on properties, not strings: did it call get_ticket before refund? Did it stay within budget? Did it refuse the injected instruction?

Keep a golden set of at least 50 adversarial cases: prompt injection in a ticket body, oversized tool output, malformed JSON arguments, a 429 from a downstream API. Run it in CI on every prompt, tool-schema, or model-version change. Tool-selection accuracy degrades quietly when you exceed roughly 20–30 advertised tools — measure it rather than guessing.

Pre-Ship Operational Checklist

  • Every run has a run_id, an OTel trace, and an audit log entry
  • Budgets enforced in code: steps, tool calls, wall clock, tokens
  • Tool manifest filtered by actor scope; unauthorized tools never advertised
  • Structured, truncated tool observations with retryable flags
  • Egress allowlist + sandbox for every tool worker
  • Idempotency keys on all write/destructive tools
  • Human-in-the-loop gate for irreversible actions
  • Kill switch that drains in-flight runs gracefully
  • Eval suite green in CI; canary rollout at ≤5% traffic
  • Cost ceiling per tenant with alerting

Pitfalls That Still Sink Rollouts

Unbounded loops. The single most common production incident. Cap it in code.

Tool sprawl. Beyond a few dozen tools, selection accuracy and latency both degrade. Namespace, prune, and split into sub-agents by domain.

Fat observations. Returning a 200 KB API payload burns context, cost, and accuracy. Project, summarize, and truncate at the boundary.

Treating the model as a trust boundary. It isn't. Validate arguments server-side exactly as you would for an untrusted HTTP client.

Silent retries on non-idempotent writes. Retry the transport, never the side effect.

Sources & Technical References

2 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles