Skip to content

Normalizar entradas JSON de herramientas en PHP: preservar payloads con forma de objeto y recuperar doble codificación

Introducción

Cuando persistes argumentos de tool calls en PHP, no basta con que sea "JSON válido".

Las entradas de herramientas normalmente deben ser objetos JSON. Pero en el camino, PHP puede convertir fácilmente un payload vacío en [], decodificar {} como un array vacío o dejarte con un string JSON doblemente codificado como "{"key":"value"}".

Si tu aplicación guarda entradas de herramientas en la base de datos y luego las vuelve a enviar a un proveedor de IA o a otro servicio interno, esas diferencias de forma terminan convirtiéndose en bugs sutiles.

Este artículo muestra una clase pequeña llamada JsonUtil que normaliza las entradas en ambos sentidos:

  • encodeToolInput() garantiza JSON con forma de objeto para almacenamiento
  • decodeToolInput() garantiza un resultado reutilizable con forma de objeto

La idea es simple: aceptar solo objetos JSON, rechazar listas y escalares, y recuperar errores comunes de persistencia.

Por Qué Esto Se Rompe Tan Fácil en PHP

Hay dos comportamientos de PHP detrás de la mayoría de estos problemas:

1. json_encode([]) Produce [], No {}

Si un tool call no tiene argumentos, muchos sistemas igual esperan un objeto JSON vacío porque el payload conceptualmente sigue siendo "un objeto sin propiedades".

Pero PHP trata un array vacío como una lista:

php
json_encode([]); // "[]"

Eso es JSON válido, pero representa la forma incorrecta para entradas de herramientas basadas en objetos.

2. json_decode('{}', true) Produce un Array Vacío

Aquí pasa el problema inverso:

php
$decoded = json_decode('{}', true);

var_dump($decoded); // []

Con associative: true, PHP convierte objetos JSON en arrays. Eso suele ser cómodo, pero también hace que un objeto vacío se convierta en un array vacío, y luego debes normalizarlo si te importa distinguir entre objeto y lista.

Importante

Si te importa la forma del JSON, no trates "JSON válido" y "objeto JSON válido" como si fueran lo mismo.

La Clase Utilitaria

Esta es la utilidad:

php
<?php

namespace App\Utils;

class JsonUtil
{
    /**
     * Codifica la entrada de herramienta para guardarla en la BD, preservando
     * objetos vacíos como "{}". json_encode([]) produce "[]", pero la entrada
     * de herramienta se espera con forma de objeto.
     */
    public static function encodeToolInput(mixed $input): string
    {
        if (empty($input)) {
            return '{}';
        }

        // Si ya viene un string JSON, solo se conserva cuando representa
        // un objeto JSON decodificado como array asociativo.
        if (is_string($input)) {
            $decoded = json_decode($input, associative: true);

            if (! is_array($decoded) || array_is_list($decoded)) {
                return '{}';
            }

            return $input;
        }

        // Acepta solo arrays asociativos y stdClass.
        if (is_array($input) || $input instanceof \stdClass) {
            return json_encode($input) ?: '{}';
        }

        return '{}';
    }

    /**
     * Decodifica la entrada desde la BD y garantiza un resultado con forma de objeto.
     * Devuelve array asociativo o stdClass, nunca strings, arrays indexados o escalares.
     */
    public static function decodeToolInput(?string $json): array|\stdClass
    {
        if (! $json) {
            return new \stdClass;
        }

        $decoded = json_decode(json: $json, associative: true);

        // Maneja JSON inválido, escalares y strings JSON doblemente codificados.
        if (! is_array($decoded)) {
            if (is_string($decoded)) {
                $inner = json_decode($decoded, associative: true);

                if (is_array($inner) && ! empty($inner) && ! array_is_list($inner)) {
                    return $inner;
                }
            }

            return new \stdClass;
        }

        // Los arrays indexados representan listas JSON, no objetos de entrada.
        if (array_is_list($decoded)) {
            return new \stdClass;
        }

        return $decoded;
    }
}

Reglas de Codificación

encodeToolInput() es estricta a propósito. Prefiere un objeto vacío seguro antes que preservar un valor ambiguo o inválido.

Los Valores Vacíos Se Convierten en {}

Si la entrada es null, [], false u otro valor vacío, el método devuelve:

php
{}

Eso te da un formato estable para representar "sin argumentos".

Los Strings JSON Solo Se Aceptan Si Representan Objetos

Si un caller pasa un string JSON directo, el método lo conserva solo cuando se decodifica como array asociativo:

php
JsonUtil::encodeToolInput('{"city":"Panama"}');
// {"city":"Panama"}

Pero strings con forma de lista o inválidos se rechazan:

php
JsonUtil::encodeToolInput('["a","b"]');
// {}

JsonUtil::encodeToolInput('not json');
// {}

Los Escalares Se Rechazan

Los payloads de entrada no deberían ser enteros, booleanos ni strings planos:

php
JsonUtil::encodeToolInput(42);    // {}
JsonUtil::encodeToolInput(true);  // {}

Esto es intencionalmente conservador. Si el payload no es claramente un objeto, la utilidad lo normaliza como objeto vacío.

Reglas de Decodificación

decodeToolInput() resuelve el problema inverso: toma lo que venga del almacenamiento y lo convierte en algo seguro para reutilizar como argumentos.

JSON Ausente o Inválido Se Convierte en Objeto Vacío

Todos estos casos devuelven new stdClass():

php
JsonUtil::decodeToolInput(null);
JsonUtil::decodeToolInput('');
JsonUtil::decodeToolInput('not json at all');
JsonUtil::decodeToolInput('42');
JsonUtil::decodeToolInput('true');
JsonUtil::decodeToolInput('"just a string"');

Así el código downstream nunca tiene que lidiar con entradas escalares.

Los Arrays Indexados Se Rechazan

Las listas JSON son válidas, pero siguen teniendo la forma incorrecta para argumentos de herramientas:

php
JsonUtil::decodeToolInput('["a","b"]');
// stdClass {}

Los Strings JSON Doblemente Codificados Se Desenvuelven una Vez

Uno de los errores más molestos de persistencia aparece cuando por accidente haces json_encode() sobre un string JSON en lugar de hacerlo sobre un array u objeto PHP:

php
$doubleEncoded = json_encode('{"key":"value"}');

echo $doubleEncoded;
// "{\"key\":\"value\"}"

El primer json_decode() devuelve un string, no un array. La utilidad detecta ese caso e intenta una segunda decodificación:

php
JsonUtil::decodeToolInput($doubleEncoded);
// ['key' => 'value']

Eso te da una recuperación práctica sin aceptar strings escalares arbitrarios como payloads válidos.

Resumen del Comportamiento

Este es el contrato efectivo:

EntradaencodeToolInput()decodeToolInput()
null'{}'stdClass {}
[]'{}'stdClass {} al leer '[]'
['k' => 'v']'{"k":"v"}'['k' => 'v']
'{"k":"v"}''{"k":"v"}'['k' => 'v']
'["a","b"]''{}'stdClass {}
'42''{}'stdClass {}
json_encode('{"k":"v"}')'{}' si se pasa directo como JSON de un string['k' => 'v']

La asimetría es intencional:

  • la codificación es estricta y rechaza entradas ambiguas
  • la decodificación es un poco más tolerante y puede recuperar un error común de almacenamiento

Cómo Usarlo en un Pipeline de Tool Calls

Este patrón sirve cuando tu aplicación:

  • recibe argumentos de herramientas desde un proveedor LLM
  • los guarda en una columna de base de datos
  • luego los vuelve a hidratar para reintentos, auditorías o replays

Ejemplo:

php
// Persistir entrada de herramienta
$toolInputJson = JsonUtil::encodeToolInput($toolCall['input'] ?? null);

ToolExecution::create([
    'tool_name' => $toolCall['name'],
    'tool_input' => $toolInputJson,
]);

// Reproducir entrada de herramienta
$decodedInput = JsonUtil::decodeToolInput($execution->tool_input);

$providerPayload = [
    'name' => $execution->tool_name,
    'input' => $decodedInput,
];

Con este enfoque, la base de datos guarda una representación estable en string, mientras que tu código de replay siempre recibe un payload con forma de objeto.

Prueba los Casos Límite de Forma Explícita

Utilidades como esta parecen simples, pero los edge cases son justamente lo importante. Escribe pruebas enfocadas para:

  • entrada vacía
  • objeto JSON vacío
  • arrays asociativos
  • arrays indexados
  • valores JSON escalares
  • JSON inválido
  • strings JSON doblemente codificados
  • comportamiento de round-trip

Una prueba compacta de round-trip es especialmente útil:

php
$original = ['thought' => 'I need to search the web'];

$encoded = JsonUtil::encodeToolInput($original);
$decoded = JsonUtil::decodeToolInput($encoded);

expect($decoded)->toBe($original);

Conclusión

Cuando un payload debería ser un objeto JSON, dejar pasar el comportamiento por defecto de json_encode() y json_decode() suele ser demasiado permisivo.

Este patrón con JsonUtil te da un contrato más estricto:

  • guarda entradas vacías como {}
  • rechaza listas y payloads escalares
  • recupera strings JSON doblemente codificados
  • siempre devuelve un valor con forma de objeto al decodificar

Esa pequeña capa de normalización evita una cantidad desproporcionada de bugs en flujos de tool calling, persistencia y replay.