Skip to content

feat(acp): ship our own ACP agent for OpenAI-compatible endpoints (Phase 7) - #66

Draft
BOTOOM wants to merge 8 commits into
devin/1786230729-acp-phase6from
devin/1786231403-acp-phase7
Draft

feat(acp): ship our own ACP agent for OpenAI-compatible endpoints (Phase 7)#66
BOTOOM wants to merge 8 commits into
devin/1786230729-acp-phase6from
devin/1786231403-acp-phase7

Conversation

@BOTOOM

@BOTOOM BOTOOM commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Description

Phase 7, stacked on #65#64#63#62#61#60#59. LM Studio, Ollama, vLLM, llama.cpp and OpenRouter expose OpenAI-compatible APIs, not ACP. Per ADR-0006 we neither special-case them in the host (that would reintroduce the provider abstraction this refactor exists to delete) nor depend on an unvetted third-party bridge — we ship our own minimal ACP agent, apps/acp-openai-agent, spawned through a built-in catalog entry like any other agent.

The invariant: nothing under apps/backend/src/acp/ knows LM Studio or Ollama exists — verified, no endpoint-specific names there. Local models are just another ACP agent.

What the agent does: initialize/session/new/session/prompt/session/cancel on the agent side of the same pinned SDK; ACP content blocks mapped onto chat-completions messages; streaming relayed as agent_message_chunk; session/cancel aborting the upstream request mid-stream rather than merely dropping output; model options populated from the endpoint's GET /v1/models, degrading to the configured model when it doesn't answer.

The tool loop is the substance of this phase, and the first cut of it wasn't real — it asked permission and then marked the call completed, never executing anything, never appending a tool result, never continuing the completion, and never sending a tools array in the first place, so no endpoint could have emitted a tool call at all. It now sends the tool set, accumulates streamed tool-call deltas by index until the fragmented function.arguments JSON parses (the real streaming shape), requests permission, executes on approval, appends the result and re-invokes until the model stops asking, capped at eight rounds, with pending → in_progress → completed/failed tracked as ACP tool-call updates. The Phase 4 permission model applies here in full: our own agent doesn't get to bypass it.

Tools are read_file, write_file and run_shell, all confined to the session cwdenforced in the agent, not by the permission prompt, which is a UI-side gate and the wrong place for a security boundary. Paths are resolved before comparison so ../ traversal, absolute paths outside cwd and symlinks pointing out are all rejected with the call ending failed and nothing touched on disk; there are tests for each, because a refactor could otherwise remove that guard silently.

Two capabilities were being advertised dishonestly and are fixed, which matters more here than usual since Phase 4's UI is driven entirely off what an agent claims: embeddedContext: true was advertised while resource blocks were silently dropped (so context-aware mode's page content vanished without a warning — they're mapped now), and an API-key authMethod was advertised whose authenticate was a no-op that resolved successfully and then failed on the first request (withdrawn; credentials stay backend-resolved).

Tests drive the agent end-to-end over the real ACP stdio transport against an in-process fake OpenAI-compatible server — streaming, fragmented tool calls, a rejected permission, upstream errors, a held stream for cancellation, an approved write_fileread_file round-trip the model then answers from, the three escape attempts, and cancel mid-tool-loop.

One incidental fix: the new package's Vitest was discovering the ACP SDK's own compiled tests under node_modules/.../dist, inflating its result to 425 tests. Scoped to src/**/*.test.ts — the honest count is 13, and no other suite's coverage was narrowed (backend 184, extension 177).

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring (no functional changes)
  • Dependency update

Related Issues

Stacked on #65#64#63#62#61#60#59. Implements ADR-0006.

Checklist

  • My code compiles without errors (pnpm typecheck)
  • Linter passes (pnpm lint) — scoped Biome clean; repo-wide run is pre-existing-red on unrelated files
  • Tests pass (pnpm test) — acp-openai-agent 13, backend 184, extension 177; Playwright blocked on legacy Copilot auth (Phase 8)
  • I have added tests for new functionality (if applicable)
  • I have updated documentation (if applicable)
  • My changes follow the project coding conventions

Screenshots (if applicable)

n/a

Link to Devin session: https://app.devin.ai/sessions/bab32da5729e4a95a9cb79f1648f005e
Requested by: @BOTOOM


Open in Devin Review

@BOTOOM BOTOOM self-assigned this Aug 8, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
devmentorai-website-cli Ready Ready Preview Aug 10, 2026 2:38pm

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 8 potential issues.

Open in Devin Review

Comment on lines +40 to +45
distribution: {
command: {
cmd: process.execPath,
args: [path.resolve(process.cwd(), 'apps/acp-openai-agent/dist/main.js')],
},
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 El nuevo agente integrado no arranca salvo que el servidor se ejecute desde la raíz del repositorio

La ruta del ejecutable del nuevo agente se calcula a partir del directorio de trabajo del proceso (path.resolve(process.cwd(), ...) en apps/backend/src/acp/catalog/agent-catalog.ts:43) al cargar el módulo, así que apunta a un archivo inexistente en cualquier arranque que no sea desde la raíz del repositorio.
Impacto: los usuarios que seleccionen el agente para endpoints compatibles con OpenAI verán un fallo al iniciarlo en instalaciones publicadas y en el modo de desarrollo del backend.

Resolución de ruta relativa al cwd en una constante de módulo

BUILT_IN_AGENTS se evalúa al importar el módulo. pnpm dev:backend ejecuta pnpm --filter devmentorai-server dev, cuyo cwd es apps/backend, por lo que la ruta resulta apps/backend/apps/acp-openai-agent/dist/main.js. En el paquete publicado devmentorai-server (que además solo incluye dist/, README.md y LICENSE según apps/backend/package.json) el agente ni siquiera está distribuido, y el cwd es el del usuario.

apps/backend/src/acp/catalog/launch-resolver.ts:86-90 toma distribution.command.cmd/args tal cual y apps/backend/src/acp/launcher.ts:132 hace spawn, de modo que Node fallará con «Cannot find module». Además availability() (apps/backend/src/acp/catalog/agent-catalog.ts:130) marca la entrada como disponible, así que la UI la ofrece igualmente.

Sería más robusto resolver la ruta respecto a la ubicación del propio módulo (import.meta.url), permitir sobreescribirla por variable de entorno y/o verificar la existencia del archivo para reflejarla en installState/platformAvailability.

Prompt for agents
La entrada integrada 'devmentorai-openai-compatible' en BUILT_IN_AGENTS (apps/backend/src/acp/catalog/agent-catalog.ts) fija el argumento del comando con path.resolve(process.cwd(), 'apps/acp-openai-agent/dist/main.js'), evaluado al cargar el módulo. El cwd del backend no es la raíz del monorepo ni en desarrollo (pnpm --filter ejecuta dentro de apps/backend) ni en el paquete npm publicado, donde el agente ni siquiera se distribuye. Hay que resolver la ubicación del bundle del agente de forma independiente del cwd (por ejemplo respecto a import.meta.url del backend compilado, o mediante una variable de entorno configurable), y considerar reflejar la ausencia del archivo en installState/platformAvailability para no ofrecer un agente que no se puede lanzar.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +260 to +264
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (payload === '[DONE]') continue;
const parsed: unknown = JSON.parse(payload);
if (!isRecord(parsed) || !Array.isArray(parsed.choices)) continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Una línea de datos malformada del endpoint aborta toda la respuesta del modelo

Cada línea de datos recibida del endpoint se interpreta como JSON sin ninguna protección (JSON.parse(payload) en apps/acp-openai-agent/src/openai-compatible-agent.ts:263), de modo que una línea vacía o no válida hace fallar la petición completa.
Impacto: la conversación termina con error y se pierde todo lo ya generado cuando el servidor envía cualquier línea de mantenimiento o no estándar.

Parseo del flujo SSE sin tolerancia a fallos

En el bucle de lectura (apps/acp-openai-agent/src/openai-compatible-agent.ts:259-280) solo se filtran las líneas que no empiezan por data: y el marcador [DONE]. Un data: vacío (keep-alive habitual en algunas implementaciones compatibles con OpenAI) produce JSON.parse(''), que lanza una excepción. Esa excepción sale de complete() y, como el controlador no está abortado, prompt() la vuelve a lanzar (:215-217), devolviendo un error JSON-RPC al cliente en lugar de terminar el turno.

Suggested change
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (payload === '[DONE]') continue;
const parsed: unknown = JSON.parse(payload);
if (!isRecord(parsed) || !Array.isArray(parsed.choices)) continue;
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (payload === '' || payload === '[DONE]') continue;
let parsed: unknown;
try {
parsed = JSON.parse(payload);
} catch {
continue;
}
if (!isRecord(parsed) || !Array.isArray(parsed.choices)) continue;
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +213 to +214
}
throw new Error('Tool-call loop exceeded its maximum number of rounds');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Alcanzar el máximo de rondas de herramientas devuelve un error en vez de finalizar el turno

Cuando se agotan las ocho rondas del ciclo de herramientas se lanza una excepción (throw new Error('Tool-call loop exceeded...') en apps/acp-openai-agent/src/openai-compatible-agent.ts:214) en lugar de terminar el turno con un motivo de parada.
Impacto: el usuario ve un fallo genérico de la petición y pierde el contexto de la respuesta en vez de un aviso de que se alcanzó el límite.

Motivo de parada previsto por el protocolo

El protocolo ACP define un motivo de parada específico para este caso (max_turn_requests), pensado para que el cliente muestre la conversación y ofrezca continuar. Al lanzar una excepción, el manejador prompt propaga el error (:215-217) y la petición session/prompt se resuelve como error JSON-RPC; además los mensajes ya acumulados en la sesión quedan con llamadas de herramienta resueltas pero sin respuesta final del modelo.

Suggested change
}
throw new Error('Tool-call loop exceeded its maximum number of rounds');
}
return { stopReason: 'max_turn_requests' };
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +188 to +211
session.messages.push({
role: 'assistant',
content: result.content || null,
tool_calls: result.toolCalls.map((call) => ({
id: call.id,
type: 'function',
function: { name: call.name, arguments: call.arguments },
})),
});
const results = await this.resolveToolCalls(
session,
params.sessionId,
result.toolCalls,
client
);
for (const toolResult of results) {
session.messages.push({
role: 'tool',
tool_call_id: toolResult.call.id,
content: toolResult.rejected
? 'Tool execution rejected by the user.'
: (toolResult.result ?? ''),
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 El estado de la sesión puede quedar inconsistente si el turno falla tras registrar las llamadas de herramienta

En apps/acp-openai-agent/src/openai-compatible-agent.ts:188-211 se añade primero el mensaje de asistente con tool_calls y después, en un paso separado, los mensajes role: 'tool'. Si algo lanza en medio (por ejemplo, la petición de permiso al cliente falla o la conexión ACP se cae durante client.notify en :308-318), la sesión queda con un mensaje de asistente con tool_calls sin las respuestas tool correspondientes. Como session.messages persiste entre prompts, todas las peticiones posteriores de esa sesión enviarán un historial que la mayoría de endpoints compatibles con OpenAI rechazan con 400, dejando la sesión inutilizable de forma permanente. Convendría rebobinar los mensajes añadidos en el catch/finally de prompt().

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +9 to +13
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest",
"typecheck": "tsc --noEmit"
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Las pruebas dependen de un dist compilado previamente y del cwd del paquete

connectAgent lanza node dist/main.js con cwd: process.cwd() (apps/acp-openai-agent/src/openai-compatible-agent.test.ts:79-81), pero el script test del paquete (apps/acp-openai-agent/package.json:11) solo ejecuta vitest sin compilar antes. En un checkout limpio (pnpm test sin pnpm build previo) todas las pruebas fallarán por módulo inexistente, y el fallo se manifestará como timeout del handshake ACP en lugar de un mensaje claro. Sería mejor encadenar la compilación (pretest) o usar tsx sobre src/main.ts.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +36 to +46
{
id: 'devmentorai-openai-compatible',
name: 'OpenAI-compatible endpoint',
description: 'DevMentorAI ACP agent for OpenAI-compatible chat-completions endpoints',
distribution: {
command: {
cmd: process.execPath,
args: [path.resolve(process.cwd(), 'apps/acp-openai-agent/dist/main.js')],
},
},
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 La entrada integrada no puede alcanzar el estado «instalado»

Para distribuciones de tipo command, defaultInstallState (apps/backend/src/acp/catalog/agent-catalog.ts:145-163) cae en la rama final y devuelve entry.installState, que para source === 'builtin' se fija a 'not_installed' en sourceEntry (:103). Por tanto el nuevo agente aparecerá siempre como no instalado aunque su binario exista, y al no tener npx/binary tampoco hay flujo de instalación que pueda cambiarlo. Conviene verificar cómo trata la UI de Fase 4 ese estado antes de exponer la entrada.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +376 to +383
if (call.name === 'run_shell') {
const result = await execFileAsync('/bin/sh', ['-c', stringValue(input.command)], {
cwd: session.cwd,
maxBuffer: 1024 * 1024,
...(session.controller ? { signal: session.controller.signal } : {}),
});
return `${result.stdout}${result.stderr}`;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 El agente concede acceso al sistema de ficheros y ejecución de shell basada solo en el permiso de la UI

El agente expone una herramienta run_shell que ejecuta cualquier cadena recibida del modelo mediante /bin/sh -c (apps/acp-openai-agent/src/openai-compatible-agent.ts:377). El comando procede directamente de la respuesta del endpoint compatible con OpenAI (potencialmente un servidor remoto como OpenRouter), y el único control es la solicitud de permiso al cliente ACP. A diferencia de read_file/write_file, que están confinados con safePath, el comando de shell no tiene ningún confinamiento más allá del cwd inicial: puede leer/escribir fuera del workspace, exfiltrar credenciales del entorno (el proceso hereda process.env, incluida OPENAI_COMPATIBLE_API_KEY) o establecer conexiones salientes.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +319 to +326
const permission = await client.request(acp.methods.client.session.requestPermission, {
sessionId,
toolCall: { toolCallId: call.id, title: call.name, kind: 'execute', status: 'pending' },
options: [
{ optionId: 'allow', name: 'Allow once', kind: 'allow_once' },
{ optionId: 'reject', name: 'Reject', kind: 'reject_once' },
],
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 La solicitud de permiso no muestra qué va a hacer la herramienta

La petición session/request_permission (apps/acp-openai-agent/src/openai-compatible-agent.ts:319-326) envía únicamente el nombre de la herramienta como título, sin los argumentos (ruta a escribir, comando a ejecutar). El usuario aprueba una acción cuyo contenido desconoce, lo que degrada la eficacia del único control interactivo previo a la ejecución de escrituras y comandos.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Revisión atendida en cbda46b + 5695f93: la ruta del agente built-in se resuelve desde import.meta.url (con override por env) y puede alcanzar installed, una línea SSE malformada ya no aborta la respuesta, agotar las rondas de herramientas termina el turno con stopReason: max_turn_requests, un turno fallido rebobina los mensajes para no dejar tool_calls sin respuesta, y los tests ya no dependen de un dist precompilado.

Sobre 🟨 run_shell: mantengo la ejecución de comandos arbitrarios (es la razón de ser de la herramienta y el control es la aprobación explícita del usuario), pero se mitiga lo señalado: el proceso hijo ya no hereda process.env — solo una allowlist mínima, nunca OPENAI_COMPATIBLE_API_KEY ni variables con KEY/TOKEN/SECRET/PASSWORD — se ejecuta en el cwd de la sesión con el AbortSignal del turno, y la solicitud de permiso muestra el comando concreto. El modelo de amenaza queda documentado en ADR-0007.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant