opencode-gitlab-duo-agentic 0.2.1 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +316 -277
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -10,7 +10,6 @@ var WORKFLOW_CONNECT_TIMEOUT_MS = 15e3;
|
|
|
10
10
|
var WORKFLOW_HEARTBEAT_INTERVAL_MS = 6e4;
|
|
11
11
|
var WORKFLOW_KEEPALIVE_INTERVAL_MS = 45e3;
|
|
12
12
|
var WORKFLOW_TOKEN_EXPIRY_BUFFER_MS = 3e4;
|
|
13
|
-
var WORKFLOW_TOOL_ERROR_MESSAGE = "Tool execution is not implemented in this client yet";
|
|
14
13
|
|
|
15
14
|
// src/gitlab/models.ts
|
|
16
15
|
import crypto from "crypto";
|
|
@@ -343,236 +342,8 @@ function toModelsConfig(available) {
|
|
|
343
342
|
return out;
|
|
344
343
|
}
|
|
345
344
|
|
|
346
|
-
// src/workflow/opencode-tools.ts
|
|
347
|
-
var SUPPORTED_TOOLS = /* @__PURE__ */ new Set([
|
|
348
|
-
"bash",
|
|
349
|
-
"read",
|
|
350
|
-
"edit",
|
|
351
|
-
"write",
|
|
352
|
-
"glob",
|
|
353
|
-
"grep"
|
|
354
|
-
]);
|
|
355
|
-
async function fetchOpencodeTools(client, provider, model) {
|
|
356
|
-
try {
|
|
357
|
-
const response = await client.tool.list({ provider, model });
|
|
358
|
-
const tools = response.data ?? [];
|
|
359
|
-
return tools.filter((t) => SUPPORTED_TOOLS.has(t.id)).map((t) => ({
|
|
360
|
-
name: t.id,
|
|
361
|
-
description: t.description,
|
|
362
|
-
inputSchema: typeof t.parameters === "string" ? t.parameters : JSON.stringify(t.parameters)
|
|
363
|
-
}));
|
|
364
|
-
} catch {
|
|
365
|
-
return [];
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
function buildFallbackTools() {
|
|
369
|
-
return [
|
|
370
|
-
{
|
|
371
|
-
name: "bash",
|
|
372
|
-
description: "Execute a shell command. Use the `command` parameter for the command to run, `description` for a short summary, optional `timeout` in milliseconds, and optional `workdir` to set the working directory.",
|
|
373
|
-
inputSchema: JSON.stringify({
|
|
374
|
-
type: "object",
|
|
375
|
-
properties: {
|
|
376
|
-
command: { type: "string", description: "The command to execute" },
|
|
377
|
-
description: { type: "string", description: "Clear, concise description of what this command does in 5-10 words" },
|
|
378
|
-
timeout: { type: "number", description: "Optional timeout in milliseconds" },
|
|
379
|
-
workdir: { type: "string", description: "The working directory to run the command in" }
|
|
380
|
-
},
|
|
381
|
-
required: ["command", "description"]
|
|
382
|
-
})
|
|
383
|
-
},
|
|
384
|
-
{
|
|
385
|
-
name: "read",
|
|
386
|
-
description: "Read a file or directory from the local filesystem. Returns content with line numbers prefixed. Supports offset and limit for pagination. Can read images and PDFs as attachments.",
|
|
387
|
-
inputSchema: JSON.stringify({
|
|
388
|
-
type: "object",
|
|
389
|
-
properties: {
|
|
390
|
-
filePath: { type: "string", description: "The absolute path to the file or directory to read" },
|
|
391
|
-
offset: { type: "number", description: "The line number to start reading from (1-indexed)" },
|
|
392
|
-
limit: { type: "number", description: "The maximum number of lines to read (defaults to 2000)" }
|
|
393
|
-
},
|
|
394
|
-
required: ["filePath"]
|
|
395
|
-
})
|
|
396
|
-
},
|
|
397
|
-
{
|
|
398
|
-
name: "edit",
|
|
399
|
-
description: "Performs exact string replacements in files. The oldString must match the file contents exactly. Provide surrounding context to make the match unique.",
|
|
400
|
-
inputSchema: JSON.stringify({
|
|
401
|
-
type: "object",
|
|
402
|
-
properties: {
|
|
403
|
-
filePath: { type: "string", description: "The absolute path to the file to modify" },
|
|
404
|
-
oldString: { type: "string", description: "The text to replace" },
|
|
405
|
-
newString: { type: "string", description: "The text to replace it with" },
|
|
406
|
-
replaceAll: { type: "boolean", description: "Replace all occurrences (default false)" }
|
|
407
|
-
},
|
|
408
|
-
required: ["filePath", "oldString", "newString"]
|
|
409
|
-
})
|
|
410
|
-
},
|
|
411
|
-
{
|
|
412
|
-
name: "write",
|
|
413
|
-
description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Parent directories are created automatically.",
|
|
414
|
-
inputSchema: JSON.stringify({
|
|
415
|
-
type: "object",
|
|
416
|
-
properties: {
|
|
417
|
-
filePath: { type: "string", description: "The absolute path to the file to write" },
|
|
418
|
-
content: { type: "string", description: "The content to write to the file" }
|
|
419
|
-
},
|
|
420
|
-
required: ["filePath", "content"]
|
|
421
|
-
})
|
|
422
|
-
},
|
|
423
|
-
{
|
|
424
|
-
name: "glob",
|
|
425
|
-
description: "Fast file pattern matching. Supports glob patterns like '**/*.js' or 'src/**/*.ts'. Returns matching file paths sorted by modification time.",
|
|
426
|
-
inputSchema: JSON.stringify({
|
|
427
|
-
type: "object",
|
|
428
|
-
properties: {
|
|
429
|
-
pattern: { type: "string", description: "The glob pattern to match files against" },
|
|
430
|
-
path: { type: "string", description: "The directory to search in" }
|
|
431
|
-
},
|
|
432
|
-
required: ["pattern"]
|
|
433
|
-
})
|
|
434
|
-
},
|
|
435
|
-
{
|
|
436
|
-
name: "grep",
|
|
437
|
-
description: "Fast content search using regular expressions. Returns file paths and line numbers with matches. Supports filtering files by pattern with the include parameter.",
|
|
438
|
-
inputSchema: JSON.stringify({
|
|
439
|
-
type: "object",
|
|
440
|
-
properties: {
|
|
441
|
-
pattern: { type: "string", description: "The regex pattern to search for in file contents" },
|
|
442
|
-
path: { type: "string", description: "The directory to search in" },
|
|
443
|
-
include: { type: "string", description: "File pattern to include in the search (e.g. '*.js', '*.{ts,tsx}')" }
|
|
444
|
-
},
|
|
445
|
-
required: ["pattern"]
|
|
446
|
-
})
|
|
447
|
-
}
|
|
448
|
-
];
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// src/workflow/flow-config.ts
|
|
452
|
-
function buildFlowConfig(systemPrompt) {
|
|
453
|
-
return {
|
|
454
|
-
version: "v1",
|
|
455
|
-
environment: "chat-partial",
|
|
456
|
-
components: [
|
|
457
|
-
{
|
|
458
|
-
name: "opencode_agent",
|
|
459
|
-
type: "AgentComponent",
|
|
460
|
-
// Empty toolset: we rely entirely on MCP tools (OpenCode tools)
|
|
461
|
-
// registered via the mcpTools field in startRequest
|
|
462
|
-
toolset: []
|
|
463
|
-
}
|
|
464
|
-
],
|
|
465
|
-
prompts: [
|
|
466
|
-
{
|
|
467
|
-
name: "opencode_prompt",
|
|
468
|
-
prompt_id: "opencode_prompt",
|
|
469
|
-
model: {
|
|
470
|
-
params: {
|
|
471
|
-
model_class_provider: "anthropic",
|
|
472
|
-
max_tokens: 32768
|
|
473
|
-
}
|
|
474
|
-
},
|
|
475
|
-
unit_primitives: ["duo_chat"],
|
|
476
|
-
prompt_template: {
|
|
477
|
-
system: systemPrompt,
|
|
478
|
-
user: "{{goal}}",
|
|
479
|
-
placeholder: "history"
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
]
|
|
483
|
-
};
|
|
484
|
-
}
|
|
485
|
-
var OPENCODE_SYSTEM_PROMPT = `You are OpenCode, an AI coding assistant integrated with GitLab Duo.
|
|
486
|
-
|
|
487
|
-
<core_mission>
|
|
488
|
-
Your primary role is collaborative programming - working alongside the user to accomplish coding objectives using the tools available to you.
|
|
489
|
-
</core_mission>
|
|
490
|
-
|
|
491
|
-
<communication_guidelines>
|
|
492
|
-
- Provide clear and concise responses. Brevity and clarity are critical.
|
|
493
|
-
- Focus on clean, practical solutions that help users make progress.
|
|
494
|
-
- Keep responses brief and to the point. One-word answers are fine when they suffice.
|
|
495
|
-
- Use active, present-tense language: 'Analyzing...', 'Processing...' instead of 'Let me...', 'I will...'
|
|
496
|
-
- When unable to complete requests, explain the limitation concisely and provide alternatives.
|
|
497
|
-
- When users correct you, acknowledge briefly and apply the correction immediately.
|
|
498
|
-
</communication_guidelines>
|
|
499
|
-
|
|
500
|
-
<tool_usage>
|
|
501
|
-
You have access to file system and shell tools. Use them effectively:
|
|
502
|
-
|
|
503
|
-
- Use the 'read' tool to read files before editing them.
|
|
504
|
-
- Use the 'edit' tool for precise string replacements in existing files.
|
|
505
|
-
- Use the 'write' tool to create new files or overwrite existing ones.
|
|
506
|
-
- Use the 'glob' tool to find files by name patterns.
|
|
507
|
-
- Use the 'grep' tool to search file contents with regex.
|
|
508
|
-
- Use the 'bash' tool for shell commands (git, npm, docker, tests, builds, etc.).
|
|
509
|
-
|
|
510
|
-
Tool orchestration:
|
|
511
|
-
- Execute multiple independent tool operations in parallel when possible.
|
|
512
|
-
- Read files before modifying them to understand context.
|
|
513
|
-
- Only use sequential execution when one operation depends on another's output.
|
|
514
|
-
- Plan your information needs upfront and execute searches together.
|
|
515
|
-
</tool_usage>
|
|
516
|
-
|
|
517
|
-
<code_analysis>
|
|
518
|
-
Before writing any code:
|
|
519
|
-
1. Read existing files to understand context and preserve important logic.
|
|
520
|
-
2. Check dependencies exist before importing.
|
|
521
|
-
3. Match existing patterns: import style, naming conventions, component structure, error handling.
|
|
522
|
-
</code_analysis>
|
|
523
|
-
|
|
524
|
-
<code_standards>
|
|
525
|
-
- Write high-quality, general purpose solutions that work for all valid inputs.
|
|
526
|
-
- Make code immediately executable. No placeholders like "TODO: implement this".
|
|
527
|
-
- Match existing patterns in the codebase.
|
|
528
|
-
- Follow the project's established error handling approach.
|
|
529
|
-
- Verify changes work as expected before completing the task.
|
|
530
|
-
</code_standards>
|
|
531
|
-
|
|
532
|
-
<file_guidelines>
|
|
533
|
-
- ALWAYS prefer editing existing files over creating new ones.
|
|
534
|
-
- NEVER create documentation files unless explicitly requested.
|
|
535
|
-
- Use extended thinking for complex tasks rather than creating temporary files.
|
|
536
|
-
</file_guidelines>
|
|
537
|
-
|
|
538
|
-
<git_guidelines>
|
|
539
|
-
When working with git:
|
|
540
|
-
- Only create commits when explicitly requested by the user.
|
|
541
|
-
- NEVER run destructive git commands (push --force, hard reset) unless explicitly requested.
|
|
542
|
-
- NEVER skip hooks unless explicitly requested.
|
|
543
|
-
- Draft concise commit messages that focus on the "why" rather than the "what".
|
|
544
|
-
- Do not push to remote unless explicitly asked.
|
|
545
|
-
</git_guidelines>`;
|
|
546
|
-
|
|
547
|
-
// src/workflow/tools-config-store.ts
|
|
548
|
-
var stored;
|
|
549
|
-
function setToolsConfig(config) {
|
|
550
|
-
stored = config;
|
|
551
|
-
}
|
|
552
|
-
function getToolsConfig() {
|
|
553
|
-
return stored;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
345
|
// src/plugin/hooks.ts
|
|
557
|
-
async function initializeToolsConfig(input) {
|
|
558
|
-
let mcpTools = await fetchOpencodeTools(
|
|
559
|
-
input.client,
|
|
560
|
-
"gitlab",
|
|
561
|
-
"duo-chat-sonnet-4-5"
|
|
562
|
-
);
|
|
563
|
-
if (mcpTools.length === 0) {
|
|
564
|
-
mcpTools = buildFallbackTools();
|
|
565
|
-
}
|
|
566
|
-
const flowConfig = buildFlowConfig(OPENCODE_SYSTEM_PROMPT);
|
|
567
|
-
setToolsConfig({
|
|
568
|
-
mcpTools,
|
|
569
|
-
flowConfig,
|
|
570
|
-
flowConfigSchemaVersion: "v1"
|
|
571
|
-
});
|
|
572
|
-
}
|
|
573
346
|
async function createPluginHooks(input) {
|
|
574
|
-
initializeToolsConfig(input).catch(() => {
|
|
575
|
-
});
|
|
576
347
|
return {
|
|
577
348
|
config: async (config) => applyRuntimeConfig(config, input.directory),
|
|
578
349
|
"chat.message": async ({ sessionID }, { parts }) => {
|
|
@@ -620,10 +391,10 @@ function isGitLabProvider(model) {
|
|
|
620
391
|
import { NoSuchModelError } from "@ai-sdk/provider";
|
|
621
392
|
|
|
622
393
|
// src/provider/duo-workflow-model.ts
|
|
623
|
-
import { randomUUID as
|
|
394
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
624
395
|
|
|
625
396
|
// src/workflow/session.ts
|
|
626
|
-
import { randomUUID } from "crypto";
|
|
397
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
627
398
|
|
|
628
399
|
// src/utils/async-queue.ts
|
|
629
400
|
var AsyncQueue = class {
|
|
@@ -645,9 +416,11 @@ var AsyncQueue = class {
|
|
|
645
416
|
};
|
|
646
417
|
|
|
647
418
|
// src/workflow/checkpoint.ts
|
|
419
|
+
import { randomUUID } from "crypto";
|
|
648
420
|
function createCheckpointState() {
|
|
649
421
|
return {
|
|
650
|
-
uiChatLog: []
|
|
422
|
+
uiChatLog: [],
|
|
423
|
+
processedRequestIndices: /* @__PURE__ */ new Set()
|
|
651
424
|
};
|
|
652
425
|
}
|
|
653
426
|
function extractAgentTextDeltas(checkpoint, state) {
|
|
@@ -672,6 +445,23 @@ function extractAgentTextDeltas(checkpoint, state) {
|
|
|
672
445
|
state.uiChatLog = next;
|
|
673
446
|
return out;
|
|
674
447
|
}
|
|
448
|
+
function extractToolRequests(checkpoint, state) {
|
|
449
|
+
const next = parseCheckpoint(checkpoint);
|
|
450
|
+
const requests = [];
|
|
451
|
+
for (let i = 0; i < next.length; i++) {
|
|
452
|
+
const item = next[i];
|
|
453
|
+
if (item.message_type !== "request") continue;
|
|
454
|
+
if (!item.tool_info) continue;
|
|
455
|
+
if (state.processedRequestIndices.has(i)) continue;
|
|
456
|
+
state.processedRequestIndices.add(i);
|
|
457
|
+
requests.push({
|
|
458
|
+
requestId: item.correlation_id ?? randomUUID(),
|
|
459
|
+
toolName: item.tool_info.name,
|
|
460
|
+
args: item.tool_info.args ?? {}
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
return requests;
|
|
464
|
+
}
|
|
675
465
|
function parseCheckpoint(raw) {
|
|
676
466
|
if (!raw) return [];
|
|
677
467
|
try {
|
|
@@ -746,9 +536,6 @@ var WORKFLOW_STATUS = {
|
|
|
746
536
|
function isCheckpointAction(action) {
|
|
747
537
|
return "newCheckpoint" in action && action.newCheckpoint != null;
|
|
748
538
|
}
|
|
749
|
-
function isMcpToolAction(action) {
|
|
750
|
-
return "runMCPTool" in action && action.runMCPTool != null;
|
|
751
|
-
}
|
|
752
539
|
var TURN_COMPLETE_STATUSES = /* @__PURE__ */ new Set([
|
|
753
540
|
WORKFLOW_STATUS.INPUT_REQUIRED,
|
|
754
541
|
WORKFLOW_STATUS.FINISHED,
|
|
@@ -853,15 +640,198 @@ function decodeSocketMessage(data) {
|
|
|
853
640
|
}
|
|
854
641
|
|
|
855
642
|
// src/workflow/tool-executor.ts
|
|
856
|
-
import { readFile, readdir, writeFile, mkdir, stat } from "fs/promises";
|
|
643
|
+
import { readFile, readdir, writeFile, mkdir as fsMkdir, stat } from "fs/promises";
|
|
857
644
|
import { exec } from "child_process";
|
|
858
645
|
import { resolve, dirname } from "path";
|
|
646
|
+
function asString(value) {
|
|
647
|
+
return typeof value === "string" ? value : void 0;
|
|
648
|
+
}
|
|
649
|
+
function asNumber(value) {
|
|
650
|
+
return typeof value === "number" ? value : void 0;
|
|
651
|
+
}
|
|
652
|
+
function asStringArray(value) {
|
|
653
|
+
if (!Array.isArray(value)) return [];
|
|
654
|
+
return value.filter((v) => typeof v === "string");
|
|
655
|
+
}
|
|
859
656
|
var ToolExecutor = class {
|
|
860
657
|
#cwd;
|
|
861
|
-
|
|
658
|
+
#client;
|
|
659
|
+
constructor(cwd, client) {
|
|
862
660
|
this.#cwd = cwd;
|
|
661
|
+
this.#client = client;
|
|
863
662
|
}
|
|
864
|
-
|
|
663
|
+
/**
|
|
664
|
+
* Execute a tool request using Duo tool names and arg formats.
|
|
665
|
+
* This handles checkpoint-based tool requests where tool_info contains
|
|
666
|
+
* Duo-native names (read_file, edit_file, etc.) and Duo-native arg keys
|
|
667
|
+
* (file_path, old_str, new_str, etc.).
|
|
668
|
+
*/
|
|
669
|
+
async executeDuoTool(toolName, args) {
|
|
670
|
+
try {
|
|
671
|
+
switch (toolName) {
|
|
672
|
+
case "read_file": {
|
|
673
|
+
const filePath = asString(args.file_path) ?? asString(args.filepath) ?? asString(args.filePath) ?? asString(args.path);
|
|
674
|
+
if (!filePath) return { response: "", error: "read_file: missing file_path" };
|
|
675
|
+
return this.#read({ filePath, offset: asNumber(args.offset), limit: asNumber(args.limit) });
|
|
676
|
+
}
|
|
677
|
+
case "read_files": {
|
|
678
|
+
const paths = asStringArray(args.file_paths);
|
|
679
|
+
if (paths.length === 0) return { response: "", error: "read_files: missing file_paths" };
|
|
680
|
+
return this.#readMultiple(paths);
|
|
681
|
+
}
|
|
682
|
+
case "create_file_with_contents": {
|
|
683
|
+
const filePath = asString(args.file_path);
|
|
684
|
+
const content = asString(args.contents);
|
|
685
|
+
if (!filePath || content === void 0) return { response: "", error: "create_file: missing file_path or contents" };
|
|
686
|
+
return this.#write({ filePath, content });
|
|
687
|
+
}
|
|
688
|
+
case "edit_file": {
|
|
689
|
+
const filePath = asString(args.file_path);
|
|
690
|
+
const oldString = asString(args.old_str);
|
|
691
|
+
const newString = asString(args.new_str);
|
|
692
|
+
if (!filePath || oldString === void 0 || newString === void 0) {
|
|
693
|
+
return { response: "", error: "edit_file: missing file_path, old_str or new_str" };
|
|
694
|
+
}
|
|
695
|
+
return this.#edit({ filePath, oldString, newString });
|
|
696
|
+
}
|
|
697
|
+
case "list_dir": {
|
|
698
|
+
const directory = asString(args.directory) ?? ".";
|
|
699
|
+
return this.#read({ filePath: directory });
|
|
700
|
+
}
|
|
701
|
+
case "find_files": {
|
|
702
|
+
const pattern = asString(args.name_pattern);
|
|
703
|
+
if (!pattern) return { response: "", error: "find_files: missing name_pattern" };
|
|
704
|
+
return this.#glob({ pattern });
|
|
705
|
+
}
|
|
706
|
+
case "grep": {
|
|
707
|
+
const pattern = asString(args.pattern);
|
|
708
|
+
if (!pattern) return { response: "", error: "grep: missing pattern" };
|
|
709
|
+
return this.#grep({
|
|
710
|
+
pattern,
|
|
711
|
+
path: asString(args.search_directory),
|
|
712
|
+
caseInsensitive: Boolean(args.case_insensitive)
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
case "mkdir": {
|
|
716
|
+
const dir = asString(args.directory_path);
|
|
717
|
+
if (!dir) return { response: "", error: "mkdir: missing directory_path" };
|
|
718
|
+
return this.#mkdir(dir);
|
|
719
|
+
}
|
|
720
|
+
case "shell_command": {
|
|
721
|
+
const command = asString(args.command);
|
|
722
|
+
if (!command) return { response: "", error: "shell_command: missing command" };
|
|
723
|
+
return this.#bash({ command });
|
|
724
|
+
}
|
|
725
|
+
case "run_command": {
|
|
726
|
+
const program = asString(args.program);
|
|
727
|
+
if (!program) return { response: "", error: "run_command: missing program" };
|
|
728
|
+
const parts = [program];
|
|
729
|
+
const flags = args.flags;
|
|
730
|
+
if (Array.isArray(flags)) parts.push(...flags.map(String));
|
|
731
|
+
const cmdArgs = args.arguments;
|
|
732
|
+
if (Array.isArray(cmdArgs)) parts.push(...cmdArgs.map(String));
|
|
733
|
+
return this.#bash({ command: parts.join(" ") });
|
|
734
|
+
}
|
|
735
|
+
case "run_git_command": {
|
|
736
|
+
const command = asString(args.command);
|
|
737
|
+
if (!command) return { response: "", error: "run_git_command: missing command" };
|
|
738
|
+
const extra = Array.isArray(args.args) ? args.args.map(String).join(" ") : asString(args.args);
|
|
739
|
+
const gitCmd = extra ? `git ${command} ${extra}` : `git ${command}`;
|
|
740
|
+
return this.#bash({ command: gitCmd });
|
|
741
|
+
}
|
|
742
|
+
case "gitlab_api_request": {
|
|
743
|
+
const method = asString(args.method) ?? "GET";
|
|
744
|
+
const path3 = asString(args.path);
|
|
745
|
+
if (!path3) return { response: "", error: "gitlab_api_request: missing path" };
|
|
746
|
+
return this.#httpRequest({ method, path: path3, body: asString(args.body) });
|
|
747
|
+
}
|
|
748
|
+
default: {
|
|
749
|
+
console.error(`[tool-executor] unknown Duo tool: ${toolName}, args: ${JSON.stringify(args).slice(0, 300)}`);
|
|
750
|
+
return { response: "", error: `Unknown Duo tool: ${toolName}` };
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
} catch (err) {
|
|
754
|
+
console.error(`[tool-executor] executeDuoTool error:`, err);
|
|
755
|
+
return { response: "", error: err instanceof Error ? err.message : String(err) };
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Route a Duo WorkflowToolAction to the appropriate local handler.
|
|
760
|
+
* This handles standalone WebSocket actions (non-checkpoint path).
|
|
761
|
+
*/
|
|
762
|
+
async executeAction(action) {
|
|
763
|
+
try {
|
|
764
|
+
if (action.runReadFile) {
|
|
765
|
+
return this.#read({
|
|
766
|
+
filePath: action.runReadFile.filepath,
|
|
767
|
+
offset: action.runReadFile.offset,
|
|
768
|
+
limit: action.runReadFile.limit
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
if (action.runReadFiles) {
|
|
772
|
+
return this.#readMultiple(action.runReadFiles.filepaths);
|
|
773
|
+
}
|
|
774
|
+
if (action.runWriteFile) {
|
|
775
|
+
return this.#write({
|
|
776
|
+
filePath: action.runWriteFile.filepath,
|
|
777
|
+
content: action.runWriteFile.contents
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
if (action.runEditFile) {
|
|
781
|
+
return this.#edit({
|
|
782
|
+
filePath: action.runEditFile.filepath,
|
|
783
|
+
oldString: action.runEditFile.oldString,
|
|
784
|
+
newString: action.runEditFile.newString
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
if (action.runShellCommand) {
|
|
788
|
+
return this.#bash({ command: action.runShellCommand.command });
|
|
789
|
+
}
|
|
790
|
+
if (action.runCommand) {
|
|
791
|
+
const parts = [action.runCommand.program];
|
|
792
|
+
if (action.runCommand.flags) parts.push(...action.runCommand.flags);
|
|
793
|
+
if (action.runCommand.arguments) parts.push(...action.runCommand.arguments);
|
|
794
|
+
return this.#bash({ command: parts.join(" ") });
|
|
795
|
+
}
|
|
796
|
+
if (action.runGitCommand) {
|
|
797
|
+
const cmd = action.runGitCommand.arguments ? `git ${action.runGitCommand.command} ${action.runGitCommand.arguments}` : `git ${action.runGitCommand.command}`;
|
|
798
|
+
return this.#bash({ command: cmd });
|
|
799
|
+
}
|
|
800
|
+
if (action.listDirectory) {
|
|
801
|
+
return this.#read({ filePath: action.listDirectory.directory });
|
|
802
|
+
}
|
|
803
|
+
if (action.grep) {
|
|
804
|
+
return this.#grep({
|
|
805
|
+
pattern: action.grep.pattern,
|
|
806
|
+
path: action.grep.search_directory,
|
|
807
|
+
caseInsensitive: action.grep.case_insensitive
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
if (action.findFiles) {
|
|
811
|
+
return this.#glob({ pattern: action.findFiles.name_pattern });
|
|
812
|
+
}
|
|
813
|
+
if (action.mkdir) {
|
|
814
|
+
return this.#mkdir(action.mkdir.directory_path);
|
|
815
|
+
}
|
|
816
|
+
if (action.runMCPTool) {
|
|
817
|
+
return this.#executeMcpTool(action.runMCPTool.name, action.runMCPTool.args);
|
|
818
|
+
}
|
|
819
|
+
if (action.runHTTPRequest) {
|
|
820
|
+
return this.#httpRequest(action.runHTTPRequest);
|
|
821
|
+
}
|
|
822
|
+
const keys = Object.keys(action).filter((k) => k !== "requestID");
|
|
823
|
+
console.error(`[tool-executor] unhandled action, keys: ${JSON.stringify(keys)}, raw: ${JSON.stringify(action).slice(0, 500)}`);
|
|
824
|
+
return { response: "", error: `Unknown tool action (keys: ${keys.join(", ")})` };
|
|
825
|
+
} catch (err) {
|
|
826
|
+
console.error(`[tool-executor] execution error:`, err);
|
|
827
|
+
return { response: "", error: err instanceof Error ? err.message : String(err) };
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Execute an MCP tool by name (for runMCPTool actions).
|
|
832
|
+
* Routes to the appropriate handler based on tool name.
|
|
833
|
+
*/
|
|
834
|
+
async #executeMcpTool(name, argsJson) {
|
|
865
835
|
const args = argsJson ? JSON.parse(argsJson) : {};
|
|
866
836
|
switch (name) {
|
|
867
837
|
case "bash":
|
|
@@ -877,21 +847,21 @@ var ToolExecutor = class {
|
|
|
877
847
|
case "grep":
|
|
878
848
|
return this.#grep(args);
|
|
879
849
|
default:
|
|
880
|
-
return { response: "", error: `Unknown tool: ${name}` };
|
|
850
|
+
return { response: "", error: `Unknown MCP tool: ${name}` };
|
|
881
851
|
}
|
|
882
852
|
}
|
|
853
|
+
// ── Handlers ──────────────────────────────────────────────────────
|
|
883
854
|
async #bash(args) {
|
|
884
855
|
const cwd = args.workdir ? resolve(this.#cwd, args.workdir) : this.#cwd;
|
|
885
856
|
const timeout = args.timeout ?? 12e4;
|
|
886
|
-
return new Promise((
|
|
857
|
+
return new Promise((res) => {
|
|
887
858
|
exec(args.command, { cwd, timeout, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
859
|
+
const output = [stdout, stderr].filter(Boolean).join("\n");
|
|
888
860
|
if (error) {
|
|
889
|
-
|
|
890
|
-
resolve2({ response: output2, error: error.message });
|
|
861
|
+
res({ response: output, error: error.message });
|
|
891
862
|
return;
|
|
892
863
|
}
|
|
893
|
-
|
|
894
|
-
resolve2({ response: output });
|
|
864
|
+
res({ response: output });
|
|
895
865
|
});
|
|
896
866
|
});
|
|
897
867
|
}
|
|
@@ -915,6 +885,20 @@ var ToolExecutor = class {
|
|
|
915
885
|
return { response: "", error: err instanceof Error ? err.message : String(err) };
|
|
916
886
|
}
|
|
917
887
|
}
|
|
888
|
+
async #readMultiple(filepaths) {
|
|
889
|
+
const results = [];
|
|
890
|
+
for (const fp of filepaths) {
|
|
891
|
+
const result = await this.#read({ filePath: fp });
|
|
892
|
+
if (result.error) {
|
|
893
|
+
results.push(`--- ${fp} ---
|
|
894
|
+
ERROR: ${result.error}`);
|
|
895
|
+
} else {
|
|
896
|
+
results.push(`--- ${fp} ---
|
|
897
|
+
${result.response}`);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return { response: results.join("\n\n") };
|
|
901
|
+
}
|
|
918
902
|
async #edit(args) {
|
|
919
903
|
try {
|
|
920
904
|
const filepath = resolve(this.#cwd, args.filePath);
|
|
@@ -944,7 +928,7 @@ var ToolExecutor = class {
|
|
|
944
928
|
async #write(args) {
|
|
945
929
|
try {
|
|
946
930
|
const filepath = resolve(this.#cwd, args.filePath);
|
|
947
|
-
await
|
|
931
|
+
await fsMkdir(dirname(filepath), { recursive: true });
|
|
948
932
|
await writeFile(filepath, args.content, "utf-8");
|
|
949
933
|
return { response: `Successfully wrote ${args.filePath}` };
|
|
950
934
|
} catch (err) {
|
|
@@ -953,38 +937,76 @@ var ToolExecutor = class {
|
|
|
953
937
|
}
|
|
954
938
|
async #glob(args) {
|
|
955
939
|
const cwd = args.path ? resolve(this.#cwd, args.path) : this.#cwd;
|
|
956
|
-
return new Promise((
|
|
940
|
+
return new Promise((res) => {
|
|
957
941
|
const command = process.platform === "win32" ? `dir /s /b "${args.pattern}"` : `find . -path "./${args.pattern}" -not -path "*/node_modules/*" -not -path "*/.git/*" 2>/dev/null | head -200`;
|
|
958
942
|
exec(command, { cwd, timeout: 3e4 }, (error, stdout) => {
|
|
959
943
|
if (error && !stdout) {
|
|
960
944
|
exec(`ls -1 ${args.pattern} 2>/dev/null | head -200`, { cwd, timeout: 3e4 }, (_err, out) => {
|
|
961
|
-
|
|
945
|
+
res({ response: out.trim() || "No matches found" });
|
|
962
946
|
});
|
|
963
947
|
return;
|
|
964
948
|
}
|
|
965
949
|
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
966
|
-
|
|
950
|
+
res({ response: lines.length > 0 ? lines.join("\n") : "No matches found" });
|
|
967
951
|
});
|
|
968
952
|
});
|
|
969
953
|
}
|
|
970
954
|
async #grep(args) {
|
|
971
955
|
const cwd = args.path ? resolve(this.#cwd, args.path) : this.#cwd;
|
|
972
|
-
|
|
956
|
+
const caseFlag = args.caseInsensitive ? "--ignore-case" : "";
|
|
957
|
+
return new Promise((res) => {
|
|
973
958
|
const includeFlag = args.include ? `--glob '${args.include}'` : "";
|
|
974
|
-
const
|
|
975
|
-
|
|
959
|
+
const escapedPattern = args.pattern.replace(/'/g, "'\\''");
|
|
960
|
+
const rgCommand = `rg --line-number --no-heading ${caseFlag} ${includeFlag} '${escapedPattern}' . 2>/dev/null | head -500`;
|
|
961
|
+
exec(rgCommand, { cwd, timeout: 3e4, maxBuffer: 5 * 1024 * 1024 }, (_error, stdout) => {
|
|
976
962
|
if (stdout.trim()) {
|
|
977
|
-
|
|
963
|
+
res({ response: stdout.trim() });
|
|
978
964
|
return;
|
|
979
965
|
}
|
|
966
|
+
const grepCaseFlag = args.caseInsensitive ? "-i" : "";
|
|
980
967
|
const grepInclude = args.include ? `--include='${args.include}'` : "";
|
|
981
|
-
const grepCommand = `grep -rn ${grepInclude} '${
|
|
968
|
+
const grepCommand = `grep -rn ${grepCaseFlag} ${grepInclude} '${escapedPattern}' . 2>/dev/null | head -500`;
|
|
982
969
|
exec(grepCommand, { cwd, timeout: 3e4, maxBuffer: 5 * 1024 * 1024 }, (_err, out) => {
|
|
983
|
-
|
|
970
|
+
res({ response: out.trim() || "No matches found" });
|
|
984
971
|
});
|
|
985
972
|
});
|
|
986
973
|
});
|
|
987
974
|
}
|
|
975
|
+
async #mkdir(directoryPath) {
|
|
976
|
+
try {
|
|
977
|
+
const filepath = resolve(this.#cwd, directoryPath);
|
|
978
|
+
await fsMkdir(filepath, { recursive: true });
|
|
979
|
+
return { response: `Created directory ${directoryPath}` };
|
|
980
|
+
} catch (err) {
|
|
981
|
+
return { response: "", error: err instanceof Error ? err.message : String(err) };
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
async #httpRequest(args) {
|
|
985
|
+
if (!this.#client) {
|
|
986
|
+
return { response: "", error: "HTTP requests require GitLab client credentials" };
|
|
987
|
+
}
|
|
988
|
+
try {
|
|
989
|
+
const url = `${this.#client.instanceUrl}/api/v4/${args.path}`;
|
|
990
|
+
const headers = {
|
|
991
|
+
authorization: `Bearer ${this.#client.token}`
|
|
992
|
+
};
|
|
993
|
+
if (args.body) {
|
|
994
|
+
headers["content-type"] = "application/json";
|
|
995
|
+
}
|
|
996
|
+
const response = await fetch(url, {
|
|
997
|
+
method: args.method,
|
|
998
|
+
headers,
|
|
999
|
+
body: args.body ?? void 0
|
|
1000
|
+
});
|
|
1001
|
+
const text2 = await response.text();
|
|
1002
|
+
if (!response.ok) {
|
|
1003
|
+
return { response: text2, error: `HTTP ${response.status}: ${response.statusText}` };
|
|
1004
|
+
}
|
|
1005
|
+
return { response: text2 };
|
|
1006
|
+
} catch (err) {
|
|
1007
|
+
return { response: "", error: err instanceof Error ? err.message : String(err) };
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
988
1010
|
};
|
|
989
1011
|
|
|
990
1012
|
// src/workflow/session.ts
|
|
@@ -1006,8 +1028,12 @@ var WorkflowSession = class {
|
|
|
1006
1028
|
this.#tokenService = new WorkflowTokenService(client);
|
|
1007
1029
|
this.#modelId = modelId;
|
|
1008
1030
|
this.#cwd = cwd;
|
|
1009
|
-
this.#toolExecutor = new ToolExecutor(cwd);
|
|
1031
|
+
this.#toolExecutor = new ToolExecutor(cwd, client);
|
|
1010
1032
|
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Opt-in: override the server-side system prompt and/or register MCP tools.
|
|
1035
|
+
* When not called, the server uses its default prompt and built-in tools.
|
|
1036
|
+
*/
|
|
1011
1037
|
setToolsConfig(config) {
|
|
1012
1038
|
this.#toolsConfig = config;
|
|
1013
1039
|
}
|
|
@@ -1047,7 +1073,7 @@ var WorkflowSession = class {
|
|
|
1047
1073
|
await socket.connect(url, {
|
|
1048
1074
|
authorization: `Bearer ${this.#client.token}`,
|
|
1049
1075
|
origin: new URL(this.#client.instanceUrl).origin,
|
|
1050
|
-
"x-request-id":
|
|
1076
|
+
"x-request-id": randomUUID2(),
|
|
1051
1077
|
"x-gitlab-client-type": "node-websocket"
|
|
1052
1078
|
});
|
|
1053
1079
|
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
@@ -1062,10 +1088,11 @@ var WorkflowSession = class {
|
|
|
1062
1088
|
workflowMetadata: JSON.stringify({
|
|
1063
1089
|
extended_logging: access?.workflow_metadata?.extended_logging ?? false
|
|
1064
1090
|
}),
|
|
1065
|
-
clientCapabilities: [],
|
|
1091
|
+
clientCapabilities: ["shell_command"],
|
|
1066
1092
|
mcpTools,
|
|
1067
1093
|
additional_context: [],
|
|
1068
1094
|
preapproved_tools: preapprovedTools,
|
|
1095
|
+
// Only include flowConfig when explicitly configured (opt-in prompt override)
|
|
1069
1096
|
...this.#toolsConfig?.flowConfig ? {
|
|
1070
1097
|
flowConfig: this.#toolsConfig.flowConfig,
|
|
1071
1098
|
flowConfigSchemaVersion: this.#toolsConfig.flowConfigSchemaVersion ?? "v1"
|
|
@@ -1081,39 +1108,48 @@ var WorkflowSession = class {
|
|
|
1081
1108
|
throw new Error(`workflow websocket closed abnormally (${event.code}): ${event.reason}`);
|
|
1082
1109
|
}
|
|
1083
1110
|
if (isCheckpointAction(event.action)) {
|
|
1084
|
-
const
|
|
1111
|
+
const ckpt = event.action.newCheckpoint.checkpoint;
|
|
1112
|
+
const deltas = extractAgentTextDeltas(ckpt, this.#checkpoint);
|
|
1085
1113
|
for (const delta of deltas) {
|
|
1086
1114
|
yield {
|
|
1087
1115
|
type: "text-delta",
|
|
1088
1116
|
value: delta
|
|
1089
1117
|
};
|
|
1090
1118
|
}
|
|
1119
|
+
const toolRequests = extractToolRequests(ckpt, this.#checkpoint);
|
|
1120
|
+
for (const req of toolRequests) {
|
|
1121
|
+
console.error(`[duo-workflow] checkpoint tool request: ${req.toolName} requestId=${req.requestId} args=${JSON.stringify(req.args).slice(0, 200)}`);
|
|
1122
|
+
const result2 = await this.#toolExecutor.executeDuoTool(req.toolName, req.args);
|
|
1123
|
+
console.error(`[duo-workflow] checkpoint tool result: ${result2.response.length} bytes, error=${result2.error ?? "none"}`);
|
|
1124
|
+
socket.send({
|
|
1125
|
+
actionResponse: {
|
|
1126
|
+
requestID: req.requestId,
|
|
1127
|
+
plainTextResponse: {
|
|
1128
|
+
response: result2.response,
|
|
1129
|
+
error: result2.error ?? ""
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1091
1134
|
if (isTurnComplete(event.action.newCheckpoint.status)) {
|
|
1092
1135
|
socket.close();
|
|
1093
1136
|
}
|
|
1094
1137
|
continue;
|
|
1095
1138
|
}
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
socket.send({
|
|
1101
|
-
actionResponse: {
|
|
1102
|
-
requestID: event.action.requestID,
|
|
1103
|
-
plainTextResponse: {
|
|
1104
|
-
response: result.response,
|
|
1105
|
-
error: result.error ?? ""
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
});
|
|
1139
|
+
const actionKeys = Object.keys(event.action).filter((k) => k !== "requestID");
|
|
1140
|
+
console.error(`[duo-workflow] tool action received: keys=${JSON.stringify(actionKeys)} requestID=${event.action.requestID ?? "MISSING"}`);
|
|
1141
|
+
if (!event.action.requestID) {
|
|
1142
|
+
console.error("[duo-workflow] skipping action without requestID");
|
|
1109
1143
|
continue;
|
|
1110
1144
|
}
|
|
1145
|
+
const result = await this.#toolExecutor.executeAction(event.action);
|
|
1146
|
+
console.error(`[duo-workflow] tool result: response=${result.response.length} bytes, error=${result.error ?? "none"}`);
|
|
1111
1147
|
socket.send({
|
|
1112
1148
|
actionResponse: {
|
|
1113
1149
|
requestID: event.action.requestID,
|
|
1114
1150
|
plainTextResponse: {
|
|
1115
|
-
response:
|
|
1116
|
-
error:
|
|
1151
|
+
response: result.response,
|
|
1152
|
+
error: result.error ?? ""
|
|
1117
1153
|
}
|
|
1118
1154
|
}
|
|
1119
1155
|
});
|
|
@@ -1224,7 +1260,11 @@ var DuoWorkflowModel = class {
|
|
|
1224
1260
|
this.#client = client;
|
|
1225
1261
|
this.#cwd = cwd ?? process.cwd();
|
|
1226
1262
|
}
|
|
1227
|
-
/**
|
|
1263
|
+
/**
|
|
1264
|
+
* Opt-in: override the server-side system prompt and/or register MCP tools.
|
|
1265
|
+
* When not called, the server uses its default prompt and built-in tools.
|
|
1266
|
+
* Tool execution is always bridged locally regardless of this setting.
|
|
1267
|
+
*/
|
|
1228
1268
|
setToolsConfig(config) {
|
|
1229
1269
|
this.#toolsConfig = config;
|
|
1230
1270
|
for (const session of sessions.values()) {
|
|
@@ -1259,7 +1299,7 @@ var DuoWorkflowModel = class {
|
|
|
1259
1299
|
const goal = extractGoal(options.prompt);
|
|
1260
1300
|
if (!goal) throw new Error("missing user message content");
|
|
1261
1301
|
const session = this.#resolveSession(sessionID);
|
|
1262
|
-
const textId =
|
|
1302
|
+
const textId = randomUUID3();
|
|
1263
1303
|
return {
|
|
1264
1304
|
stream: new ReadableStream({
|
|
1265
1305
|
start: async (controller) => {
|
|
@@ -1328,8 +1368,7 @@ var DuoWorkflowModel = class {
|
|
|
1328
1368
|
const existing = sessions.get(key);
|
|
1329
1369
|
if (existing) return existing;
|
|
1330
1370
|
const created = new WorkflowSession(this.#client, this.modelId, this.#cwd);
|
|
1331
|
-
|
|
1332
|
-
if (config) created.setToolsConfig(config);
|
|
1371
|
+
if (this.#toolsConfig) created.setToolsConfig(this.#toolsConfig);
|
|
1333
1372
|
sessions.set(key, created);
|
|
1334
1373
|
return created;
|
|
1335
1374
|
}
|