DEV Community

Cover image for Construye un agente de desarrollo .NET, Parte 2: dale voz
Juan Gómez
Juan Gómez

Posted on

Construye un agente de desarrollo .NET, Parte 2: dale voz

Construye un agente de desarrollo .NET, Parte 2: dale voz

La primera vez que mi agente de desarrollo me respondió en voz alta, tardó sesenta y ocho segundos en decirme que habían fallado cuatro tests.

La respuesta era correcta, y completa: los cuatro nombres de test leídos enteros, AddedLines_reports_new_file_numbers_across_hunks incluido, cada uno con su número de línea y su valor esperado, y al final un párrafo ofreciendo revisar el parser. En pantalla, esa respuesta se lee de un vistazo. Con audífonos, es un minuto entero quieto, esperando la única frase que importaba, que llegó al final: todos los fallos estaban desfasados por uno.

Una onda de audio larga en cuatro tramos apagados, con la única frase útil encendida justo al final.

Conectar dictado y síntesis de voz a un agente lleva una tarde. Este post trata de lo que pasa después de esa tarde, cuando descubres dos cosas. Una respuesta escrita para leerse tiene la forma equivocada para escucharse. Y la transcripción que recibe tu agente se equivoca más o menos en una de cada dos palabras. Los dos son problemas de diseño, no de audio, y todo lo que sigue está medido sobre el agente de la Parte 1.

Dónde dejó la Parte 1 al agente

Construye un agente de desarrollo .NET, Parte 1: enséñale tu flujo de trabajo real montó tres herramientas tipadas sobre un ciclo de tool_use de Claude: git_status, dotnet_build y dotnet_test, cada una filtrando la salida de su comando hasta dejar solo lo que un modelo necesita. Las tres pasan tal cual, con una excepción: el método RunAsync de la Parte 1 tenía un bug que solo aparece en una máquina con dos SDKs de .NET, y escribir este post lo destapó. La Parte 1 ya tiene la corrección; los detalles están cerca del final.

Este es el ciclo completo:

  1. Mantienes una tecla, hablas, sueltas.
  2. Un Whisper local convierte el audio en texto.
  3. El agente trabaja con las herramientas de la Parte 1 y una nueva.
  4. Una voz de Piper, también local, lee la respuesta, y lo que no conviene leer va a la pantalla.

Todo lo que toca audio corre en tu máquina, con modelos de código abierto, y no necesitas cuenta en ningún servicio salvo el de Claude.

Dos contenedores

El dictado lo hace whisper-server, de whisper.cpp: recibe un WAV por HTTP y devuelve JSON. La síntesis la hace Piper, cuyo servidor HTTP recibe JSON y devuelve un WAV. Los dos corren en CPU.

services:
  whisper:
    # v1.9.5. La imagen se etiqueta por commit; ":main" cambia todos los días.
    image: ghcr.io/ggml-org/whisper.cpp:main-d1be6fde11ac6e0407606b4e42fe72d34add8037
    # El entrypoint de la imagen es `bash -c`, que solo ejecuta su PRIMER argumento. Un
    # texto plano se parte en palabras: cada flag después de "whisper-server" se pierde en
    # silencio, y arranca con el modelo que trae la imagen, escuchando en 127.0.0.1.
    # Una lista de un solo elemento mantiene la línea de comando entera como ese argumento.
    command: ["whisper-server --host 0.0.0.0 --port 8080 --model /models/ggml-${WHISPER_MODEL:-tiny.en}.bin --threads 4"]
    volumes:
      - ./models:/models:ro
    ports:
      - "127.0.0.1:8080:8080"

  piper:
    build: ./piper
    ports:
      - "127.0.0.1:5000:5000"
Enter fullscreen mode Exit fullscreen mode

Piper no tiene una imagen oficial con su servidor HTTP, así que le toca un piper/Dockerfile de cinco líneas. La voz se descarga al construir la imagen, para que el contenedor arranque sin red:

FROM python:3.12-slim
RUN pip install --no-cache-dir "piper-tts[http]==1.8.0"
WORKDIR /voices
# La voz queda dentro de la imagen, así el contenedor arranca sin red.
RUN python -m piper.download_voices en_US-lessac-medium --data-dir /voices
EXPOSE 5000
CMD ["python", "-m", "piper.http_server", "--host", "0.0.0.0", "--port", "5000", "--data-dir", "/voices", "-m", "en_US-lessac-medium"]
Enter fullscreen mode Exit fullscreen mode

El modelo de Whisper es un archivo que descargas una vez en models/:

curl -L -o models/ggml-tiny.en.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin
docker compose up -d
Enter fullscreen mode Exit fullscreen mode

El comentario sobre command: está ahí porque me costó una hora. La imagen de whisper.cpp usa bash -c como entrypoint, y bash -c solo ejecuta su primer argumento. Si escribes el comando como texto plano, Compose lo parte en palabras: bash -c recibe whisper-server como script y el resto de los flags como parámetros que nadie lee. El servidor arranca igual, con el modelo que trae la imagen y escuchando en 127.0.0.1, donde nada fuera del contenedor puede alcanzarlo. Si lo escribes como texto de varias líneas, falla de forma más discreta: el salto de línea termina el comando, así que el servidor escucha donde le pediste, pero carga el modelo de la imagen. Mi primera prueba pidió large-v3-turbo, recibió base.en, y pasé un buen rato culpando a mi acento. La lista de un solo elemento mantiene la línea de comando entera.

Fija también la imagen por commit. :main se reconstruye a diario, y la etiqueta de arriba es la que se construyó desde la versión v1.9.5.

Captura: pulsar para hablar, en el formato que pide Whisper

Del lado de .NET hace falta una sola dependencia nativa para el audio. PortAudioSharp2 trae los binarios de PortAudio para Windows, Linux y macOS dentro del paquete, así que no hay nada que instalar:

dotnet new console -n VoiceDevAgent
dotnet add package Anthropic --version 12.57.0
dotnet add package PortAudioSharp2 --version 1.0.6
Enter fullscreen mode Exit fullscreen mode

La grabación es a 16 kHz, mono y 16 bits, exactamente lo que consume Whisper, así que nadie remuestrea nada. PortAudio entrega el audio desde su propio hilo mediante un callback, y ese callback tiene un único trabajo: copiar las muestras a algún lado y volver, sin esperar nunca al resto del programa. Un Channel<T> es el lugar indicado, porque escribir en uno sin límite nunca bloquea.

using System.Buffers.Binary;
using System.Runtime.InteropServices;
using System.Threading.Channels;
using PortAudioSharp;
using PaStream = PortAudioSharp.Stream;

/// <summary>Captura al pulsar para hablar: 16 kHz, mono, 16 bits, el formato que pide Whisper, así nadie remuestrea.</summary>
sealed class Microphone
{
    public const int SampleRate = 16_000;

    public static async Task<byte[]> RecordAsync(Func<CancellationToken, Task> waitForStop, CancellationToken ct)
    {
        // El callback corre en el hilo de audio de PortAudio. Nunca debe esperar a nada de lo
        // que hace el resto del programa, así que solo copia las muestras a un canal sin
        // límite, cuyas escrituras nunca bloquean.
        Channel<short[]> chunks = Channel.CreateUnbounded<short[]>(new() { SingleReader = true, SingleWriter = true });

        PaStream.Callback callback = (IntPtr input, IntPtr output, uint frameCount,
            ref StreamCallbackTimeInfo timeInfo, StreamCallbackFlags flags, IntPtr userData) =>
        {
            short[] chunk = new short[frameCount];
            Marshal.Copy(input, chunk, 0, (int)frameCount);
            chunks.Writer.TryWrite(chunk);
            return StreamCallbackResult.Continue;
        };

        int device = PortAudio.DefaultInputDevice;
        if (device == PortAudio.NoDevice) throw new InvalidOperationException("No hay dispositivo de entrada.");

        StreamParameters parameters = new()
        {
            device = device,
            channelCount = 1,
            sampleFormat = SampleFormat.Int16,
            suggestedLatency = PortAudio.GetDeviceInfo(device).defaultLowInputLatency,
            hostApiSpecificStreamInfo = IntPtr.Zero,
        };

        using (PaStream stream = new(parameters, null, SampleRate, 0, StreamFlags.ClipOff, callback, null!))
        {
            stream.Start();
            try
            {
                await waitForStop(ct);
            }
            finally
            {
                stream.Stop();          // espera a que termine el último callback
                chunks.Writer.Complete();
            }
        }

        List<short> samples = [];
        await foreach (short[] chunk in chunks.Reader.ReadAllAsync(CancellationToken.None))
            samples.AddRange(chunk);

        return Wav.Encode([.. samples], SampleRate);
    }
}

/// <summary>Reproduce PCM mono de 16 bits y se detiene en cuanto se cancela el token.</summary>
static class Speaker
{
    static readonly short[] Silence = new short[4096];

    public static async Task PlayAsync(byte[] wav, CancellationToken ct)
    {
        (short[] samples, int sampleRate) = Wav.Decode(wav);
        int position = 0;
        TaskCompletionSource finished = new(TaskCreationOptions.RunContinuationsAsynchronously);

        PaStream.Callback callback = (IntPtr input, IntPtr output, uint frameCount,
            ref StreamCallbackTimeInfo timeInfo, StreamCallbackFlags flags, IntPtr userData) =>
        {
            int remaining = samples.Length - position;
            int count = Math.Min(remaining, (int)frameCount);
            if (count > 0) Marshal.Copy(samples, position, output, count);

            // Rellena el último búfer con silencio en vez de reproducir memoria vieja,
            // sin asignar memoria en el hilo de audio.
            for (int padded = count; padded < frameCount; )
            {
                int n = Math.Min(Silence.Length, (int)frameCount - padded);
                Marshal.Copy(Silence, 0, output + padded * sizeof(short), n);
                padded += n;
            }

            position += count;
            return position >= samples.Length ? StreamCallbackResult.Complete : StreamCallbackResult.Continue;
        };

        int device = PortAudio.DefaultOutputDevice;
        if (device == PortAudio.NoDevice) throw new InvalidOperationException("No hay dispositivo de salida.");

        StreamParameters parameters = new()
        {
            device = device,
            channelCount = 1,
            sampleFormat = SampleFormat.Int16,
            suggestedLatency = PortAudio.GetDeviceInfo(device).defaultHighOutputLatency,
            hostApiSpecificStreamInfo = IntPtr.Zero,
        };

        using PaStream stream = new(null, parameters, sampleRate, 0, StreamFlags.ClipOff, callback, null!);
        stream.SetFinishedCallback(_ => finished.TrySetResult());
        stream.Start();

        try
        {
            await finished.Task.WaitAsync(ct);
        }
        catch (OperationCanceledException)
        {
            // Interrupción: descarta el audio del búfer en vez de reproducirlo hasta el final.
            // El callback de fin igual se dispara después; completar una tarea ya abandonada
            // no tiene efecto.
            stream.Abort();
        }
    }
}

static class Wav
{
    public static byte[] Encode(short[] samples, int sampleRate)
    {
        byte[] wav = new byte[44 + samples.Length * 2];
        Span<byte> h = wav;
        "RIFF"u8.CopyTo(h);
        BinaryPrimitives.WriteInt32LittleEndian(h[4..], wav.Length - 8);
        "WAVEfmt "u8.CopyTo(h[8..]);
        BinaryPrimitives.WriteInt32LittleEndian(h[16..], 16);              // tamaño del chunk fmt
        BinaryPrimitives.WriteInt16LittleEndian(h[20..], 1);               // PCM
        BinaryPrimitives.WriteInt16LittleEndian(h[22..], 1);               // mono
        BinaryPrimitives.WriteInt32LittleEndian(h[24..], sampleRate);
        BinaryPrimitives.WriteInt32LittleEndian(h[28..], sampleRate * 2);  // bytes por segundo
        BinaryPrimitives.WriteInt16LittleEndian(h[32..], 2);               // alineación de bloque
        BinaryPrimitives.WriteInt16LittleEndian(h[34..], 16);              // bits por muestra
        "data"u8.CopyTo(h[36..]);
        BinaryPrimitives.WriteInt32LittleEndian(h[40..], samples.Length * 2);
        MemoryMarshal.AsBytes(samples.AsSpan()).CopyTo(h[44..]);
        return wav;
    }

    /// <summary>Recorre los chunks en vez de suponer una cabecera de 44 bytes: la de Piper lo es, otras no.</summary>
    public static (short[] Samples, int SampleRate) Decode(byte[] wav)
    {
        ReadOnlySpan<byte> s = wav;
        if (!s[..4].SequenceEqual("RIFF"u8) || !s[8..12].SequenceEqual("WAVE"u8))
            throw new InvalidDataException("No es un archivo RIFF/WAVE.");

        int sampleRate = 0, offset = 12;
        while (offset + 8 <= s.Length)
        {
            ReadOnlySpan<byte> id = s.Slice(offset, 4);
            // Quien escribe en streaming pone aquí 0xFFFFFFFF ("desconocido"): toma el resto.
            uint declared = BinaryPrimitives.ReadUInt32LittleEndian(s[(offset + 4)..]);
            int size = (int)Math.Min(declared, (uint)(s.Length - offset - 8));
            ReadOnlySpan<byte> body = s.Slice(offset + 8, size);

            if (id.SequenceEqual("fmt "u8))
            {
                if (body.Length < 16) throw new InvalidDataException("Chunk fmt demasiado corto.");
                short channels = BinaryPrimitives.ReadInt16LittleEndian(body[2..]);
                short bits = BinaryPrimitives.ReadInt16LittleEndian(body[14..]);
                if (channels != 1 || bits != 16) throw new InvalidDataException($"Se esperaba mono de 16 bits; llegó {bits} bits x{channels}.");
                sampleRate = BinaryPrimitives.ReadInt32LittleEndian(body[4..]);
            }
            else if (id.SequenceEqual("data"u8))
            {
                if (sampleRate == 0) throw new InvalidDataException("Chunk data antes del chunk fmt.");
                return (MemoryMarshal.Cast<byte, short>(body).ToArray(), sampleRate);
            }
            offset += 8 + size + (size & 1);    // los chunks se alinean a 2 bytes
        }
        throw new InvalidDataException("No hay chunk data.");
    }

    public static TimeSpan Duration(byte[] wav)
    {
        (short[] samples, int rate) = Decode(wav);
        return TimeSpan.FromSeconds((double)samples.Length / rate);
    }
}
Enter fullscreen mode Exit fullscreen mode

Tres detalles de ese archivo merecen una frase. stream.Stop() espera a que termine el último callback, así que el canal puede cerrarse justo después sin perder el último bloque. El callback de captura sigue asignando un arreglo pequeño por cada búfer. .NET no da garantías de tiempo real de ninguna forma; si las necesitas, graba en un búfer circular preasignado. La reproducción rellena su último búfer con un bloque de silencio preasignado. Y no hay GC.KeepAlive sobre los callbacks porque PortAudioSharp2 fija cada delegado con un GCHandle mientras vive el stream. Si escribes tu propio binding de PortAudio, fijarlos te toca a ti, o el siguiente callback salta a memoria liberada.

Speaker.PlayAsync recibe un token de cancelación para poder interrumpir (barge-in): cuando ya oíste suficiente, Enter detiene el audio con Abort(), que descarta lo que quedaba en el búfer en lugar de reproducirlo hasta el final.

Por qué pulsar para hablar y no escuchar siempre

Un micrófono siempre abierto parece más amable, y para esta herramienta es peor en tres aspectos que se pueden medir:

  • Se oye a sí mismo. Con los altavoces de la laptop, la respuesta vuelve directo al micrófono y llega como una pregunta nueva.
  • Whisper falla en los bordes del habla. Con mis ocho frases grabadas, tiny.en devolvió [BLANK_AUDIO] para dos que se oían perfectamente, y base.en convirtió una de ellas, una pregunta sobre CS1061, en "Peace. See you in the next video. Bye." Para ser justos con Whisper, se entrenó con mucho YouTube. Un micrófono siempre abierto le entrega muchos más bordes que una tecla.
  • Pierdes los límites. Al pulsar dices ahora te hablo a ti, y al soltar dices terminé. Escuchando siempre, necesitas detección de actividad de voz para adivinar las dos cosas, y cada error de esa detección es un turno.

Por eso el arnés filtra las marcas de silencio de Whisper antes de que lleguen al modelo; lo verás más abajo en VoiceAgent.IsBlank. Pulsar la tecla sin querer no cuesta nada ni ejecuta nada.

Dictado: mal en una de cada dos palabras

Los clientes HTTP son cortos:

using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Serialization;

/// <summary>whisper-server de whisper.cpp: POST /inference, multipart, devuelve {"text": "..."}.</summary>
sealed class WhisperClient(HttpClient http)
{
    public async Task<string> TranscribeAsync(byte[] wav, string? vocabulary, string? language, CancellationToken ct)
    {
        using MultipartFormDataContent form = new();
        ByteArrayContent file = new(wav);
        file.Headers.ContentType = new MediaTypeHeaderValue("audio/wav");
        form.Add(file, "file", "speech.wav");
        form.Add(new StringContent("json"), "response_format");
        form.Add(new StringContent("0.0"), "temperature");
        if (vocabulary is not null) form.Add(new StringContent(vocabulary), "prompt");
        if (language is not null) form.Add(new StringContent(language), "language");

        using HttpResponseMessage response = await http.PostAsync("/inference", form, ct);
        response.EnsureSuccessStatusCode();
        WhisperResult result = await response.Content.ReadFromJsonAsync<WhisperResult>(ct)
            ?? throw new InvalidDataException("whisper-server devolvió una respuesta vacía.");
        return result.Text.Trim();
    }

    sealed record WhisperResult([property: JsonPropertyName("text")] string Text);
}

/// <summary>Servidor HTTP de Piper: POST /synthesize con {"text": "..."}, devuelve un WAV.</summary>
sealed class PiperClient(HttpClient http)
{
    public async Task<byte[]> SynthesizeAsync(string text, CancellationToken ct)
    {
        using HttpResponseMessage response = await http.PostAsJsonAsync("/synthesize", new { text }, ct);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsByteArrayAsync(ct);
    }
}
Enter fullscreen mode Exit fullscreen mode

prompt es el prompt inicial de Whisper: texto que trata como si hubiera sonado justo antes del audio. Pasarle ahí el vocabulario del proyecto es la mejora de precisión más barata que existe, y actúa justo sobre las palabras que se rompen.

Para saber cuánto se rompen, grabé los ocho pedidos que de verdad le hago al agente (Why is the build red?, Is CS1061 coming from the rename I did in ChangedFile?, Don't run the tests yet, just build.) con mi voz, mi acento y un headset USB, a velocidad normal. Después pasé cada clip por cuatro modelos de Whisper, con y sin el prompt de vocabulario. Es una sola voz y son ocho frases: tómalo como una anécdota medida, no como un benchmark.

La máquina es una laptop con un i7-1165G7 de 4 núcleos limitado a 1,69 GHz por energía, Docker en Windows y 4 hilos. La latencia es la mediana por frase; la columna del encoder sale del propio whisper-bench de whisper.cpp:

Modelo Tasa de error por palabra (sin prompt / con prompt) Encoder, una ventana Mediana por frase
tiny.en 0,76 / 0,68 1,4 s 1,8 s
base.en 0,66 / 0,59 3,8 s 5,6 s
small.en 0,64 / 0,48 12,3 s 13,4 s
large-v3-turbo (q5_0) 0,48 / 0,41 77,6 s sin medir, más de un minuto

Según la tasa de error por palabra, ningún modelo transcribió bien ni una sola de las ocho frases, aunque algunos fallos fueron tan pequeños como "test" por "tests". Why is the build red? volvió como "What is the view right?" en tres de los cuatro. Lo que se rompió fueron las palabras del propio proyecto: ClaudeReviewBot se volvió "cloud review bot" y "Cloud Review", ChangedFile se volvió "change flight" y "Change. Fight.", origin main se volvió "originally". El prompt arregló varias de esas, y tuvo su costo: en un clip, small.en devolvió pedazos del prompt, "CS1061, nullable, origin main, IDP, ChangedFile.", en lugar de lo que dije.

La columna del encoder explica casi toda la latencia. El encoder de Whisper siempre procesa una ventana completa de 30 segundos, así que a él una frase de cuatro segundos le cuesta lo mismo que una de treinta. El resto es la decodificación, y de ahí salen los casos extremos: cuando el decodificador alucina, sigue y sigue. Ese clip con prompt le llevó 31,2 segundos a tiny.en y 38,6 a small.en. En este hardware, solo tiny.en y base.en responden a ritmo de conversación, y son justo los dos menos precisos.

Un resultado más, que no estaba en el plan. En la prueba en vivo dije mi primer pedido en español sin pensarlo. tiny.en solo entiende inglés, y no falló de forma visible: "¿Por qué está rojo el build?" volvió como "Okay, let's start from a little bit." Este no me avisó de que no me entendía. Me devolvió una frase fluida en su propio idioma.

Micrófono, dictado, agente y altavoz: la transcripción llega revuelta y el agente la vuelve a unir en un solo pedido.

Diseña para la transcripción que vas a recibir

Si en una laptop el dictado no se puede volver preciso, el agente tiene que arreglárselas con lo que llega. Así que le pasé al agente cada transcripción distinta de esa tabla y anoté qué herramienta pedía primero, sin ejecutar nada: 64 transcripciones, tres corridas cada una, con Claude Haiku 5.5.

Transcripciones de Herramienta correcta Preguntó o no hizo nada Herramienta equivocada Silencio, filtrado
Las frases exactas 8 0 0 0
large-v3-turbo + prompt 8 0 0 0
large-v3-turbo, sin prompt 7 1 0 0
small.en + prompt 7 0 1 0
small.en, sin prompt 6 2 0 0
base.en + prompt 6 0 1 1
base.en, sin prompt 5 1 1 1
tiny.en + prompt 6 1 0 1
tiny.en, sin prompt 5 1 0 2

Cada celda cuenta frases, según el veredicto en el que coincidieron la mayoría de las tres corridas.

"What is the view right?" ejecutó dotnet_build todas las veces. "Bravo to test, until which ones failed" ejecutó dotnet_test. "We did a changed size minus minus moment" ejecutó git_status, que es lo más cerca de "qué cambié desde mi último commit" que uno podría esperar. Cuando la transcripción podía significar dos cosas, preguntó: "Did you want me to run the build, or are you just letting me know what you're working on?"

El pedido con negación, Don't run the tests yet, just build, no ejecutó dotnet_test en ninguna variante. Era el que más me preocupaba.

Los fallos fueron fallos de verdad, no un criterio demasiado estricto. "Did any nullable warnings show up" perdió la palabra warnings en seis transcripciones (en cinco se oyó "nullable coordinates"), y sin ella, el agente a veces entendió la pregunta como si fuera sobre cambios o sobre tests. "What is the new thread?" ejecutó git_status tres veces de tres.

Nada de esto funciona porque el modelo sea listo con el audio. Funciona porque el system prompt le dice que la transcripción viene mal, le da dos ejemplos de cómo, y le dice qué hacer cuando dos lecturas llevan a acciones distintas. Y porque el arnés imprime heard: "..." en cada turno: cuando una sola palabra mal oída cambia la pregunta, lo ves antes de creerte la respuesta.

Lo primero que se rompe: todo lo que dice

Esta es la respuesta del principio, completa. Es el system prompt de la Parte 1, sin cambios, con la respuesta enviada al altavoz:

4 of 91 tests failed, all in `PatchParserTests` (`tests/ClaudeReviewBot.Tests/PatchParserTests.cs`):

1. **`AddedLines_reports_new_file_numbers_across_hunks`** (line 29): expected `[15, 19, 20, 21]`, got `[15, 20, 21, 22]`.
2. **`ParseUnifiedDiff_splits_per_file_and_strips_the_b_prefix`** (line 109): expected first element `1`, got `2`.
3. **`AddedLines_removed_lines_do_not_advance_the_counter`** (line 43): expected first element `2`, got `3`.
4. **`AddedLines_ignores_no_newline_marker_and_crlf`** (line 62): expected first element `2`, got `3`.

The failures are all in line-number tracking, and the actual values are consistently one higher or shifted by one. That suggests an off-by-one in how the parser counts new-file line numbers, but I haven't read the parser code to confirm. I can look at the parser in `src/ClaudeReviewBot.Core` next if you want.
Enter fullscreen mode Exit fullscreen mode

Como respuesta escrita está muy bien. Piper la lee en 67,9 segundos. El diagnóstico, desfasado por uno, empieza después del cuarto nombre de test. Si en cambio le pides que lea la salida cruda de dotnet_test de la Parte 1, los 1.600 caracteres con stack frames, tarda 109,4 segundos.

El arreglo tiene dos mitades, y la que importa es la segunda.

Dale al agente otro lugar donde poner las cosas. Una cuarta herramienta, show_on_screen, solo imprime. Con ella, "no leas rutas en voz alta" deja de ser una simple prohibición: el agente tiene adónde mandarlas.

Reescribe el system prompt para alguien que escucha. Dos frases como máximo. Nunca digas una ruta, un stack frame, un hash ni un identificador completo. Empieza por la respuesta. Esta es la misma pregunta, el mismo repositorio y el mismo modelo, con el prompt nuevo:

Four tests failed, all in PatchParserTests, and they all show line numbers off by one. Details are on screen.
Enter fullscreen mode Exit fullscreen mode

Los cuatro nombres de test, las líneas y los arreglos fueron a show_on_screen. Probé los dos prompts dos veces, con dos estados rotos del repositorio:

Escenario Prompt de la Parte 1, hablado Prompt de voz, hablado
Cuatro tests fallando 88–105 palabras, 57,2–67,9 s 19–21 palabras, 6,5–7,2 s
Un error de compilación 103–120 palabras, 46,7–53,2 s 20 palabras, 7,1–7,2 s

Y el diagnóstico pasó del último párrafo a la primera frase, porque "empieza por la respuesta" también obliga a encontrar la respuesta antes de hablar. No sale gratis. Cada turno hace una llamada más a la API, la de show_on_screen, y envía entre dos y tres mil tokens más de entrada, que con Haiku es menos de una décima de centavo. Y tarda más en empezar a hablar: los turnos con el prompt de voz tomaron entre 8,3 y 19,9 segundos, contra 6,1 a 12,2 con el de la Parte 1, herramientas incluidas. Esperas unos segundos más, y después escuchas siete segundos en lugar de un minuto.

Dos paneles: a la izquierda, una tira larga de globos de diálogo; a la derecha, un globo corto y el detalle enviado a un monitor.

El agente

Este es el agente completo. El ciclo es el de la Parte 1, las tres herramientas son las de la Parte 1, y lo nuevo es el prompt, show_on_screen, el filtro de silencio y el límite de longitud del final. El system prompt queda en inglés a propósito: la voz de Piper es inglesa, y este prompt, tal cual, es el que medí. Traducido, el agente seguramente respondería en español (en mis pruebas responde en el idioma en que le hablas), y una voz inglesa leyendo español sería otro experimento.

using System.ComponentModel;
using System.Text.Json;
using System.Text.RegularExpressions;
using Anthropic;
using Anthropic.Models.Messages;

/// <summary>
/// El ciclo de tool_use de la Parte 1 con dos canales de salida: el texto final se dice en voz
/// alta, y lo que no se puede escuchar va a la pantalla mediante la herramienta show_on_screen.
/// </summary>
sealed class VoiceAgent(AnthropicClient client, string repo)
{
    const string Model = "claude-haiku-5-5";

    // Unos diez segundos de escucha al ritmo de Piper. Lo más largo va a la pantalla.
    const int MaxSpokenWords = 30;

    const string SystemPrompt = """
        You are a development assistant for the ClaudeReviewBot repository, and the
        developer is TALKING to you. Their words arrive through speech-to-text, and your
        reply is read aloud by a text-to-speech voice.

        Three projects. src/ClaudeReviewBot.Core holds the logic and is the one that
        matters. src/ClaudeReviewBot is a thin CLI wrapper. tests/ is xUnit.
        TreatWarningsAsErrors is on. CS1591 is noise; nullable warnings are real defects.

        The transcript is often wrong. Speech-to-text mangles identifiers and jargon:
        "ChangedFile" may arrive as "changed file" or "unchanged file", "build red" as
        "view right". Read for intent. If you can tell what was meant, act on it. If two
        readings would lead to different actions, ask one short question instead of guessing.

        Your reply is heard, not read. So:
        - Speak at most two short sentences. No lists, no markdown, no code.
        - Never say a file path, stack frame, hash, or full identifier aloud. Put them on
          the screen with show_on_screen, then say what matters and "details on screen".
        - Lead with the answer: "Build's red, three errors, all from your rename."

        Check state before answering. If the question is about the build, build. If it is
        about what changed, call git_status. Do not speculate.
        """;

    static readonly List<ToolUnion> Tools =
    [
        new Tool
        {
            Name = "git_status",
            Description = "Branch, ahead/behind counts, and changed and untracked files in the working copy.",
            InputSchema = new() { Properties = new Dictionary<string, JsonElement>() },
        },
        new Tool
        {
            Name = "dotnet_build",
            Description = "Builds the solution, or one project, and returns deduplicated errors and warnings.",
            InputSchema = new()
            {
                Properties = new Dictionary<string, JsonElement>
                {
                    ["project"] = JsonSerializer.SerializeToElement(new
                    {
                        type = "string",
                        description = "Optional project path relative to the repository root.",
                    }),
                },
            },
        },
        new Tool
        {
            Name = "dotnet_test",
            Description = "Runs the test suite and returns pass/fail counts plus each failure's message and own-code frames.",
            InputSchema = new() { Properties = new Dictionary<string, JsonElement>() },
        },
        new Tool
        {
            Name = "show_on_screen",
            Description = "Shows text on the developer's screen without reading it aloud. Use for paths, " +
                          "diagnostics, stack frames, lists: anything too long or too precise to listen to.",
            InputSchema = new()
            {
                Properties = new Dictionary<string, JsonElement>
                {
                    ["text"] = JsonSerializer.SerializeToElement(new { type = "string" }),
                },
                Required = ["text"],
            },
        },
    ];

    readonly List<MessageParam> messages = [];

    /// <summary>
    /// Whisper marca el silencio y el ruido con etiquetas entre corchetes: [BLANK_AUDIO],
    /// [inaudible], (silence), [Music]. Una transcripción que solo tiene etiquetas no es un
    /// pedido: pulsar la tecla sin querer no debe costar una llamada ni ejecutar herramientas.
    /// </summary>
    public static bool IsBlank(string transcript) =>
        Regex.Replace(transcript, @"\[[^\]]*\]|\([^)]*\)", "").Trim().Trim('.', ' ', '>').Length == 0;

    /// <summary>Entra un pedido hablado, sale una respuesta hablada. Lo de pantalla va a <paramref name="screen"/>.</summary>
    public async Task<string> AskAsync(string transcript, Action<string> screen, CancellationToken ct)
    {
        if (IsBlank(transcript)) return "";

        int turnStart = messages.Count;
        messages.Add(new() { Role = Role.User, Content = transcript });

        try
        {
            string? reply = await RunTurnAsync(screen, ct);
            if (reply is not null) return reply;
        }
        catch
        {
            // Una petición fallida puede dejar un tool_use sin su tool_result al final del
            // historial, y todas las peticiones siguientes serían rechazadas. Se descarta el turno.
            messages.RemoveRange(turnStart, messages.Count - turnStart);
            throw;
        }

        messages.RemoveRange(turnStart, messages.Count - turnStart);
        return "I went in circles on that one. Try asking differently.";
    }

    /// <summary>Devuelve la respuesta hablada, o null si se agotó el límite de pasos.</summary>
    async Task<string?> RunTurnAsync(Action<string> screen, CancellationToken ct)
    {
        for (int step = 0; step < 8; step++)
        {
            Message response = await client.Messages.Create(new MessageCreateParams
            {
                Model = Model,
                MaxTokens = 4096,
                System = new List<TextBlockParam> { new() { Text = SystemPrompt, CacheControl = new CacheControlEphemeral() } },
                Tools = Tools,
                OutputConfig = new OutputConfig { Effort = Effort.Low },
                Messages = messages,
            }, ct);

            if (response.StopReason == "refusal")
            {
                // Haiku 5.5 no tiene respaldo en el servidor: la petición queda rechazada.
                throw new RefusalException();
            }

            // Devuelve cada bloque sin cambios, incluidos los de thinking aunque su texto
            // venga vacío, o la siguiente petición no pasa la validación del historial.
            List<ContentBlockParam> echo = [];
            List<ContentBlockParam> results = [];
            string spoken = "";

            foreach (ContentBlock block in response.Content)
            {
                if (block.TryPickThinking(out ThinkingBlock? thinking))
                {
                    echo.Add(new ThinkingBlockParam { Thinking = thinking.Thinking, Signature = thinking.Signature });
                }
                else if (block.TryPickRedactedThinking(out RedactedThinkingBlock? redacted))
                {
                    echo.Add(new RedactedThinkingBlockParam { Data = redacted.Data });
                }
                else if (block.TryPickText(out TextBlock? text))
                {
                    echo.Add(new TextBlockParam { Text = text.Text });
                    spoken += text.Text;
                }
                else if (block.TryPickToolUse(out ToolUseBlock? use))
                {
                    echo.Add(new ToolUseBlockParam { ID = use.ID, Name = use.Name, Input = use.Input });
                    (string content, bool isError) = await RunToolAsync(use, screen, ct);
                    results.Add(new ToolResultBlockParam { ToolUseID = use.ID, Content = content, IsError = isError });
                }
            }

            messages.Add(new() { Role = Role.Assistant, Content = echo });

            if (results.Count == 0) return GuardSpoken(spoken.Trim(), screen);

            // Todos los resultados de un turno vuelven en UN solo mensaje de usuario.
            messages.Add(new() { Role = Role.User, Content = results });
        }

        return null;
    }

    async Task<(string Content, bool IsError)> RunToolAsync(ToolUseBlock use, Action<string> screen, CancellationToken ct)
    {
        try
        {
            switch (use.Name)
            {
                case "git_status":
                    return (await DevTools.GitStatusAsync(repo, ct), false);
                case "dotnet_build":
                    string? project = use.Input.TryGetValue("project", out JsonElement p) && p.ValueKind == JsonValueKind.String
                        ? p.GetString()
                        : null;
                    return (await DevTools.DotnetBuildAsync(repo, project, ct), false);
                case "dotnet_test":
                    return (await DevTools.DotnetTestAsync(repo, ct), false);
                case "show_on_screen":
                    screen(use.Input["text"].GetString() ?? "");
                    return ("Shown.", false);
                default:
                    return ($"Unknown tool {use.Name}.", true);
            }
        }
        // Una herramienta que no puede correr es una respuesta para el modelo ("no pude
        // compilar"), no un cierre del programa. Process.Start lanza Win32Exception si falta
        // el ejecutable o el directorio de trabajo no existe.
        catch (Exception ex) when (ex is TimeoutException or InvalidOperationException or KeyNotFoundException
                                       or Win32Exception or IOException)
        {
            return (ex.Message, true);
        }
    }

    /// <summary>
    /// El prompt pide respuestas cortas; esto lo garantiza. Una respuesta demasiado larga para
    /// escucharla va a la pantalla, y solo se dice su primera frase.
    /// </summary>
    static string GuardSpoken(string spoken, Action<string> screen)
    {
        string[] words = spoken.Split(' ', StringSplitOptions.RemoveEmptyEntries);
        if (words.Length <= MaxSpokenWords) return spoken;

        screen(spoken);
        // Una frase termina en . ? ! seguido de un espacio o del final, no dentro de
        // "ChangedFile.cs" ni de "1.8 seconds".
        Match end = Regex.Match(spoken, @"[.?!](\s|$)");
        string first = end.Success ? spoken[..(end.Index + 1)] : spoken;
        string[] firstWords = first.Split(' ', StringSplitOptions.RemoveEmptyEntries);
        if (firstWords.Length > MaxSpokenWords) first = string.Join(' ', firstWords.Take(MaxSpokenWords)) + "...";
        return $"{first} The rest is on screen.";
    }
}

sealed class RefusalException() : Exception("El modelo rechazó esta petición.");
Enter fullscreen mode Exit fullscreen mode

DevTools son las tres funciones de herramienta de la Parte 1 y su método RunAsync, dentro de static partial class DevTools y marcadas como public, junto con la clase de regex BuildOutput y los records Diagnostic y ProcessResult de la Parte 1.

El prompt es un pedido; GuardSpoken es una garantía. En los cuatro turnos de voz de la prueba de arriba, el prompt solo bastó para que ninguna respuesta pasara de 30 palabras, y el límite nunca se activó. Después, en la sesión en vivo, hice una pregunta abierta, "What do I need to do to fix it?", y la respuesta se alargó. El límite la mandó a la pantalla y dijo una frase y "The rest is on screen." Una regla que solo vive en el prompt aguanta hasta la primera pregunta que nadie probó.

Si una herramienta falla, el error vuelve al modelo. Si dotnet no puede arrancar, por un SDK que falta o una ruta mala, Process.Start lanza una Win32Exception. Mi primera versión no la atrapaba, y la primera vez que pasó, el agente entero se cayó a mitad de frase. Ahora la excepción se convierte en un tool_result con IsError = true, y el agente lo dice. Apuntado a un directorio que no existe, respondió: "I couldn't tell whether it's red. The build tool failed twice before compiling, because it's pointed at a working directory that doesn't exist. Details on screen."

Una petición fallida no puede ensuciar el historial. Si una petición falla después de un paso con herramientas (un límite de peticiones, la API sobrecargada, una conexión caída), messages termina en un tool_use sin su tool_result, y todas las peticiones siguientes se rechazan. Por eso AskAsync descarta el turno entero ante cualquier excepción, y también cuando se le acaban los pasos, y el ciclo de abajo atrapa el error, lo dice y mantiene la sesión. Con una key inválida, dos turnos seguidos fallaron, se anunciaron, y la sesión siguió.

El ciclo

using System.Diagnostics;
using Anthropic;
using Anthropic.Exceptions;
using PortAudioSharp;

// uso: dotnet run -- <ruta del repo> [--text]
//   --text  escribir en vez de hablar (la respuesta se sigue diciendo en voz alta)
if (args.Length == 0)
{
    Console.Error.WriteLine("uso: dotnet run -- <ruta del repo> [--text]");
    return 2;
}

string repo = args[0];
bool typed = args.Contains("--text");

// Prompt inicial de Whisper: pistas de escritura para las palabras que más falla.
const string Vocabulary =
    "dotnet build, dotnet test, git status, xUnit, ClaudeReviewBot, ChangedFile, CS1061, nullable, origin main.";

// Falla antes de abrir el micrófono, no después de la primera grabación.
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")))
{
    Console.Error.WriteLine("ANTHROPIC_API_KEY no está definida.");
    return 1;
}

using HttpClient whisperHttp = new() { BaseAddress = new Uri("http://localhost:8080"), Timeout = TimeSpan.FromMinutes(2) };
using HttpClient piperHttp = new() { BaseAddress = new Uri("http://localhost:5000"), Timeout = TimeSpan.FromMinutes(2) };
WhisperClient whisper = new(whisperHttp);
PiperClient piper = new(piperHttp);

try
{
    await piper.SynthesizeAsync("ready", CancellationToken.None);
    if (!typed)
        await whisper.TranscribeAsync(Wav.Encode(new short[Microphone.SampleRate / 2], Microphone.SampleRate), null, "en", CancellationToken.None);
}
catch (HttpRequestException ex)
{
    Console.Error.WriteLine($"Primero levanta los contenedores (docker compose up -d): {ex.Message}");
    return 1;
}

PortAudio.Initialize();
try
{
    VoiceAgent agent = new(new AnthropicClient(), repo);
    Console.WriteLine(typed ? "Escribe un pedido; línea vacía para salir." : "Enter para hablar, Enter otra vez para terminar. Ctrl+C para salir.");
    bool alreadyPressed = false;

    while (true)
    {
        string heard;
        if (typed)
        {
            Console.Write("\n> ");
            heard = Console.ReadLine() ?? "";
            if (heard.Length == 0) break;
        }
        else
        {
            if (!alreadyPressed)
            {
                Console.Write("\n[Enter para hablar] ");
                Console.ReadLine();
            }
            alreadyPressed = false;
            Console.Write("escuchando... ");
            byte[] wav = await Microphone.RecordAsync(ct => Task.Run(() => Console.ReadLine(), ct), CancellationToken.None);

            Stopwatch stt = Stopwatch.StartNew();
            try
            {
                heard = await whisper.TranscribeAsync(wav, Vocabulary, "en", CancellationToken.None);
            }
            catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
            {
                Console.WriteLine($"\n  falló el dictado: {ex.Message}");
                continue;
            }
            // Muestra siempre lo que se oyó: una palabra mal oída puede cambiar la pregunta.
            Console.WriteLine($"\n  oyó: \"{heard}\"  ({stt.ElapsedMilliseconds} ms)");
        }

        Stopwatch thinking = Stopwatch.StartNew();
        string reply;
        try
        {
            reply = await agent.AskAsync(heard, text => Console.WriteLine($"  ┌ pantalla\n  │ {text.Replace("\n", "\n  │ ")}\n  └"), CancellationToken.None);
        }
        catch (RefusalException)
        {
            reply = "I can't help with that one.";
        }
        catch (Exception ex) when (ex is AnthropicException or HttpRequestException or TaskCanceledException)
        {
            // Límite de peticiones, sobrecarga, conexión caída: lo dice y la sesión sigue.
            Console.WriteLine($"  falló la petición: {ex.Message}");
            reply = "I couldn't reach the model. Try again in a moment.";
        }
        Console.WriteLine($"  ({thinking.ElapsedMilliseconds} ms)");
        if (reply.Length == 0) continue;

        Console.WriteLine($"  dice: \"{reply}\"");
        byte[] speech;
        try
        {
            speech = await piper.SynthesizeAsync(reply, CancellationToken.None);
        }
        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
        {
            Console.WriteLine($"  falló la síntesis de voz: {ex.Message}");
            continue;
        }

        if (typed)
        {
            await Speaker.PlayAsync(speech, CancellationToken.None);
            continue;
        }

        // Enter mientras habla la interrumpe. Enter cuando termina empieza la siguiente
        // grabación, así que en los dos casos una sola tecla hace avanzar la conversación.
        using CancellationTokenSource stop = new();
        Task<string?> press = Task.Run(Console.ReadLine);
        Task playback = Speaker.PlayAsync(speech, stop.Token);
        if (await Task.WhenAny(press, playback) == press) stop.Cancel();
        await playback;
        if (!press.IsCompleted) Console.Write("  [Enter para hablar] ");
        await press;
        alreadyPressed = true;
    }
}
finally
{
    PortAudio.Terminate();
}
return 0;
Enter fullscreen mode Exit fullscreen mode

Una sola tecla controla todo. Enter mientras habla la interrumpe; Enter cuando termina empieza la siguiente grabación. --text cambia el micrófono por el teclado y mantiene la voz. Sirve en una oficina abierta, y para probar el ciclo sin tener que hablarle.

Por qué Haiku y no el modelo de la Parte 1

La Parte 1 usaba Claude Opus 5. Este post usa Claude Haiku 5.5, y la razón es que ahora la respuesta queda en medio de otras dos cosas lentas: el dictado antes y la síntesis después.

Le pasé a Claude Opus 5.5, el Opus actual, las doce transcripciones en las que Haiku había preguntado, fallado o cambiado de respuesta entre corridas. Opus corrió una vez cada una, contra tres de Haiku, así que la muestra es pequeña. Opus resolvió más: las cuatro preguntas con "nullable coordinates" terminaron en builds. Pero también actuó donde Haiku preguntaba. Con "What is the field print?", Haiku preguntó tres veces de tres; Opus, en su única corrida, ejecutó el build y los tests. En un agente al que le hablas, preguntar cuando la transcripción no tiene sentido es justo lo que quieres.

Con esas mismas doce transcripciones, una llamada a Opus costó unas treinta veces lo que una a Haiku, con una mediana de 2,5 segundos contra 1,8. Un turno lleva entre una y tres llamadas, así que son hasta dos segundos más en un ciclo que ya tarda entre siete y doce. El diseño de arriba (mostrar lo que se oyó, preguntar ante la duda) cubre casi toda la diferencia de precisión. El costo y los segundos se quedan.

Haiku 5.5 trae tres diferencias de API que dan forma al código. Rechaza cualquier temperature distinta de la de por defecto, así que no puedes fijar su salida, y la misma transcripción puede recibir otra herramienta en otra corrida: pasó con 8 de mis 64. Su razonamiento es adaptativo y viene activado por defecto, así que los bloques thinking hay que devolverlos intactos aunque su texto venga vacío. Y el effort es la palanca de latencia: con las transcripciones de los tres pedidos más fáciles (build, tests y estado), low y medium eligieron la misma herramienta en 21 de 22, así que el agente corre en low. En los casos difíciles no los comparé.

¿Y si le hablo en español?

Con lo medido aquí, todavía no en esta laptop. Los tres modelos rápidos son .en: solo entienden inglés, y ya viste lo que hacen con el español. El único multilingüe que probé, large-v3-turbo, transcribió bien buena parte de mis seis frases en español, con un error de 0,50 por palabra, pero tarda más de un minuto por frase en este CPU.

Lo que sí funciona es el agente. Con las seis frases exactas en español, Haiku eligió la herramienta correcta 18 veces de 18, incluida "Todavía no corras los tests, solo compila", que nunca ejecutó tests. Con las transcripciones de large-v3-turbo, también acertó casi siempre ("El CSS1061 viene del rename que hice en change file" terminó en build y git_status), y cuando dudó, preguntó en español: "¿Compilo src/ClaudeReviewBot, la CLI, o src/ClaudeReviewBot.Core?" Lo que falta para hablarle en español es un dictado multilingüe rápido y una voz de Piper en español, no un agente distinto.

Una sesión real

Cuatro turnos hablados, con un repositorio en el que renombré ChangedFile.Patch a Diff y olvidé un uso. Las transcripciones son las que oyó Whisper, con tiny.en y el prompt de vocabulario:

Oyó Herramientas Dijo
"What is the build, great?" dotnet_build, pantalla "The build's red with one error: ReviewOrchestrator is calling a Patch property that ChangedFile doesn't have. Details on screen."
"Why did I change?" git_status, pantalla "I can't see your reasons, only the edits. Three files changed, and ChangedFile.cs is the likely cause of the Patch error. The list is on screen."
"Run the test." dotnet_test "The tests didn't run because the build is still failing, so there are no results yet. Fixing the Patch error in ReviewOrchestrator should unblock them."
"What I need to do to fix it." ninguna "I can't read the code from here, so I can't say the exact fix yet. The rest is on screen."

La segunda fila resume el problema de la transcripción en una línea. Yo dije what; Whisper oyó why; y el agente contestó la pregunta que recibió, en tono filosófico, antes de darme igual la lista de archivos. La cuarta fila es una herramienta que falta, y es material para la Parte 3.

Las etapas que medí entre soltar Enter y la primera palabra de la respuesta suman entre 7 y 12 segundos por turno: 2,6–3,6 s de dictado, 2,1–7,4 s de agente (entre una y tres llamadas, más el build mismo) y 2,3–2,9 s de Piper sintetizando la respuesta completa antes de que suene nada. A eso se suman la decodificación del WAV y la latencia propia del dispositivo de audio, que no medí. Ese último número es el más barato de recortar: sintetizar la primera frase, empezar a reproducirla y sintetizar el resto por detrás.

Un turno hablado como barra apilada: dictado, el agente con sus herramientas y la síntesis de voz, con el último tramo marcado para recortar.

El bug que traía la Parte 1

A mitad de este post, una actualización instaló el SDK de .NET 11 RC junto al de .NET 10. Desde ese momento, el agente reportaba cada build como roto con MSB4216, un error del task host de las entrañas de MSBuild, incluso con código que compilaba bien desde una terminal.

dotnet run sobre un proyecto exporta MSBuildSDKsPath y MSBuildExtensionsPath, apuntando al SDK que compiló el agente. Cada dotnet build que lanza el agente las hereda. El global.json del repositorio elige .NET 10, las variables heredadas cargan los targets de .NET 11, y el build muere antes de llegar a una línea de código. Lanzado contra un build con un único CS1061 real, el método original de la Parte 1 devolvió seis errores MSB4216 y seis MSB4027, y ningún CS1061. Curiosamente, dotnet run app.cs sobre una app de un solo archivo no exporta esas dos variables, y por eso el bug se esconde en unas configuraciones y en otras no.

La corrección son tres líneas en RunAsync, que la Parte 1 ya incluye. Quita todas las variables MSBuild*, también las que hayas definido a propósito, como MSBUILDDEBUGPATH; si dependes de alguna, quita solo MSBuildSDKsPath y MSBuildExtensionsPath por nombre.

foreach (string key in psi.Environment.Keys
             .Where(k => k.StartsWith("MSBuild", StringComparison.OrdinalIgnoreCase)).ToList())
    psi.Environment.Remove(key);
Enter fullscreen mode Exit fullscreen mode

Cuándo no darle voz a tu agente

Si estás en tu escritorio con las manos en el teclado, escribir es más rápido y más preciso que hablar. Todos los números de arriba lo dicen: entre 7 y 12 segundos por turno, y una transcripción que se equivoca en más de la mitad de las palabras.

La voz se gana su lugar cuando tienes las manos o los ojos ocupados en otra cosa: seguir una corrida de tests mientras lees un diff en el otro monitor, o preguntar "¿ya está en verde?" desde el otro lado del cuarto mientras termina un build lento. También encaja con preguntas cortas de respuesta corta, que es casi todo lo que le preguntas a un agente de build.

Es la herramienta equivocada para cualquier cosa que escriba, borre o haga push. Las herramientas de la Parte 1 son de solo lectura a propósito, y ahora eso importa más: en el peor caso, un pedido mal oído ejecuta un build. Antes de darle a un agente de voz una herramienta con efectos, haz que pida confirmación en el código, no en el prompt.

Si prefieres no correr modelos en tu máquina, los servicios de dictado y síntesis alojados corren en GPU y se ahorran la latencia de CPU de arriba, a cambio de mandarle tu voz a un tercero. No los medí aquí. Todo el diseño sigue valiendo con ellos; solo cambiaría la tabla de latencias.


Puntos clave

  • Las respuestas de tu agente están escritas para leerse. Leída en voz alta, la misma respuesta tardó entre 57 y 68 segundos. Un prompt de voz con un canal en pantalla la bajó a 7, con el diagnóstico primero.
  • Diseña para la transcripción que vas a recibir. En el CPU de una laptop, los dos modelos de Whisper lo bastante rápidos para conversar erraron entre el 59 y el 76 por ciento de mis palabras. Dile al modelo que la transcripción viene mal, haz que pregunte cuando dos lecturas difieran, y muestra siempre lo que se oyó.
  • Una regla del prompt necesita una garantía en el código. El límite de 30 palabras nunca se activó en las pruebas y se activó con la primera pregunta abierta de uso real. Lo mismo con el silencio: fíltralo antes de que cueste una llamada.
  • Elige el modelo para el ciclo completo, no para la respuesta. Con mis doce transcripciones más difíciles, Opus recuperó más pedidos mal transcritos, pero costó unas treinta veces más por llamada, sumó tiempo a un ciclo que ya es lento y actuó donde Haiku preguntaba. Para este agente, Haiku con buen diseño fue la mejor opción.
  • Mide con la configuración que usa tu lector. El escape del SDK solo aparece con dotnet run sobre un proyecto, y los flags de Compose solo fallan en silencio. Las dos cosas parecían bien hasta que las medí de la forma correcta.

El código completo está en GitHub, listo para clonar y ejecutar: Juxn89/dotnet-voice-dev-agent. Es el código de este post, con las herramientas de la Parte 1 y sus dos correcciones. El README está en inglés.


Siguiente en la serie: Construye un agente de desarrollo .NET, Parte 3: haz que te recuerde, memoria de sesión, para que el agente deje de preguntarte cada mañana qué proyecto importa, y para que "¿qué estaba haciendo ayer?" sea una pregunta que pueda responder.

Top comments (0)