Prechádzať zdrojové kódy

feat(llm): integrate GitLab DWS tool approval with permission system (#19955)

Co-authored-by: Aiden Cline <[email protected]>
Vladimir Glafirov 1 týždeň pred
rodič
commit
cd8e8a9928

+ 2 - 2
bun.lock

@@ -367,7 +367,7 @@
         "drizzle-orm": "catalog:",
         "effect": "catalog:",
         "fuzzysort": "3.1.0",
-        "gitlab-ai-provider": "6.0.0",
+        "gitlab-ai-provider": "6.4.2",
         "glob": "13.0.5",
         "google-auth-library": "10.5.0",
         "gray-matter": "4.0.3",
@@ -3165,7 +3165,7 @@
 
     "github-slugger": ["[email protected]", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
 
-    "gitlab-ai-provider": ["gitlab-ai-provider@6.0.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-683GcJdrer/GhnljkbVcGsndCEhvGB8f9fUdCxQBlkuyt8rzf0G9DpSh+iMBYp9HpcSvYmYG0Qv5ks9dLrNxwQ=="],
+    "gitlab-ai-provider": ["gitlab-ai-provider@6.4.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Wyw6uslCuipBOr/NYwAtpgXEUJj68iJY5aekad2DjePN99JetKVQBqkLgAy9PZp2EA4OuscfRQu9qKIBN/evNw=="],
 
     "glob": ["[email protected]", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
 

+ 1 - 1
packages/opencode/package.json

@@ -137,7 +137,7 @@
     "drizzle-orm": "catalog:",
     "effect": "catalog:",
     "fuzzysort": "3.1.0",
-    "gitlab-ai-provider": "6.0.0",
+    "gitlab-ai-provider": "6.4.2",
     "glob": "13.0.5",
     "google-auth-library": "10.5.0",
     "gray-matter": "4.0.3",

+ 1 - 0
packages/opencode/src/provider/provider.ts

@@ -574,6 +574,7 @@ export namespace Provider {
               const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
               const model = sdk.workflowChat(sdkModelID, {
                 featureFlags,
+                workflowDefinition: options?.workflowDefinition as string | undefined,
               })
               if (workflowRef) {
                 model.selectedModelRef = workflowRef

+ 56 - 0
packages/opencode/src/session/llm.ts

@@ -15,6 +15,10 @@ import { Plugin } from "@/plugin"
 import { SystemPrompt } from "./system"
 import { Flag } from "@/flag/flag"
 import { Permission } from "@/permission"
+import { PermissionID } from "@/permission/schema"
+import { Bus } from "@/bus"
+import { Wildcard } from "@/util/wildcard"
+import { SessionID } from "@/session/schema"
 import { Auth } from "@/auth"
 import { Installation } from "@/installation"
 
@@ -231,6 +235,7 @@ export namespace LLM {
     // and results sent back over the WebSocket.
     if (language instanceof GitLabWorkflowLanguageModel) {
       const workflowModel = language
+      workflowModel.sessionID = input.sessionID
       workflowModel.systemPrompt = system.join("\n")
       workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
         const t = tools[toolName]
@@ -253,6 +258,57 @@ export namespace LLM {
           return { result: "", error: e.message ?? String(e) }
         }
       }
+
+      const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
+      workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => {
+        const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
+        return !match || match.action !== "ask"
+      })
+
+      const approvedToolsForSession = new Set<string>()
+      workflowModel.approvalHandler = Instance.bind(async (approvalTools) => {
+        const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
+        // Auto-approve tools that were already approved in this session
+        // (prevents infinite approval loops for server-side MCP tools)
+        if (uniqueNames.every((name) => approvedToolsForSession.has(name))) {
+          return { approved: true }
+        }
+
+        const id = PermissionID.ascending()
+        let reply: Permission.Reply | undefined
+        let unsub: (() => void) | undefined
+        try {
+          unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
+            if (evt.properties.requestID === id) reply = evt.properties.reply
+          })
+          const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
+            try {
+              const parsed = JSON.parse(t.args) as Record<string, unknown>
+              const title = (parsed?.title ?? parsed?.name ?? "") as string
+              return title ? `${t.name}: ${title}` : t.name
+            } catch {
+              return t.name
+            }
+          })
+          const uniquePatterns = [...new Set(toolPatterns)] as string[]
+          await Permission.ask({
+            id,
+            sessionID: SessionID.make(input.sessionID),
+            permission: "workflow_tool_approval",
+            patterns: uniquePatterns,
+            metadata: { tools: approvalTools },
+            always: uniquePatterns,
+            ruleset: [],
+          })
+          for (const name of uniqueNames) approvedToolsForSession.add(name)
+          workflowModel.sessionPreapprovedTools = [...workflowModel.sessionPreapprovedTools, ...uniqueNames]
+          return { approved: true }
+        } catch {
+          return { approved: false }
+        } finally {
+          unsub?.()
+        }
+      })
     }
 
     return streamText({

+ 12 - 5
packages/opencode/src/session/message-v2.ts

@@ -573,6 +573,12 @@ export namespace MessageV2 {
     }))
   }
 
+  function providerMeta(metadata: Record<string, any> | undefined) {
+    if (!metadata) return undefined
+    const { providerExecuted: _, ...rest } = metadata
+    return Object.keys(rest).length > 0 ? rest : undefined
+  }
+
   export const toModelMessagesEffect = Effect.fnUntraced(function* (
     input: WithParts[],
     model: Provider.Model,
@@ -741,7 +747,8 @@ export namespace MessageV2 {
                 toolCallId: part.callID,
                 input: part.state.input,
                 output,
-                ...(differentModel ? {} : { callProviderMetadata: part.metadata }),
+                ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
+                ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
               })
             }
             if (part.state.status === "error")
@@ -751,10 +758,9 @@ export namespace MessageV2 {
                 toolCallId: part.callID,
                 input: part.state.input,
                 errorText: part.state.error,
-                ...(differentModel ? {} : { callProviderMetadata: part.metadata }),
+                ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
+                ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
               })
-            // Handle pending/running tool calls to prevent dangling tool_use blocks
-            // Anthropic/Claude APIs require every tool_use to have a corresponding tool_result
             if (part.state.status === "pending" || part.state.status === "running")
               assistantMessage.parts.push({
                 type: ("tool-" + part.tool) as `tool-${string}`,
@@ -762,7 +768,8 @@ export namespace MessageV2 {
                 toolCallId: part.callID,
                 input: part.state.input,
                 errorText: "[Tool execution was interrupted]",
-                ...(differentModel ? {} : { callProviderMetadata: part.metadata }),
+                ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
+                ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
               })
           }
           if (part.type === "reasoning") {

+ 4 - 1
packages/opencode/src/session/processor.ts

@@ -161,6 +161,7 @@ export namespace SessionProcessor {
                 tool: value.toolName,
                 callID: value.id,
                 state: { status: "pending", input: {}, raw: "" },
+                metadata: value.providerExecuted ? { providerExecuted: true } : undefined,
               } satisfies MessageV2.ToolPart)
               return
 
@@ -180,7 +181,9 @@ export namespace SessionProcessor {
                 ...match,
                 tool: value.toolName,
                 state: { status: "running", input: value.input, time: { start: Date.now() } },
-                metadata: value.providerMetadata,
+                metadata: match.metadata?.providerExecuted
+                  ? { ...value.providerMetadata, providerExecuted: true }
+                  : value.providerMetadata,
               } satisfies MessageV2.ToolPart)
 
               const parts = MessageV2.parts(ctx.assistantMessage.id)

+ 4 - 1
packages/opencode/src/session/prompt.ts

@@ -1371,7 +1371,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
             )
             // Some providers return "stop" even when the assistant message contains tool calls.
             // Keep the loop running so tool results can be sent back to the model.
-            const hasToolCalls = lastAssistantMsg?.parts.some((part) => part.type === "tool") ?? false
+            // Skip provider-executed tool parts — those were fully handled within the
+            // provider's stream (e.g. DWS Agent Platform) and don't need a re-loop.
+            const hasToolCalls =
+              lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false
 
             if (
               lastAssistant?.finish &&