diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts index a662ac0cc6..039339bd29 100644 --- a/src/adapters/devin.ts +++ b/src/adapters/devin.ts @@ -7,6 +7,7 @@ * streams CloudChatEvent into AdapterEvent. */ import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTool, OcxToolCall, OcxToolResultMessage, OcxUsage } from "../types"; +import { namespacedToolName } from "../types"; import type { IncomingMeta, ProviderAdapter } from "./base"; import { streamChatEvents, allocateCascadeId, CloudChatError, type ChatHistoryItem, type ToolDef } from "./devin/cloud-direct"; import { getCachedCatalog } from "./devin/cloud-direct/catalog"; @@ -326,6 +327,65 @@ export function mapOcxToolsToDevin(tools: OcxTool[] | undefined): ToolDef[] | un })); } +/** + * Devin's request mapper advertises the local tool name, so a namespaced Codex tool such as + * `mcp__cua_repl__js` is sent upstream as `js`. Restore a returned bare name to its canonical request + * identity only when exactly one advertised tool owns it. A null owner is an ambiguous catalog and + * must fail before dispatch; an absent owner remains unchanged for the shared undeclared-tool guard + * to reject. + */ +function buildDevinReturnedToolNameMap( + tools: OcxTool[] | undefined, +): ReadonlyMap { + const names = new Map(); + for (const tool of tools ?? []) { + const canonical = namespacedToolName(tool.namespace, tool.name); + if (!names.has(tool.name)) { + names.set(tool.name, canonical); + } else if (names.get(tool.name) !== canonical) { + names.set(tool.name, null); + } + } + return names; +} + +function restoreDevinReturnedToolName( + name: string, + names: ReadonlyMap, +): string | null { + return names.has(name) ? names.get(name)! : name; +} + +type DevinMappedToolCallStart = + | Extract + | Extract; + +function mapDevinToolCallStart( + id: string, + name: string, + names: ReadonlyMap, +): DevinMappedToolCallStart { + const restoredName = restoreDevinReturnedToolName(name, names); + if (restoredName === null) { + return { + type: "error", + message: "Devin emitted a bare client tool name that maps to multiple request-declared tools.", + status: 502, + retryable: false, + }; + } + return { type: "tool_call_start", id, name: restoredName }; +} + +/** Test seam for the request-scoped tool-call event mapping used by runTurn. */ +export function mapDevinToolCallStartForTests( + id: string, + name: string, + tools: OcxTool[] | undefined, +): DevinMappedToolCallStart { + return mapDevinToolCallStart(id, name, buildDevinReturnedToolNameMap(tools)); +} + export function createDevinAdapter( provider: OcxProviderConfig, context: { providerId?: string } = {}, @@ -387,6 +447,7 @@ export function createDevinAdapter( // every RPC to the US server it is not provisioned on. const host = resolveDevinApiServer(provider.baseUrl, credentialProviderId); const modelUid = await resolveWireModelUid(rawModelId, apiKey, host, parsed.options.reasoning); + const returnedToolNames = buildDevinReturnedToolNameMap(parsed.context.tools); let openToolId: string | undefined; let usage: OcxUsage | undefined; let stopReason: string | undefined; @@ -440,8 +501,13 @@ export function createDevinAdapter( } if (event.kind === "tool_call_start") { closeOpenTool(); + const mapped = mapDevinToolCallStart(event.id, event.name, returnedToolNames); + if (mapped.type === "error") { + emit({ ...mapped, ...(usage ? { usage } : {}) }); + return; + } openToolId = event.id; - emit({ type: "tool_call_start", id: event.id, name: event.name }); + emit(mapped); continue; } if (event.kind === "tool_call_args") { diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 8c23e38eef..fcc50e3b97 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -26,7 +26,10 @@ Some adapters share another adapter's routed-tool semantics while retaining inde `devin-cli` imports that token and the two rows differ only in where the credential came from. `AdapterFactoryContext.providerId` is what keeps them apart: the Cognition tenant is recorded on the credential, not in the registry, so the adapter has to know which row it is serving before it - can resolve a host. + can resolve a host. The adapter advertises bare local tool names to Cognition, so `runTurn` also + owns a request-scoped return map from each unique bare name to the canonical Codex namespace + identity. Unknown names remain subject to the shared undeclared-tool guard; duplicate bare names + fail before dispatch rather than selecting a request tool by declaration order. There is no second Devin transport. An Agent Client Protocol adapter that spawned a local `devin acp` child once existed under the `devin-cli` adapter id and was removed: the CLI's diff --git a/tests/providers/devin-adapter.test.ts b/tests/providers/devin-adapter.test.ts index 262da900d9..2bffa97730 100644 --- a/tests/providers/devin-adapter.test.ts +++ b/tests/providers/devin-adapter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createDevinAdapter, mapOcxMessagesToDevin, mapOcxToolsToDevin, resolveWireModelUidForTests } from "../../src/adapters/devin"; +import { createDevinAdapter, mapDevinToolCallStartForTests, mapOcxMessagesToDevin, mapOcxToolsToDevin, resolveWireModelUidForTests } from "../../src/adapters/devin"; import { sanitizeToolDescriptionForCognitionForTests } from "../../src/adapters/devin/cloud-direct/chat"; import { DEVIN_MODEL_CONTEXT_WINDOWS, DEVIN_STATIC_MODELS, collapseDevinModelUid } from "../../src/adapters/devin/live-models"; import { parseCatalogBuffer } from "../../src/adapters/devin/cloud-direct/catalog"; @@ -77,6 +77,97 @@ describe("devin adapter", () => { expect(system).not.toContain("codex_app__list_threads"); }); + test("maps Cognition tool_call_start names to unique canonical request identities", () => { + const tools = [ + { namespace: "mcp__cua_repl", name: "js", description: "control UI", parameters: { type: "object" } }, + { name: "exec", description: "run code", parameters: { type: "object" } }, + ]; + + expect(mapDevinToolCallStartForTests("call_js", "js", tools)).toEqual({ + type: "tool_call_start", + id: "call_js", + name: "mcp__cua_repl__js", + }); + expect(mapDevinToolCallStartForTests("call_canonical", "mcp__cua_repl__js", tools)).toEqual({ + type: "tool_call_start", + id: "call_canonical", + name: "mcp__cua_repl__js", + }); + expect(mapDevinToolCallStartForTests("call_exec", "exec", tools)).toEqual({ + type: "tool_call_start", + id: "call_exec", + name: "exec", + }); + expect(mapDevinToolCallStartForTests("call_unknown", "undeclared", tools)).toEqual({ + type: "tool_call_start", + id: "call_unknown", + name: "undeclared", + }); + }); + + test("maps ambiguous Cognition tool_call_start names to a non-retryable error", () => { + const namespaceCollision = [ + { namespace: "mcp__first", name: "js", description: "first", parameters: { type: "object" } }, + { namespace: "mcp__second", name: "js", description: "second", parameters: { type: "object" } }, + ]; + const bareCollision = [ + { name: "js", description: "bare", parameters: { type: "object" } }, + { namespace: "mcp__cua_repl", name: "js", description: "namespaced", parameters: { type: "object" } }, + ]; + const duplicateIdentity = [ + { namespace: "mcp__cua_repl", name: "js", description: "first copy", parameters: { type: "object" } }, + { namespace: "mcp__cua_repl", name: "js", description: "second copy", parameters: { type: "object" } }, + ]; + + const expected = { + type: "error", + message: "Devin emitted a bare client tool name that maps to multiple request-declared tools.", + status: 502, + retryable: false, + }; + expect(mapDevinToolCallStartForTests("call_1", "js", namespaceCollision)).toEqual(expected); + expect(mapDevinToolCallStartForTests("call_1", "js", [...namespaceCollision].reverse())).toEqual(expected); + expect(mapDevinToolCallStartForTests("call_1", "js", bareCollision)).toEqual(expected); + expect(mapDevinToolCallStartForTests("call_1", "js", duplicateIdentity)).toEqual({ + type: "tool_call_start", + id: "call_1", + name: "mcp__cua_repl__js", + }); + }); + + test("replays a restored namespaced call under the same bare name Cognition was offered", () => { + const parsed: OcxParsedRequest = { + modelId: "swe-2", + stream: true, + context: { + messages: [{ + role: "assistant", + content: [{ + type: "toolCall", + id: "js_0", + namespace: "mcp__cua_repl", + name: "js", + arguments: { code: "1+1" }, + }], + timestamp: 1, + }], + tools: [{ + namespace: "mcp__cua_repl", + name: "js", + description: "control UI", + parameters: { type: "object" }, + }], + }, + options: {}, + }; + + expect(mapOcxMessagesToDevin(parsed).find(item => item.role === "assistant")?.tool_calls).toEqual([{ + id: "js_0", + name: "js", + arguments: JSON.stringify({ code: "1+1" }), + }]); + }); + test("a request with no tools keeps the system prompt exactly as it was", () => { const parsed: OcxParsedRequest = { modelId: "swe-1-7",