Skip to content

English · Español

Lab 03 — Generación de tool call dirigida por máscara

Objetivo: cerrar el bucle Fase 30 → Fase 31. Usar ministruct.JSONSchemaMask para restringir la salida de un modelo de modo que emita un tool call JSON válido; parsear el JSON; despachar a través del cliente MCP; observar el resultado. End-to-end.

Tiempo estimado: 2–3 horas.

Prereq: Lab 02 hecho (MCPClient funcionando) Y el Lab 01 de la Fase 30 hecho (JSONSchemaMask funcionando sobre el schema de conjugación).


Qué produces

En experiments/31-mask-driven-toolcall/:

  • demo.py — corre el pipeline completo.
  • transcript.json{prompt, model_output_str, parsed_tool_call, tool_result} para ~20 prompts de test.
  • results.json{total_prompts, parse_failures, dispatch_failures, tool_errors}.
  • manifest.json — versiones + seed.

Más un pequeño adaptador en src/miniagent/:

  • src/miniagent/tool_call.py — puente entre un blob de salida de decodificación restringida y un despacho de tool call.

El pipeline

prompt
MiniGPT.generate(prompt, mask=JSONSchemaMask(tool_call_schema), temperature=0.7)
  │  → "{\"name\":\"conjugate\",\"arguments\":{\"verb\":\"eat\",\"tense\":\"past_simple\",\"person\":\"3sg\"}}"
json.loads(output)
  │  → {"name": "conjugate", "arguments": {...}}
MCPClient.call_tool("conjugate", verb="eat", tense="past_simple", person="3sg")
  │  → "ate"
result printed; logged to transcript

Cada flecha es testeable. El Lab 03 las cablea juntas.

TODOs

Bloque A — el schema de envoltorio de tool call

En src/miniagent/tool_call.py, define el JSON Schema para un mensaje de tool call (no el schema de argumentos de la tool — una capa por encima):

  • def build_tool_call_schema(tool_schemas: dict[str, dict]) -> dict:
    return {
      "type": "object",
      "properties": {
        "name": {"type": "string", "enum": list(tool_schemas.keys())},
        "arguments": {  # see note below on per-tool argument schemas
          "oneOf": [
            {"type": "object", "properties": {"name": {"const": name}, ...}}
            for name, schema in tool_schemas.items()
          ]
        }
      },
      "required": ["name", "arguments"],
      "additionalProperties": False
    }
    
  • Reality check. Construir un discriminador oneOf completo es engorroso. Para el lab de la Fase 31, simplifica a una máscara de dos etapas: primero genera name bajo un schema solo-enum; luego re-instancia la máscara con el input_schema específico de la tool y genera arguments. Esto evita oneOf enteramente. Documenta la simplificación en el docstring del módulo de tool_call.py.

La variante de dos etapas en pseudocódigo:

def generate_tool_call(model, prompt, tool_schemas, *, temperature=0.7) -> dict:
    name_schema = {"type": "string", "enum": list(tool_schemas)}
    name = json.loads(model.generate(prompt + "\nname=", mask=JSONSchemaMask(name_schema)))
    arg_schema = tool_schemas[name]
    args_text = model.generate(prompt + f'\nname={name}\narguments=', mask=JSONSchemaMask(arg_schema))
    return {"name": name, "arguments": json.loads(args_text)}

Esta es la ruta pedagógica. Un sistema de producción usaría oneOf en una sola pasada — esa es una optimización de Fase 33.

Bloque B — conjunto de prompts

En experiments/31-mask-driven-toolcall/prompts.json:

  • 20 prompts cortos, cada uno diseñado para elicitar un tool call específico. Ejemplos:
    [
      {"prompt": "I need the past simple of 'eat' for he/she/it.", "expected_tool": "conjugate", "expected_args": {"verb": "eat", "tense": "past_simple", "person": "3sg"}},
      {"prompt": "Is 'go' an irregular verb?", "expected_tool": "lookup_irregular_verb", "expected_args": {"verb": "go"}},
      {"prompt": "Translate 'ate' to Spanish.", "expected_tool": "lookup_spanish", "expected_args": {"english_form": "ate"}},
      {"prompt": "Does 'he go' agree?", "expected_tool": "check_subject_verb_agreement", "expected_args": {"subject": "he", "verb_form": "go"}}
    ]
    
    (Cinco de cada tool, redacciones variadas.)
  • Estos son etiquetas oro para evaluación. No esperamos que el pequeño MiniGPT clave la selección de tools — ese es el trabajo de la Fase 32. Solo necesitamos que el parse tenga éxito.

Bloque C — el bucle del demo

En experiments/31-mask-driven-toolcall/demo.py:

  • Inicializa:
  • Carga MiniGPT (de donde sea que las Fases 26-29 lo dejaron; si el modelo no está listo, mockéalo con un stub de salida fija que emita las etiquetas oro — esto es aceptable para la Fase 31 ya que la ruta decodificación-restringida-más-despacho es lo que estamos testeando, no la calidad del modelo).
  • JSONSchemaMask por tool (cache).
  • with MCPClient(["python", "-m", "miniagent.mcp_server"]) as client: client.initialize(); client.list_tools().
  • Para cada prompt:
  • Genera el name bajo la máscara solo-name.
  • Genera los args bajo la máscara por-tool.
  • parsed = {"name": name, "arguments": json.loads(args_text)}.
  • Valida que parsed sea un tool call bien formado (build_tool_call_schema valida).
  • try: result = client.call_tool(**parsed) ; except ToolError as e: result = {"error": str(e)}.
  • Append {prompt, model_output_str, parsed_tool_call, tool_result} a transcript.json.
  • Agrega contadores → results.json.

Bloque D — aserciones

La afirmación operacional del lab (de theory/01-function-calling-formats.md §"La cuestión del formato de argumentos"): bajo la máscara, los fallos de parseo son imposibles.

  • Aserta results["parse_failures"] == 0. Si algún prompt produjo JSON no parseable, la máscara está rota — depura el JSONSchemaMask de la Fase 30, no el despacho.
  • Aserta que cada tool_result es o bien una salida de tool válida (string o dict) o bien un texto de ToolError (fallo lógico de la tool, vale — es prerrogativa de la tool).
  • No assertes la precisión de selección de tools. El modelo es pequeño; la selección de tools es Fase 32 con planificación. Estamos testeando el plumbing, no el cerebro.

Bloque E — medición

Tiempos por prompt escritos a timings.json:

  • mask_construction_ms — cuánto se tarda en construir JSONSchemaMask por tool.
  • generation_ms — tiempo del lado del modelo gastado generando el JSON.
  • parse_msjson.loads (siempre microsegundos).
  • dispatch_ms — round-trip a través del cliente MCP al servidor y vuelta.
  • total_ms.

Reporta el agregado: media, P50, P95. Esta es la latencia base de la Fase 31; la Fase 33 comparará.

Bloque F — loguea un ejemplo exitoso representativo

En transcript.json, la primera entrada debe ser un caso demostrativo limpio para el report de la fase:

{
  "prompt": "I need the past simple of 'eat' for he/she/it.",
  "model_output_name": "conjugate",
  "model_output_args": "{\"verb\":\"eat\",\"tense\":\"past_simple\",\"person\":\"3sg\"}",
  "parsed_tool_call": {"name": "conjugate", "arguments": {"verb": "eat", "tense": "past_simple", "person": "3sg"}},
  "tool_result": "ate",
  "timings_ms": {"mask_construction": 1.4, "generation": 142.7, "parse": 0.1, "dispatch": 8.3, "total": 152.5}
}

Este es el artefacto que va a PHASE_31_REPORT.md.

Restricciones

  • Sin reentrenamiento del modelo. Usa el MiniGPT que exista en la apertura de la fase. Si no puede hacer tool call (o no existe), usa el stub (nota del Bloque C) y documéntalo.
  • Un error de tool está bien. Una generación enmascarada que produce un tool call con JSON válido pero una llamada lógicamente equivocada (p. ej., conjugate("be", "past_simple", "3sg") devolviendo "was" cuando el prompt era sobre eat) es un fallo de corrección del modelo, no un fallo del lab.
  • Sin retries en fallo de parseo. Si parse_failures > 0, arregla la máscara, no lo tapes con retries.

Condiciones de parada

Hecho cuando:

  1. results["parse_failures"] == 0 sobre 20 prompts.
  2. transcript.json existe con 20 entradas.
  3. Tiempos agregados escritos.
  4. Un caso de éxito representativo seleccionado y copiado-pegado en el borrador del report de la fase.

Pitfalls

  • La máscara solo restringe lo que está entre los corchetes. La prosa circundante ("Sure, here you go: {...}") aún corrompe el JSON. O prefija la generación con { (para que el modelo no tenga que elegir empezar el JSON) o usa un schema exterior que incluya el token de llave inicial en su conjunto de aceptación. El Lab 01 de la Fase 30 debería haber manejado esto; si no, arréglalo ahí.
  • La generación de dos etapas re-ancla el contexto. Cuando generas arguments= tras generar name=, el contexto previo del modelo difiere del de una generación de una sola pasada. Esto puede dañar la precisión. Aceptable para la Fase 31; la Fase 33 puede unificar en una sola máscara.
  • oneOf y JSON Schema. Muchos validadores JSON-Schema manejan oneOf de forma diferente. No usamos oneOf en tiempo de decodificación (usamos el atajo de dos etapas). lo usamos en build_tool_call_schema para validación; asegúrate de que tu versión de librería jsonschema lo soporta (lo hace, Draft7+).
  • Caching de máscaras. Construir un JSONSchemaMask desde cero en cada llamada es caro. Cachea por (schema_id, tokenizer_id). Esta es la única optimización permitida en el Lab 03 — cualquier otra (p. ej., llamadas en paralelo, batch de peticiones) espera a la Fase 33.

Cuándo consultar solutions/

Tras results["parse_failures"] == 0 y tener 20 entradas de transcript limpias. La solución en solutions/03-mask-driven-toolcall-ref.md recorre cómo la implementación de referencia manejó la simplificación de dos etapas y cómo habría sido oneOf si hubiéramos unificado.


Fin de labs de la Fase 31. Siguiente: escribir PHASE_31_REPORT.md, luego abrir la Fase 32 (docs/phase-32-agents/).