Para el Azure Tour 2026 de Sevilla, el 26 de septiembre, preparé una demo de un agente 100 % local para entornos industriales. Un modelo de visión observa a una persona en su módulo de trabajo (work-cell) y, cuando detecta un incidente, un agente de texto definido en Microsoft Agent Framework (MAF) avisa al supervisor y lo registra con dos herramientas, notify_supervisor y log_incident. Quería utilizar Foundry Local 2.x para el modelo del agente, por lo que antes de subir al escenario tenía que averiguar si un agente de MAF en C# puede llamar a herramientas sobre un modelo de Foundry Local 2.x, sin que nada salga del ordenador.
Estaba en duda por una razón sencilla. Un agente de MAF en .NET habla con el modelo a través de IChatClient (la abstracción de Microsoft.Extensions.AI), Foundry Local no trae ninguno, y el único adaptador que yo conocía, el de Bruno Capuano, estaba escrito para la 1.x.
Construir ese agente me obligó a conectarlo a Foundry Local 2.x con un adaptador IChatClient propio, a perseguir unas tool calls que no salían como esperaba y a buscar el modelo que de verdad llamaba a las dos herramientas.
No hay adaptador IChatClient para Foundry Local 2.x
Lo primero que comprobé es si Microsoft lo da hecho, y no. La página de Foundry Local en la documentación de MAF lo dice sin rodeos para .NET, «Foundry Local is not currently supported in .NET» (la integración oficial, FoundryLocalClient, es solo de Python). La tabla de proveedores de C# no lo lista, aunque deja abierta la puerta que importa: «Any inference service that provides a Microsoft.Extensions.AI.IChatClient implementation can back a ChatClientAgent».
El SDK tampoco lo trae. En los paquetes Microsoft.AI.Foundry.Local 2.0.1 y 2.1.0 (publicados el 31 de agosto y el 29 de septiembre) no aparece IChatClient ni en la documentación XML ni en el README, y no hay dependencia de Microsoft.Extensions.AI. Lo más parecido es el OpenAIChatClient que devuelve GetChatClientAsync(), que se apoya en la librería Betalgo, no implementa ninguna interfaz de Microsoft.Extensions.AI y, además, está marcado [Obsolete] con retirada prevista para final de 2026 en favor de ChatSession. En el issue #653 un usuario ofreció donar su propio adaptador y el mantenedor respondió con una muestra, no con una promesa de adaptador.
Quedaba el de elbruno. Su paquete, ElBruno.MAF.FoundryLocal.Adapter, va por la 0.2.1 (4 de julio), se compila y se prueba contra el SDK 1.2.1, y el código de la librería no se ha tocado desde el 5 de junio. En ese tiempo el SDK ha sacado cinco versiones, con salto de major version incluido. El propio repositorio se describe como sample. Leyendo el código encontré tres cosas que, para mi agente, lo descartaban:
- No tiene interruptor de telemetría. La
Configurationque crea solo llevaAppName. Tampoco podría tenerlo, porqueDisableNonessentialTelemetryno existe en el SDK 1.2.1 y aparece en la 2.x. - Pierde las tool calls en streaming.
MapStreamingUpdatesolo leeDelta.Contenty nuncaDelta.ToolCalls, así que en streaming no emiteFunctionCallContenty elFunctionInvokingChatClient(de Microsoft.Extensions.AI, el que usa MAF) nunca ve la llamada. La ruta sin streaming sí las mapea. El README lo deja entrever con su matriz de compatibilidad, que marca el streaming como Best effort y el tool calling como Partial. - Va una versión mayor por detrás del SDK que la parte de visión de la demo ya usaba dentro del propio proceso.
No llegué a ejecutarlo contra la 2.x, así que no sé si funcionaría. La superficie de API que usa sigue existiendo con la misma firma en la documentación de 2.1.0, pero el paquete nativo cambia de Foundry.Local.Core a Foundry.Local.Runtime, y no he comprobado si cambia el comportamiento en ejecución.
Busqué alternativas en NuGet, en GitHub y en los issues de microsoft/foundry-local, microsoft/agent-framework y dotnet/extensions, la última vez el 11 de octubre. Lo que hay no sirve para MAF sobre la 2.x. El PR #5170 de agent-framework, que añadía un Microsoft.Agents.AI.FoundryLocal, lleva parado desde el 8 de abril y apunta al SDK 0.9.0. Microsoft tiene un IChatClient sobre ChatSession 2.0.1 en ai-dev-gallery, pero es una clase internal de una app de muestra y, por lo que se ve al leerla, no mapea herramientas. Los paquetes de terceros que encontré son para otros frameworks y siguen en la 1.x.
He de reconocer que inicialmente no me di cuenta de que el adaptador de elbruno no me servía, así que lo adopté y di por buena la espera hasta que saliera un proveedor oficial (ADR-0002). Más tarde, al necesitar que el agente llamara siempre a las dos herramientas, confirmé que necesitaba un adaptador nuevo (revisión del issue #62, ADR-0015).
La vía OpenAI-compatible tiene un fallo abierto con las tool calls
La alternativa que usa casi todo el mundo no necesita adaptador. Foundry Local levanta un servidor REST compatible con OpenAI, y basta con apuntar el paquete OpenAI a él. La documentación de Learn para C# enseña a hacerlo, y la muestra oficial de MAF con Foundry Local encadena GetChatClient(...).AsIChatClient() (su último cambio es del 3 de abril).
El problema está en el issue #874, abierto desde el 12 de julio. Con tool_choice="auto", que es lo que mandan por defecto los frameworks de agentes, el servidor devuelve la llamada como texto dentro de message.content, con tool_calls vacío y finish_reason="stop". Solo "required" produce una llamada estructurada. En streaming, los comentarios describen delta.tool_calls vacío y el bloque <tool_call> colándose por delta.content. Los propios comentarios insisten en que el fallo depende del modelo y de la variante, y todos los reportes son del CLI de Foundry Local 0.8.119 a 0.10.3, no del servidor que arranca el SDK 2.x. La 2.1.0 mejoró el tool calling automático de Qwen, pero sus release notes no mencionan #874, y el PR #1162, fusionado el 5 de octubre y aún sin publicar en ninguna versión del SDK, reconoce que en la 2.1.0 una llamada de Qwen que no cuadra con el esquema todavía puede salir al cliente como texto.
En la prueba que monté el 2 de octubre, el servidor embebido de la 2.1.0 devolvió la llamada estructurada con el modelo de la demo. Eso no cierra #874 para el resto del catálogo. A eso se suma que /v1/models no expone si un modelo soporta tool calling (la petición se cerró como «not planned»). Me parece que, para un agente cuyo único trabajo es llamar a herramientas, un transporte con un fallo abierto que depende de la variante deja la fiabilidad en manos de qué variante toque. La API nativa, en cambio, entrega la llamada ya tipada.
El adaptador sobre ChatSession
La 2.x trae una API nativa de sesiones tipadas (ChatSession, Request, Response e items como ToolCallItem o ToolResultItem), y es hacia donde el propio SDK manda a quien usaba los clientes OpenAI. El adaptador es un único fichero de 391 líneas, FoundryLocalChatClient.cs, escrito contra el SDK 2.0.1. Del de elbruno tomé el patrón (un IChatClient delante de Foundry Local para que la invocación automática de funciones que usa MAF funcione sin tocar nada), no el código.
Las herramientas. Cada AIFunctionDeclaration de ChatOptions.Tools se registra en la sesión con su esquema JSON:
using var session = new ChatSession(model);
foreach (var tool in ToolDefinitionsOf(options))
{
session.AddToolDefinition(tool.Name, tool.Description ?? string.Empty, tool.JsonSchema.GetRawText());
}
using var request = BuildRequest(messages, options);
using var response = await session.ProcessRequestAsync(request, cancellationToken);
return ToChatResponse(response);Cada llamada abre una ChatSession nueva y le pasa el historial completo como un solo Request. Cuando el servicio no guarda la conversación, ChatClientAgent carga el historial de la sesión (por defecto, de un InMemoryChatHistoryProvider) y lo manda entero en cada llamada, así que la sesión nativa no tiene por qué arrastrar turnos.
Las instrucciones. El mismo ChatClientAgent entrega las instrucciones del agente en ChatOptions.Instructions, y no las añade al historial como mensaje de sistema. Un cliente que no las convierta en uno le manda al modelo la conversación y las herramientas, pero ninguna instrucción:
if (!string.IsNullOrEmpty(options?.Instructions))
{
request.AddItem(MessageItem.System(options.Instructions, string.Empty), takeOwnership: true);
}Esta pieza no estaba en la primera versión.
Las llamadas y sus resultados. De vuelta, cada ToolCallItem se convierte en un FunctionCallContent, que es lo que FunctionInvokingChatClient sabe ejecutar. Y de ida, el resultado de cada herramienta viaja como un ToolResultItem hermano de la llamada a la que responde:
// Response → MAF
case ToolCallItem call:
contents.Add(new FunctionCallContent(call.CallId, call.Name, ParseArguments(call.Arguments)));
break;
// MAF → Request
if (content is FunctionResultContent result)
{
request.AddItem(new ToolResultItem(result.CallId, FormatToolResult(result.Result)), takeOwnership: true);
}La telemetría. El FoundryLocalManager se crea con una Configuration que lleva DisableNonessentialTelemetry = true, la propiedad que el SDK añadió en la 2.x (su propia documentación avisa de que aún puede enviar un evento mínimo ProcessInfo).
Con eso, montar el agente es lo mismo que con cualquier otro proveedor:
IChatClient chatClient = new FoundryLocalChatClient("qwen2.5-1.5b-instruct-generic-cpu:4");
var agent = chatClient.AsAIAgent(new ChatClientAgentOptions
{
ChatOptions = new ChatOptions { Tools = [notifySupervisor, logIncident] },
});No lo he publicado como repositorio ni como paquete NuGet. Es un fichero con poco acoplamiento al resto de la demo, y un paquete tendría que seguir a un SDK que ha sacado seis versiones entre junio y septiembre y que versiona juntos el SDK y su runtime nativo.
El streaming no parece el culpable
La última pieza del adaptador descansa en una suposición que la comprobación posterior no sostuvo. GetStreamingResponseAsync no usa el streaming de Foundry Local. Hace una única llamada sin streaming y la devuelve como actualizaciones:
var response = await GetResponseAsync(messages, options, cancellationToken);
foreach (var update in response.ToChatResponseUpdates())
{
yield return update;
}Mientras desarrollaba la demo, el agente no conseguía hacer bien las tool calls, y con el Azure Tour encima no tuve tiempo de hacer la prueba que habría aislado la causa. Junté lo que sabía del adaptador de elbruno y de #874, supuse que el culpable era el streaming de Foundry Local y lo dejé escrito así (ADR-0015). Lo único que tenía medido eran tool calls rotas en las variantes OpenVINO del modelo, que se dejaban una de las dos herramientas en todas las pasadas.
Monté esa prueba aislada el 2 de octubre, con el SDK 2.1.0 y la variante que usa el agente, qwen2.5-1.5b-instruct-generic-cpu:4. Una herramienta (notify_supervisor), sin mensaje de sistema, temperatura 0 y cinco ensayos por combinación. La llamada llegó estructurada en las 40 peticiones:
ChatSession, con y sin streaming, bajoAutoy bajoRequired, 20 de 20. En streaming, el iterador entregó un únicoToolCallItemya completo, sin deltas parciales de argumentos, que es lo que describe el testToolCall_Streaming_Succeedsdel SDK (ese test solo cubreRequired, y esta prueba añadeAuto).- El servidor REST embebido (
/v1/chat/completions), con y sinstream, bajoautoy bajorequired, 20 de 20. En la respuesta SSE (Server-Sent Events) cruda, la llamada llega en un solo chunk condelta.tool_callsrelleno y ningún<tool_call>endelta.content. Es lo contrario del síntoma de #874.
El alcance de la prueba es estrecho. Es un modelo, una variante, una herramienta y una vuelta, en otra máquina, un i7-13700KF y no el Zenbook de la medición (al ser una variante de CPU, la GPU no entra). Con temperatura 0, el 5 de 5 comprueba la ruta más que medir una tasa de acierto. No probé el SDK 2.0.1, con el que escribí el ADR, ni el streaming sobre las variantes OpenVINO. Lo que sí puedo decir es que, con el modelo que el adaptador usa por defecto y el SDK 2.1.0, el fallo que justificaba servir el streaming desde la ruta sin streaming no se reproduce.
Por el camino di con una trampa que cambia cómo se lee #874. Yo tenía instalado el CLI de Foundry Local 0.8.119, y un foundry cache ls arrancó su propio servicio, con otro puerto y otro runtime. El SDK 2.x lleva su runtime nativo dentro del paquete y no usa el CLI para nada. Son dos productos con versiones distintas, y toda la evidencia de #874 es del CLI.
Queda la pregunta de por qué fallaban las variantes OpenVINO. Sospecho que es la cuantización básica con la que se publican esos modelos, pero la verdad es que no lo he medido. Lo medido se limita a que el mismo modelo acierta en la variante de CPU y falla en las de OpenVINO.
La elección del modelo de texto
La elección de modelo salió de una medición repetible. Diez incidentes sintéticos pasan por un ChatClientAgent sobre este adaptador, con las dos herramientas, en cada combinación de modelo de texto y execution provider disponible en un ASUS Zenbook S14 (Core Ultra 7 258V, NPU Intel AI Boost, iGPU Arc 140V) con el SDK 2.0.1. Un incidente cuenta como fiable si el agente llama a las dos herramientas. Los candidatos fueron tres modelos de texto, qwen2.5-1.5b-instruct, qwen3-1.7b y qwen3-4b.
Solo una combinación aguantó, qwen2.5-1.5b-instruct-generic-cpu:4, con 10 de 10. Las otras fallaron de formas distintas:
- El mismo
qwen2.5-1.5ben OpenVINO se dejaba siempre una herramienta. En GPU llamaba solo alog_incidenty en NPU solo anotify_supervisor, en las diez pasadas. - Los
qwen3no llegaban a llamar. El de 1,7B devolvía texto vacío y el de 4B se ponía a razonar en prosa («Okay, let’s see…») sin llegar a la llamada, con 74 a 93 segundos por incidente. - Las variantes
generic-gpuni siquiera cargaban en esa máquina («WebGPU execution provider is not supported in this build»).
El tamaño, por sí solo, no lo explica. El mismo modelo da 10 de 10 o 0 de 10 según la variante, y qwen3-4b, más grande, falla en la misma CPU donde acierta el de 1,5B. El catálogo declara el soporte de tool calling por variante (SupportsToolCalling en el SDK, la columna tools en foundry model list), y la página de Learn sobre tool calling se limita a advertir que «different models have different capabilities». Ninguna fuente oficial publica cómo de fiable es cada combinación.
La primera versión del adaptador ignoraba ChatOptions.Instructions, de modo que las instrucciones que la medición fijaba nunca llegaban al modelo, y el 10 de 10 se midió, sin querer, sin instrucciones de sistema. Cuando el adaptador empezó a enviarlas, la medición cayó a 0 de 10 en las siete combinaciones, incluida la buena. Probé seis formas de plantear el turno sobre esa variante, y anoté el resultado de cada una en el ADR-0015 sin guardar la salida de cada pasada. Pedir «Respond only by calling notify_supervisor and log_incident …» dio 1 de 5, y el modelo escribía las llamadas como JSON sin las etiquetas <tool_call> que Foundry Local reconoce. Quitar las instrucciones y enriquecer la descripción de un parámetro dio 8 de 10. Sin instrucciones, 10 de 10. Así que el agente de la demo corre con las instrucciones a null, y añadir cualquiera obliga a repetir la medición antes de fijar el modelo.
La medición tiene sus límites. Son tres modelos de texto, no todo el catálogo, en una máquina y con el SDK 2.0.1 (hoy ya está la 2.1.0). Tampoco probé los modelos de visión de 4B y 8B con tool calling, que la nota de la demo deja abiertos.
Cada capa se mide por separado
El agente de la demo quedó en un ChatClientAgent de MAF sobre un adaptador propio a ChatSession, con qwen2.5-1.5b-instruct-generic-cpu:4 y sin instrucciones de sistema, todo dentro del portátil. El adaptador, la variante y las instrucciones a null salieron de comprobar algo que yo daba por hecho.
Para MAF sobre la 2.x, a fecha de 11 de octubre me parece que la vía más segura es un IChatClient propio sobre la API tipada del SDK, y no es mucho código. Basta con mapear bien herramientas, instrucciones y resultados, y MAF hace el resto. Lo que más pesó fue una omisión del adaptador, que no pasaba las instrucciones, así que medí el modelo sin ellas y sin saberlo.
Lo demás fueron dos suposiciones. Le eché la culpa al streaming de Foundry Local sin haberlo aislado, y una prueba de una tarde, ya pasada la demo, apunta a que no era eso. Y di por hecho que un modelo que declara soporte de tool calling lo usaría en mi agente. El mismo modelo pasó de 10 de 10 a 0 de 10 al cambiar de variante, y de 10 de 10 a 0 de 10 al añadir un mensaje de sistema.
Lo que no mides por separado, lo supones.
Si vas a llevar un agente con herramientas a un modelo local, me parece que lo más operativo es separar las capas (el adaptador, el transporte, la variante y las instrucciones), no dar por hecho cómo se comporta ninguna y tener la medición como un comando que se repite: diez incidentes, todas las variantes y las instrucciones que vas a usar de verdad. Y lanzarla cada vez que cambie cualquiera de esas capas. En Foundry Local cada mes sale un SDK nuevo, así que esa medición tiene que vivir en el repositorio junto al agente, no solo correr antes de la primera demo.