feat(llm): integrate GitLab DWS tool approval with permission system (#19955)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
This commit is contained in:
co-authored by
Aiden Cline
parent
8bdcc22541
commit
cd8e8a9928
@@ -367,7 +367,7 @@
|
|||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"gitlab-ai-provider": "6.0.0",
|
"gitlab-ai-provider": "6.4.2",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
@@ -3165,7 +3165,7 @@
|
|||||||
|
|
||||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
"github-slugger": ["github-slugger@2.0.0", "", {}, "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": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,7 @@
|
|||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"gitlab-ai-provider": "6.0.0",
|
"gitlab-ai-provider": "6.4.2",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
|
|||||||
@@ -574,6 +574,7 @@ export namespace Provider {
|
|||||||
const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
|
const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
|
||||||
const model = sdk.workflowChat(sdkModelID, {
|
const model = sdk.workflowChat(sdkModelID, {
|
||||||
featureFlags,
|
featureFlags,
|
||||||
|
workflowDefinition: options?.workflowDefinition as string | undefined,
|
||||||
})
|
})
|
||||||
if (workflowRef) {
|
if (workflowRef) {
|
||||||
model.selectedModelRef = workflowRef
|
model.selectedModelRef = workflowRef
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import { Plugin } from "@/plugin"
|
|||||||
import { SystemPrompt } from "./system"
|
import { SystemPrompt } from "./system"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Permission } from "@/permission"
|
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 { Auth } from "@/auth"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
|
|
||||||
@@ -231,6 +235,7 @@ export namespace LLM {
|
|||||||
// and results sent back over the WebSocket.
|
// and results sent back over the WebSocket.
|
||||||
if (language instanceof GitLabWorkflowLanguageModel) {
|
if (language instanceof GitLabWorkflowLanguageModel) {
|
||||||
const workflowModel = language
|
const workflowModel = language
|
||||||
|
workflowModel.sessionID = input.sessionID
|
||||||
workflowModel.systemPrompt = system.join("\n")
|
workflowModel.systemPrompt = system.join("\n")
|
||||||
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
|
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
|
||||||
const t = tools[toolName]
|
const t = tools[toolName]
|
||||||
@@ -253,6 +258,57 @@ export namespace LLM {
|
|||||||
return { result: "", error: e.message ?? String(e) }
|
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({
|
return streamText({
|
||||||
|
|||||||
@@ -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* (
|
export const toModelMessagesEffect = Effect.fnUntraced(function* (
|
||||||
input: WithParts[],
|
input: WithParts[],
|
||||||
model: Provider.Model,
|
model: Provider.Model,
|
||||||
@@ -741,7 +747,8 @@ export namespace MessageV2 {
|
|||||||
toolCallId: part.callID,
|
toolCallId: part.callID,
|
||||||
input: part.state.input,
|
input: part.state.input,
|
||||||
output,
|
output,
|
||||||
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
|
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
|
||||||
|
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (part.state.status === "error")
|
if (part.state.status === "error")
|
||||||
@@ -751,10 +758,9 @@ export namespace MessageV2 {
|
|||||||
toolCallId: part.callID,
|
toolCallId: part.callID,
|
||||||
input: part.state.input,
|
input: part.state.input,
|
||||||
errorText: part.state.error,
|
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")
|
if (part.state.status === "pending" || part.state.status === "running")
|
||||||
assistantMessage.parts.push({
|
assistantMessage.parts.push({
|
||||||
type: ("tool-" + part.tool) as `tool-${string}`,
|
type: ("tool-" + part.tool) as `tool-${string}`,
|
||||||
@@ -762,7 +768,8 @@ export namespace MessageV2 {
|
|||||||
toolCallId: part.callID,
|
toolCallId: part.callID,
|
||||||
input: part.state.input,
|
input: part.state.input,
|
||||||
errorText: "[Tool execution was interrupted]",
|
errorText: "[Tool execution was interrupted]",
|
||||||
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
|
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
|
||||||
|
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (part.type === "reasoning") {
|
if (part.type === "reasoning") {
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ export namespace SessionProcessor {
|
|||||||
tool: value.toolName,
|
tool: value.toolName,
|
||||||
callID: value.id,
|
callID: value.id,
|
||||||
state: { status: "pending", input: {}, raw: "" },
|
state: { status: "pending", input: {}, raw: "" },
|
||||||
|
metadata: value.providerExecuted ? { providerExecuted: true } : undefined,
|
||||||
} satisfies MessageV2.ToolPart)
|
} satisfies MessageV2.ToolPart)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -180,7 +181,9 @@ export namespace SessionProcessor {
|
|||||||
...match,
|
...match,
|
||||||
tool: value.toolName,
|
tool: value.toolName,
|
||||||
state: { status: "running", input: value.input, time: { start: Date.now() } },
|
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)
|
} satisfies MessageV2.ToolPart)
|
||||||
|
|
||||||
const parts = MessageV2.parts(ctx.assistantMessage.id)
|
const parts = MessageV2.parts(ctx.assistantMessage.id)
|
||||||
|
|||||||
@@ -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.
|
// 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.
|
// 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 (
|
if (
|
||||||
lastAssistant?.finish &&
|
lastAssistant?.finish &&
|
||||||
|
|||||||
Reference in New Issue
Block a user