@mcoda/core 0.1.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/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +9 -0
- package/dist/api/AgentsApi.d.ts +36 -0
- package/dist/api/AgentsApi.d.ts.map +1 -0
- package/dist/api/AgentsApi.js +176 -0
- package/dist/api/QaTasksApi.d.ts +8 -0
- package/dist/api/QaTasksApi.d.ts.map +1 -0
- package/dist/api/QaTasksApi.js +36 -0
- package/dist/api/TasksApi.d.ts +7 -0
- package/dist/api/TasksApi.d.ts.map +1 -0
- package/dist/api/TasksApi.js +34 -0
- package/dist/config/ConfigService.d.ts +3 -0
- package/dist/config/ConfigService.d.ts.map +1 -0
- package/dist/config/ConfigService.js +2 -0
- package/dist/domain/dependencies/Dependency.d.ts +3 -0
- package/dist/domain/dependencies/Dependency.d.ts.map +1 -0
- package/dist/domain/dependencies/Dependency.js +2 -0
- package/dist/domain/epics/Epic.d.ts +3 -0
- package/dist/domain/epics/Epic.d.ts.map +1 -0
- package/dist/domain/epics/Epic.js +2 -0
- package/dist/domain/projects/Project.d.ts +3 -0
- package/dist/domain/projects/Project.d.ts.map +1 -0
- package/dist/domain/projects/Project.js +2 -0
- package/dist/domain/tasks/Task.d.ts +3 -0
- package/dist/domain/tasks/Task.d.ts.map +1 -0
- package/dist/domain/tasks/Task.js +2 -0
- package/dist/domain/userStories/UserStory.d.ts +3 -0
- package/dist/domain/userStories/UserStory.d.ts.map +1 -0
- package/dist/domain/userStories/UserStory.js +2 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/prompts/PdrPrompts.d.ts +4 -0
- package/dist/prompts/PdrPrompts.d.ts.map +1 -0
- package/dist/prompts/PdrPrompts.js +21 -0
- package/dist/prompts/PromptLoader.d.ts +3 -0
- package/dist/prompts/PromptLoader.d.ts.map +1 -0
- package/dist/prompts/PromptLoader.js +2 -0
- package/dist/prompts/SdsPrompts.d.ts +5 -0
- package/dist/prompts/SdsPrompts.d.ts.map +1 -0
- package/dist/prompts/SdsPrompts.js +44 -0
- package/dist/services/agents/AgentManagementService.d.ts +3 -0
- package/dist/services/agents/AgentManagementService.d.ts.map +1 -0
- package/dist/services/agents/AgentManagementService.js +2 -0
- package/dist/services/agents/GatewayAgentService.d.ts +92 -0
- package/dist/services/agents/GatewayAgentService.d.ts.map +1 -0
- package/dist/services/agents/GatewayAgentService.js +870 -0
- package/dist/services/agents/RoutingApiClient.d.ts +23 -0
- package/dist/services/agents/RoutingApiClient.d.ts.map +1 -0
- package/dist/services/agents/RoutingApiClient.js +62 -0
- package/dist/services/agents/RoutingService.d.ts +50 -0
- package/dist/services/agents/RoutingService.d.ts.map +1 -0
- package/dist/services/agents/RoutingService.js +386 -0
- package/dist/services/agents/generated/RoutingApiClient.d.ts +21 -0
- package/dist/services/agents/generated/RoutingApiClient.d.ts.map +1 -0
- package/dist/services/agents/generated/RoutingApiClient.js +68 -0
- package/dist/services/backlog/BacklogService.d.ts +98 -0
- package/dist/services/backlog/BacklogService.d.ts.map +1 -0
- package/dist/services/backlog/BacklogService.js +453 -0
- package/dist/services/backlog/TaskOrderingService.d.ts +88 -0
- package/dist/services/backlog/TaskOrderingService.d.ts.map +1 -0
- package/dist/services/backlog/TaskOrderingService.js +675 -0
- package/dist/services/docs/DocsService.d.ts +82 -0
- package/dist/services/docs/DocsService.d.ts.map +1 -0
- package/dist/services/docs/DocsService.js +1631 -0
- package/dist/services/estimate/EstimateService.d.ts +12 -0
- package/dist/services/estimate/EstimateService.d.ts.map +1 -0
- package/dist/services/estimate/EstimateService.js +103 -0
- package/dist/services/estimate/VelocityService.d.ts +19 -0
- package/dist/services/estimate/VelocityService.d.ts.map +1 -0
- package/dist/services/estimate/VelocityService.js +237 -0
- package/dist/services/estimate/types.d.ts +30 -0
- package/dist/services/estimate/types.d.ts.map +1 -0
- package/dist/services/estimate/types.js +1 -0
- package/dist/services/execution/ExecutionService.d.ts +3 -0
- package/dist/services/execution/ExecutionService.d.ts.map +1 -0
- package/dist/services/execution/ExecutionService.js +2 -0
- package/dist/services/execution/QaFollowupService.d.ts +38 -0
- package/dist/services/execution/QaFollowupService.d.ts.map +1 -0
- package/dist/services/execution/QaFollowupService.js +236 -0
- package/dist/services/execution/QaProfileService.d.ts +22 -0
- package/dist/services/execution/QaProfileService.d.ts.map +1 -0
- package/dist/services/execution/QaProfileService.js +142 -0
- package/dist/services/execution/QaTasksService.d.ts +101 -0
- package/dist/services/execution/QaTasksService.d.ts.map +1 -0
- package/dist/services/execution/QaTasksService.js +1117 -0
- package/dist/services/execution/TaskSelectionService.d.ts +50 -0
- package/dist/services/execution/TaskSelectionService.d.ts.map +1 -0
- package/dist/services/execution/TaskSelectionService.js +281 -0
- package/dist/services/execution/TaskStateService.d.ts +19 -0
- package/dist/services/execution/TaskStateService.d.ts.map +1 -0
- package/dist/services/execution/TaskStateService.js +59 -0
- package/dist/services/execution/WorkOnTasksService.d.ts +80 -0
- package/dist/services/execution/WorkOnTasksService.d.ts.map +1 -0
- package/dist/services/execution/WorkOnTasksService.js +1833 -0
- package/dist/services/jobs/JobInsightsService.d.ts +97 -0
- package/dist/services/jobs/JobInsightsService.d.ts.map +1 -0
- package/dist/services/jobs/JobInsightsService.js +263 -0
- package/dist/services/jobs/JobResumeService.d.ts +16 -0
- package/dist/services/jobs/JobResumeService.d.ts.map +1 -0
- package/dist/services/jobs/JobResumeService.js +113 -0
- package/dist/services/jobs/JobService.d.ts +149 -0
- package/dist/services/jobs/JobService.d.ts.map +1 -0
- package/dist/services/jobs/JobService.js +490 -0
- package/dist/services/jobs/JobsApiClient.d.ts +73 -0
- package/dist/services/jobs/JobsApiClient.d.ts.map +1 -0
- package/dist/services/jobs/JobsApiClient.js +67 -0
- package/dist/services/openapi/OpenApiService.d.ts +54 -0
- package/dist/services/openapi/OpenApiService.d.ts.map +1 -0
- package/dist/services/openapi/OpenApiService.js +503 -0
- package/dist/services/planning/CreateTasksService.d.ts +68 -0
- package/dist/services/planning/CreateTasksService.d.ts.map +1 -0
- package/dist/services/planning/CreateTasksService.js +989 -0
- package/dist/services/planning/KeyHelpers.d.ts +5 -0
- package/dist/services/planning/KeyHelpers.d.ts.map +1 -0
- package/dist/services/planning/KeyHelpers.js +62 -0
- package/dist/services/planning/PlanningService.d.ts +3 -0
- package/dist/services/planning/PlanningService.d.ts.map +1 -0
- package/dist/services/planning/PlanningService.js +2 -0
- package/dist/services/planning/RefineTasksService.d.ts +56 -0
- package/dist/services/planning/RefineTasksService.d.ts.map +1 -0
- package/dist/services/planning/RefineTasksService.js +1328 -0
- package/dist/services/review/CodeReviewService.d.ts +103 -0
- package/dist/services/review/CodeReviewService.d.ts.map +1 -0
- package/dist/services/review/CodeReviewService.js +1187 -0
- package/dist/services/system/SystemUpdateService.d.ts +55 -0
- package/dist/services/system/SystemUpdateService.d.ts.map +1 -0
- package/dist/services/system/SystemUpdateService.js +136 -0
- package/dist/services/tasks/TaskApiResolver.d.ts +7 -0
- package/dist/services/tasks/TaskApiResolver.d.ts.map +1 -0
- package/dist/services/tasks/TaskApiResolver.js +41 -0
- package/dist/services/tasks/TaskDetailService.d.ts +106 -0
- package/dist/services/tasks/TaskDetailService.d.ts.map +1 -0
- package/dist/services/tasks/TaskDetailService.js +332 -0
- package/dist/services/telemetry/TelemetryService.d.ts +53 -0
- package/dist/services/telemetry/TelemetryService.d.ts.map +1 -0
- package/dist/services/telemetry/TelemetryService.js +434 -0
- package/dist/workspace/WorkspaceManager.d.ts +35 -0
- package/dist/workspace/WorkspaceManager.d.ts.map +1 -0
- package/dist/workspace/WorkspaceManager.js +201 -0
- package/package.json +45 -0
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { AgentService } from "@mcoda/agents";
|
|
4
|
+
import { DocdexClient } from "@mcoda/integrations";
|
|
5
|
+
import { GlobalRepository, WorkspaceRepository } from "@mcoda/db";
|
|
6
|
+
import { canonicalizeCommandName, getCommandRequiredCapabilities } from "@mcoda/shared";
|
|
7
|
+
import { JobService } from "../jobs/JobService.js";
|
|
8
|
+
import { TaskSelectionService } from "../execution/TaskSelectionService.js";
|
|
9
|
+
import { RoutingService } from "./RoutingService.js";
|
|
10
|
+
const DEFAULT_GATEWAY_PROMPT = [
|
|
11
|
+
"You are the gateway agent. Read the task context and docdex snippets, digest the task, decide what is done vs. remaining, and plan the work.",
|
|
12
|
+
"You must identify concrete file paths to modify or create before offloading.",
|
|
13
|
+
"Do not use placeholders like (unknown), TBD, or glob patterns in file paths.",
|
|
14
|
+
"If docdex returns no results, say so in docdexNotes.",
|
|
15
|
+
"Do not leave currentState, todo, or understanding blank.",
|
|
16
|
+
"Put reasoningSummary near the top of the JSON object so it appears early in the stream.",
|
|
17
|
+
"Do not claim to have read files or performed a repo scan unless explicit file content was provided.",
|
|
18
|
+
"Do not include fields outside the schema.",
|
|
19
|
+
"Return JSON only with the following schema:",
|
|
20
|
+
"{",
|
|
21
|
+
' "summary": "1-3 sentence summary of the task and intent",',
|
|
22
|
+
' "reasoningSummary": "1-2 sentence high-level rationale (no chain-of-thought)",',
|
|
23
|
+
' "currentState": "short statement of what is already implemented or known to exist",',
|
|
24
|
+
' "todo": "short statement of what still needs to be done",',
|
|
25
|
+
' "understanding": "short statement of what success looks like",',
|
|
26
|
+
' "plan": ["step 1", "step 2", "step 3"],',
|
|
27
|
+
' "complexity": 1-10,',
|
|
28
|
+
' "discipline": "backend|frontend|uiux|docs|architecture|qa|planning|ops|other",',
|
|
29
|
+
' "filesLikelyTouched": ["path/to/file.ext"],',
|
|
30
|
+
' "filesToCreate": ["path/to/new_file.ext"],',
|
|
31
|
+
' "assumptions": ["assumption 1"],',
|
|
32
|
+
' "risks": ["risk 1"],',
|
|
33
|
+
' "docdexNotes": ["notes about docdex coverage/gaps"]',
|
|
34
|
+
"}",
|
|
35
|
+
"If information is missing, keep arrays empty and mention the gap in assumptions or docdexNotes.",
|
|
36
|
+
].join("\n");
|
|
37
|
+
const REQUIRED_PROMPT_MARKERS = [
|
|
38
|
+
'"summary"',
|
|
39
|
+
'"reasoningSummary"',
|
|
40
|
+
'"currentState"',
|
|
41
|
+
'"todo"',
|
|
42
|
+
'"understanding"',
|
|
43
|
+
'"filesLikelyTouched"',
|
|
44
|
+
'"filesToCreate"',
|
|
45
|
+
];
|
|
46
|
+
const hasRequiredPromptMarkers = (content) => REQUIRED_PROMPT_MARKERS.every((marker) => content.includes(marker));
|
|
47
|
+
const DEFAULT_JOB_PROMPT = "You are an mcoda agent that follows workspace runbooks and responds with actionable, concise output.";
|
|
48
|
+
const DEFAULT_CHARACTER_PROMPT = "Write clearly, avoid hallucinations, cite assumptions, and prioritize risk mitigation for the user.";
|
|
49
|
+
const extractJson = (raw) => {
|
|
50
|
+
const fenced = raw.match(/```json([\s\S]*?)```/);
|
|
51
|
+
const candidate = fenced ? fenced[1] : raw;
|
|
52
|
+
const start = candidate.indexOf("{");
|
|
53
|
+
const end = candidate.lastIndexOf("}");
|
|
54
|
+
if (start === -1 || end === -1 || end <= start)
|
|
55
|
+
return undefined;
|
|
56
|
+
const body = candidate.slice(start, end + 1);
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(body);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const estimateTokens = (text) => Math.max(1, Math.ceil((text ?? "").length / 4));
|
|
65
|
+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
66
|
+
const normalizeList = (value) => {
|
|
67
|
+
if (Array.isArray(value))
|
|
68
|
+
return value.map((item) => String(item)).filter(Boolean);
|
|
69
|
+
if (typeof value === "string" && value.trim())
|
|
70
|
+
return [value.trim()];
|
|
71
|
+
return [];
|
|
72
|
+
};
|
|
73
|
+
const normalizeTextField = (value) => {
|
|
74
|
+
if (typeof value === "string") {
|
|
75
|
+
const trimmed = value.trim();
|
|
76
|
+
return trimmed ? trimmed : undefined;
|
|
77
|
+
}
|
|
78
|
+
if (Array.isArray(value)) {
|
|
79
|
+
const parts = value.map((item) => String(item).trim()).filter(Boolean);
|
|
80
|
+
return parts.length ? parts.join("; ") : undefined;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
};
|
|
84
|
+
const isPlaceholderPath = (value) => {
|
|
85
|
+
const lower = value.trim().toLowerCase();
|
|
86
|
+
if (!lower)
|
|
87
|
+
return true;
|
|
88
|
+
const hasWord = (word) => new RegExp(`\\b${word}\\b`, "i").test(lower);
|
|
89
|
+
if (lower.includes("(unknown)") || hasWord("unknown") || hasWord("tbd") || hasWord("todo"))
|
|
90
|
+
return true;
|
|
91
|
+
if (lower.includes("...") || lower.includes("*") || lower.includes("<") || lower.includes(">"))
|
|
92
|
+
return true;
|
|
93
|
+
return false;
|
|
94
|
+
};
|
|
95
|
+
const normalizeFileList = (value) => normalizeList(value).map((item) => item.trim()).filter((item) => item.length > 0 && !isPlaceholderPath(item));
|
|
96
|
+
const listMissingFields = (raw) => {
|
|
97
|
+
const missing = [];
|
|
98
|
+
const summary = normalizeTextField(raw?.summary);
|
|
99
|
+
const reasoningSummary = normalizeTextField(raw?.reasoningSummary);
|
|
100
|
+
const currentState = normalizeTextField(raw?.currentState);
|
|
101
|
+
const todo = normalizeTextField(raw?.todo);
|
|
102
|
+
const understanding = normalizeTextField(raw?.understanding);
|
|
103
|
+
const plan = normalizeList(raw?.plan);
|
|
104
|
+
const filesLikelyTouched = normalizeFileList(raw?.filesLikelyTouched);
|
|
105
|
+
const filesToCreate = normalizeFileList(raw?.filesToCreate);
|
|
106
|
+
if (!summary)
|
|
107
|
+
missing.push("summary");
|
|
108
|
+
if (!reasoningSummary)
|
|
109
|
+
missing.push("reasoningSummary");
|
|
110
|
+
if (!currentState)
|
|
111
|
+
missing.push("currentState");
|
|
112
|
+
if (!todo)
|
|
113
|
+
missing.push("todo");
|
|
114
|
+
if (!understanding)
|
|
115
|
+
missing.push("understanding");
|
|
116
|
+
if (plan.length === 0)
|
|
117
|
+
missing.push("plan");
|
|
118
|
+
if (filesLikelyTouched.length === 0 && filesToCreate.length === 0)
|
|
119
|
+
missing.push("files");
|
|
120
|
+
return missing;
|
|
121
|
+
};
|
|
122
|
+
const normalizeDiscipline = (value) => {
|
|
123
|
+
if (!value)
|
|
124
|
+
return undefined;
|
|
125
|
+
const normalized = value.trim().toLowerCase();
|
|
126
|
+
if (!normalized)
|
|
127
|
+
return undefined;
|
|
128
|
+
const allowed = new Set(["backend", "frontend", "uiux", "docs", "architecture", "qa", "planning", "ops", "other"]);
|
|
129
|
+
return allowed.has(normalized) ? normalized : "other";
|
|
130
|
+
};
|
|
131
|
+
const inferDiscipline = (job, taskTitles, input) => {
|
|
132
|
+
const text = [job, ...taskTitles, input ?? ""].join(" ").toLowerCase();
|
|
133
|
+
if (text.includes("sds") || text.includes("pdr") || text.includes("documentation"))
|
|
134
|
+
return "docs";
|
|
135
|
+
if (text.includes("openapi") || text.includes("spec"))
|
|
136
|
+
return "docs";
|
|
137
|
+
if (text.includes("qa") || text.includes("test"))
|
|
138
|
+
return "qa";
|
|
139
|
+
if (text.includes("architecture") || text.includes("design"))
|
|
140
|
+
return "architecture";
|
|
141
|
+
if (text.includes("refine") || text.includes("create-tasks") || text.includes("planning"))
|
|
142
|
+
return "planning";
|
|
143
|
+
if (text.includes("frontend") || text.includes("ui") || text.includes("ux"))
|
|
144
|
+
return "frontend";
|
|
145
|
+
if (text.includes("backend") || text.includes("api") || text.includes("database"))
|
|
146
|
+
return "backend";
|
|
147
|
+
return "other";
|
|
148
|
+
};
|
|
149
|
+
const usageKeywords = {
|
|
150
|
+
backend: ["backend", "api", "server", "db", "database"],
|
|
151
|
+
frontend: ["frontend", "ui", "ux", "web", "react", "mobile"],
|
|
152
|
+
uiux: ["ui", "ux", "design", "prototype"],
|
|
153
|
+
docs: ["doc", "documentation", "sds", "pdr", "spec"],
|
|
154
|
+
architecture: ["arch", "architecture", "system", "design"],
|
|
155
|
+
qa: ["qa", "test", "testing", "quality"],
|
|
156
|
+
planning: ["plan", "planning", "product", "pm"],
|
|
157
|
+
ops: ["ops", "devops", "infra", "deployment"],
|
|
158
|
+
};
|
|
159
|
+
const scoreUsage = (discipline, bestUsage, capabilities) => {
|
|
160
|
+
if (!discipline)
|
|
161
|
+
return 0;
|
|
162
|
+
const normalized = (bestUsage ?? "").toLowerCase();
|
|
163
|
+
const keywords = usageKeywords[discipline] ?? [];
|
|
164
|
+
const hasKeyword = keywords.some((k) => normalized.includes(k));
|
|
165
|
+
let score = hasKeyword ? 1 : 0;
|
|
166
|
+
const caps = new Set((capabilities ?? []).map((c) => c.toLowerCase()));
|
|
167
|
+
if (discipline === "docs" && caps.has("docdex_query"))
|
|
168
|
+
score += 0.5;
|
|
169
|
+
if (discipline === "qa" && caps.has("qa_interpretation"))
|
|
170
|
+
score += 0.5;
|
|
171
|
+
if (discipline === "planning" && caps.has("plan"))
|
|
172
|
+
score += 0.5;
|
|
173
|
+
if ((discipline === "backend" || discipline === "frontend") && caps.has("code_write"))
|
|
174
|
+
score += 0.5;
|
|
175
|
+
return score;
|
|
176
|
+
};
|
|
177
|
+
const DEFAULT_STATUS_FILTER = [
|
|
178
|
+
"not_started",
|
|
179
|
+
"in_progress",
|
|
180
|
+
"blocked",
|
|
181
|
+
"ready_to_review",
|
|
182
|
+
"ready_to_qa",
|
|
183
|
+
"completed",
|
|
184
|
+
"cancelled",
|
|
185
|
+
"failed",
|
|
186
|
+
"skipped",
|
|
187
|
+
];
|
|
188
|
+
const summarizeDoc = (doc, index) => {
|
|
189
|
+
const title = doc.title ?? doc.path ?? doc.id ?? `doc-${index + 1}`;
|
|
190
|
+
const excerptSource = doc.segments?.[0]?.content ?? doc.content ?? "";
|
|
191
|
+
const excerpt = excerptSource ? (excerptSource.length > 480 ? `${excerptSource.slice(0, 480)}...` : excerptSource) : undefined;
|
|
192
|
+
return {
|
|
193
|
+
id: doc.id ?? `doc-${index + 1}`,
|
|
194
|
+
docType: doc.docType,
|
|
195
|
+
title,
|
|
196
|
+
path: doc.path,
|
|
197
|
+
excerpt,
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
const buildDocContext = (docs) => {
|
|
201
|
+
if (docs.length === 0)
|
|
202
|
+
return "Docdex: (no matching documents found)";
|
|
203
|
+
return [
|
|
204
|
+
"Docdex context:",
|
|
205
|
+
...docs.map((doc) => {
|
|
206
|
+
const head = `[${doc.docType}] ${doc.title}`;
|
|
207
|
+
const tail = doc.path ? ` (${doc.path})` : "";
|
|
208
|
+
const excerpt = doc.excerpt ? `\n Excerpt: ${doc.excerpt}` : "";
|
|
209
|
+
return `- ${head}${tail}${excerpt}`;
|
|
210
|
+
}),
|
|
211
|
+
].join("\n");
|
|
212
|
+
};
|
|
213
|
+
const buildTaskContext = (tasks) => {
|
|
214
|
+
if (tasks.length === 0)
|
|
215
|
+
return "Task context: (no task records found)";
|
|
216
|
+
const lines = ["Task context:"];
|
|
217
|
+
for (const task of tasks) {
|
|
218
|
+
lines.push([
|
|
219
|
+
`- ${task.key}: ${task.title}`,
|
|
220
|
+
task.description ? ` Description: ${task.description}` : undefined,
|
|
221
|
+
task.status ? ` Status: ${task.status}` : undefined,
|
|
222
|
+
task.storyKey ? ` Story: ${task.storyKey} ${task.storyTitle ?? ""}`.trim() : undefined,
|
|
223
|
+
task.epicKey ? ` Epic: ${task.epicKey} ${task.epicTitle ?? ""}`.trim() : undefined,
|
|
224
|
+
task.storyPoints !== undefined ? ` Story points: ${task.storyPoints}` : undefined,
|
|
225
|
+
task.acceptanceCriteria?.length ? ` Acceptance: ${task.acceptanceCriteria.join(" | ")}` : undefined,
|
|
226
|
+
task.dependencies?.length ? ` Dependencies: ${task.dependencies.join(", ")}` : undefined,
|
|
227
|
+
]
|
|
228
|
+
.filter(Boolean)
|
|
229
|
+
.join("\n"));
|
|
230
|
+
}
|
|
231
|
+
return lines.join("\n");
|
|
232
|
+
};
|
|
233
|
+
export class GatewayAgentService {
|
|
234
|
+
constructor(workspace, deps) {
|
|
235
|
+
this.workspace = workspace;
|
|
236
|
+
this.deps = deps;
|
|
237
|
+
this.taskSelectionService = new TaskSelectionService(workspace, deps.workspaceRepo);
|
|
238
|
+
}
|
|
239
|
+
static async create(workspace) {
|
|
240
|
+
const globalRepo = await GlobalRepository.create();
|
|
241
|
+
const agentService = new AgentService(globalRepo);
|
|
242
|
+
const routingService = await RoutingService.create();
|
|
243
|
+
const docdex = new DocdexClient({
|
|
244
|
+
workspaceRoot: workspace.workspaceRoot,
|
|
245
|
+
baseUrl: workspace.config?.docdexUrl ?? process.env.MCODA_DOCDEX_URL,
|
|
246
|
+
});
|
|
247
|
+
const workspaceRepo = await WorkspaceRepository.create(workspace.workspaceRoot);
|
|
248
|
+
const jobService = new JobService(workspace, workspaceRepo);
|
|
249
|
+
return new GatewayAgentService(workspace, {
|
|
250
|
+
agentService,
|
|
251
|
+
docdex,
|
|
252
|
+
globalRepo,
|
|
253
|
+
jobService,
|
|
254
|
+
workspaceRepo,
|
|
255
|
+
routingService,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
async readPromptFiles(paths) {
|
|
259
|
+
const contents = [];
|
|
260
|
+
const seen = new Set();
|
|
261
|
+
for (const promptPath of paths) {
|
|
262
|
+
try {
|
|
263
|
+
const content = await fs.promises.readFile(promptPath, "utf8");
|
|
264
|
+
const trimmed = content.trim();
|
|
265
|
+
if (trimmed && !seen.has(trimmed)) {
|
|
266
|
+
contents.push(trimmed);
|
|
267
|
+
seen.add(trimmed);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
/* optional prompt */
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return contents;
|
|
275
|
+
}
|
|
276
|
+
async close() {
|
|
277
|
+
const maybeClose = async (target) => {
|
|
278
|
+
try {
|
|
279
|
+
if (target?.close)
|
|
280
|
+
await target.close();
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
/* ignore */
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
await maybeClose(this.taskSelectionService);
|
|
287
|
+
await maybeClose(this.deps.agentService);
|
|
288
|
+
await maybeClose(this.deps.docdex);
|
|
289
|
+
await maybeClose(this.deps.globalRepo);
|
|
290
|
+
await maybeClose(this.deps.jobService);
|
|
291
|
+
await maybeClose(this.deps.workspaceRepo);
|
|
292
|
+
await maybeClose(this.deps.routingService);
|
|
293
|
+
}
|
|
294
|
+
async loadGatewayPrompts(agentId) {
|
|
295
|
+
const agentPrompts = "getPrompts" in this.deps.agentService ? await this.deps.agentService.getPrompts(agentId) : undefined;
|
|
296
|
+
const mcodaPromptPath = path.join(this.workspace.workspaceRoot, ".mcoda", "prompts", "gateway-agent.md");
|
|
297
|
+
const workspacePromptPath = path.join(this.workspace.workspaceRoot, "prompts", "gateway-agent.md");
|
|
298
|
+
try {
|
|
299
|
+
await fs.promises.mkdir(path.dirname(mcodaPromptPath), { recursive: true });
|
|
300
|
+
await fs.promises.access(mcodaPromptPath);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
try {
|
|
304
|
+
await fs.promises.access(workspacePromptPath);
|
|
305
|
+
await fs.promises.copyFile(workspacePromptPath, mcodaPromptPath);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
await fs.promises.writeFile(mcodaPromptPath, DEFAULT_GATEWAY_PROMPT, "utf8");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const existing = await fs.promises.readFile(mcodaPromptPath, "utf8");
|
|
313
|
+
if (!hasRequiredPromptMarkers(existing)) {
|
|
314
|
+
let nextPrompt = DEFAULT_GATEWAY_PROMPT;
|
|
315
|
+
try {
|
|
316
|
+
const workspacePrompt = await fs.promises.readFile(workspacePromptPath, "utf8");
|
|
317
|
+
if (hasRequiredPromptMarkers(workspacePrompt)) {
|
|
318
|
+
nextPrompt = workspacePrompt.trim();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
/* ignore */
|
|
323
|
+
}
|
|
324
|
+
await fs.promises.writeFile(mcodaPromptPath, nextPrompt, "utf8");
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
/* ignore */
|
|
329
|
+
}
|
|
330
|
+
const commandPromptFiles = (await this.readPromptFiles([mcodaPromptPath, workspacePromptPath])).filter(hasRequiredPromptMarkers);
|
|
331
|
+
const mergedCommandPrompt = (() => {
|
|
332
|
+
const parts = [...commandPromptFiles];
|
|
333
|
+
const agentCommandPrompt = agentPrompts?.commandPrompts?.["gateway-agent"];
|
|
334
|
+
if (agentCommandPrompt && hasRequiredPromptMarkers(agentCommandPrompt)) {
|
|
335
|
+
parts.push(agentCommandPrompt);
|
|
336
|
+
}
|
|
337
|
+
if (!parts.length)
|
|
338
|
+
parts.push(DEFAULT_GATEWAY_PROMPT);
|
|
339
|
+
return parts.filter(Boolean).join("\n\n");
|
|
340
|
+
})();
|
|
341
|
+
return {
|
|
342
|
+
jobPrompt: agentPrompts?.jobPrompt ?? DEFAULT_JOB_PROMPT,
|
|
343
|
+
characterPrompt: agentPrompts?.characterPrompt ?? DEFAULT_CHARACTER_PROMPT,
|
|
344
|
+
commandPrompt: mergedCommandPrompt,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
async resolveGatewayAgent(override, warnings = []) {
|
|
348
|
+
try {
|
|
349
|
+
const resolved = await this.deps.routingService.resolveAgentForCommand({
|
|
350
|
+
workspace: this.workspace,
|
|
351
|
+
commandName: "gateway-agent",
|
|
352
|
+
overrideAgentSlug: override,
|
|
353
|
+
});
|
|
354
|
+
return resolved.agent;
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
warnings.push(`Routing defaults unavailable for gateway-agent; using best available agent (${error.message})`);
|
|
358
|
+
const requiredCaps = getCommandRequiredCapabilities("gateway-agent");
|
|
359
|
+
if (override) {
|
|
360
|
+
try {
|
|
361
|
+
const overrideAgent = await this.deps.agentService.resolveAgent(override);
|
|
362
|
+
const caps = await this.deps.globalRepo.getAgentCapabilities(overrideAgent.id);
|
|
363
|
+
const missing = requiredCaps.filter((cap) => !caps.includes(cap));
|
|
364
|
+
const health = await this.deps.globalRepo.getAgentHealth(overrideAgent.id);
|
|
365
|
+
if (missing.length === 0 && health?.status !== "unreachable") {
|
|
366
|
+
return overrideAgent;
|
|
367
|
+
}
|
|
368
|
+
if (missing.length) {
|
|
369
|
+
warnings.push(`Override agent ${overrideAgent.slug} is missing gateway capabilities (${missing.join(", ")}); ignoring override.`);
|
|
370
|
+
}
|
|
371
|
+
else if (health?.status === "unreachable") {
|
|
372
|
+
warnings.push(`Override agent ${overrideAgent.slug} is unreachable; ignoring override.`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
catch (overrideError) {
|
|
376
|
+
warnings.push(`Override agent ${override} could not be resolved (${overrideError.message}); ignoring override.`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const candidates = await this.listCandidates(requiredCaps, "planning");
|
|
380
|
+
if (!candidates.length) {
|
|
381
|
+
throw new Error("No eligible gateway agents available; add a plan/docdex_query-capable agent");
|
|
382
|
+
}
|
|
383
|
+
const sorted = candidates
|
|
384
|
+
.slice()
|
|
385
|
+
.sort((a, b) => {
|
|
386
|
+
const qa = b.reasoning || b.quality;
|
|
387
|
+
const qb = a.reasoning || a.quality;
|
|
388
|
+
if (qa !== qb)
|
|
389
|
+
return qa - qb;
|
|
390
|
+
if (b.quality !== a.quality)
|
|
391
|
+
return b.quality - a.quality;
|
|
392
|
+
return a.cost - b.cost;
|
|
393
|
+
});
|
|
394
|
+
return sorted[0].agent;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async invokeGatewayAgent(agent, prompt, job, options) {
|
|
398
|
+
const startedAt = Date.now();
|
|
399
|
+
const stream = options?.stream !== false;
|
|
400
|
+
const onChunk = options?.onChunk;
|
|
401
|
+
try {
|
|
402
|
+
if (stream) {
|
|
403
|
+
const generator = await this.deps.agentService.invokeStream(agent.id, {
|
|
404
|
+
input: prompt,
|
|
405
|
+
metadata: { command: "gateway-agent", job },
|
|
406
|
+
});
|
|
407
|
+
let output = "";
|
|
408
|
+
for await (const chunk of generator) {
|
|
409
|
+
const text = chunk.output ?? "";
|
|
410
|
+
output += text;
|
|
411
|
+
if (text && onChunk)
|
|
412
|
+
onChunk(text);
|
|
413
|
+
}
|
|
414
|
+
return { output, durationSeconds: (Date.now() - startedAt) / 1000 };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
const message = error.message ?? "";
|
|
419
|
+
if (!/does not support streaming/i.test(message)) {
|
|
420
|
+
throw error;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const response = await this.deps.agentService.invoke(agent.id, {
|
|
424
|
+
input: prompt,
|
|
425
|
+
metadata: { command: "gateway-agent", job },
|
|
426
|
+
});
|
|
427
|
+
const output = response.output ?? "";
|
|
428
|
+
if (output && onChunk)
|
|
429
|
+
onChunk(output);
|
|
430
|
+
return { output, durationSeconds: (Date.now() - startedAt) / 1000 };
|
|
431
|
+
}
|
|
432
|
+
async buildTasksSummary(request, warnings) {
|
|
433
|
+
const hasFilters = Boolean(request.projectKey) ||
|
|
434
|
+
Boolean(request.epicKey) ||
|
|
435
|
+
Boolean(request.storyKey) ||
|
|
436
|
+
Boolean(request.taskKeys?.length);
|
|
437
|
+
if (!hasFilters)
|
|
438
|
+
return [];
|
|
439
|
+
const limit = request.taskKeys?.length ? request.taskKeys.length : request.limit ?? 8;
|
|
440
|
+
try {
|
|
441
|
+
const filters = {
|
|
442
|
+
projectKey: request.projectKey,
|
|
443
|
+
epicKey: request.epicKey,
|
|
444
|
+
storyKey: request.storyKey,
|
|
445
|
+
taskKeys: request.taskKeys,
|
|
446
|
+
statusFilter: request.statusFilter?.length ? request.statusFilter : DEFAULT_STATUS_FILTER,
|
|
447
|
+
limit,
|
|
448
|
+
};
|
|
449
|
+
const selection = await this.taskSelectionService.selectTasks(filters);
|
|
450
|
+
if (selection.warnings.length)
|
|
451
|
+
warnings.push(...selection.warnings);
|
|
452
|
+
const combined = [...selection.ordered, ...selection.blocked];
|
|
453
|
+
return combined.slice(0, limit).map((entry) => ({
|
|
454
|
+
key: entry.task.key,
|
|
455
|
+
title: entry.task.title,
|
|
456
|
+
description: entry.task.description ?? undefined,
|
|
457
|
+
status: entry.task.status,
|
|
458
|
+
storyPoints: entry.task.storyPoints ?? undefined,
|
|
459
|
+
storyKey: entry.task.storyKey,
|
|
460
|
+
storyTitle: entry.task.storyTitle,
|
|
461
|
+
epicKey: entry.task.epicKey,
|
|
462
|
+
epicTitle: entry.task.epicTitle,
|
|
463
|
+
acceptanceCriteria: entry.task.acceptanceCriteria,
|
|
464
|
+
dependencies: entry.dependencies.keys,
|
|
465
|
+
}));
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
warnings.push(`Task lookup failed: ${error.message}`);
|
|
469
|
+
return [];
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
pickDocTypes(job, input) {
|
|
473
|
+
const types = new Set();
|
|
474
|
+
const lower = `${job} ${input ?? ""}`.toLowerCase();
|
|
475
|
+
if (lower.includes("sds"))
|
|
476
|
+
types.add("SDS");
|
|
477
|
+
if (lower.includes("pdr"))
|
|
478
|
+
types.add("PDR");
|
|
479
|
+
if (lower.includes("rfp"))
|
|
480
|
+
types.add("RFP");
|
|
481
|
+
if (lower.includes("openapi"))
|
|
482
|
+
types.add("OPENAPI");
|
|
483
|
+
if (lower.includes("doc")) {
|
|
484
|
+
types.add("SDS");
|
|
485
|
+
types.add("PDR");
|
|
486
|
+
}
|
|
487
|
+
if (types.size === 0) {
|
|
488
|
+
types.add("SDS");
|
|
489
|
+
types.add("PDR");
|
|
490
|
+
types.add("OPENAPI");
|
|
491
|
+
}
|
|
492
|
+
return Array.from(types);
|
|
493
|
+
}
|
|
494
|
+
buildQuerySeed(tasks, input) {
|
|
495
|
+
const seeds = [];
|
|
496
|
+
tasks.forEach((task) => {
|
|
497
|
+
seeds.push(task.key, task.title);
|
|
498
|
+
if (task.storyTitle)
|
|
499
|
+
seeds.push(task.storyTitle);
|
|
500
|
+
if (task.epicTitle)
|
|
501
|
+
seeds.push(task.epicTitle);
|
|
502
|
+
});
|
|
503
|
+
if (input) {
|
|
504
|
+
seeds.push(...input.split(/\s+/).slice(0, 12));
|
|
505
|
+
}
|
|
506
|
+
return Array.from(new Set(seeds.map((s) => s.trim()).filter(Boolean))).slice(0, 10).join(" ");
|
|
507
|
+
}
|
|
508
|
+
async buildDocSummaries(request, tasks, warnings) {
|
|
509
|
+
const maxDocs = request.maxDocs ?? 4;
|
|
510
|
+
if (maxDocs <= 0)
|
|
511
|
+
return [];
|
|
512
|
+
const docTypes = this.pickDocTypes(request.job, request.inputText);
|
|
513
|
+
const query = this.buildQuerySeed(tasks, request.inputText);
|
|
514
|
+
const summaries = [];
|
|
515
|
+
for (const docType of docTypes) {
|
|
516
|
+
if (summaries.length >= maxDocs)
|
|
517
|
+
break;
|
|
518
|
+
try {
|
|
519
|
+
const docs = await this.deps.docdex.search({
|
|
520
|
+
projectKey: request.projectKey,
|
|
521
|
+
docType,
|
|
522
|
+
query,
|
|
523
|
+
});
|
|
524
|
+
for (const doc of docs) {
|
|
525
|
+
if (summaries.length >= maxDocs)
|
|
526
|
+
break;
|
|
527
|
+
summaries.push(summarizeDoc(doc, summaries.length));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
warnings.push(`Docdex search failed (${docType}): ${error.message}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return summaries;
|
|
535
|
+
}
|
|
536
|
+
buildGatewayPrompt(job, tasks, docs, inputText) {
|
|
537
|
+
const taskContext = buildTaskContext(tasks);
|
|
538
|
+
const docContext = buildDocContext(docs);
|
|
539
|
+
const inputBlock = inputText ? `Additional input:\n${inputText}` : "Additional input: (none)";
|
|
540
|
+
return [`Job: ${job}`, taskContext, inputBlock, docContext].join("\n\n");
|
|
541
|
+
}
|
|
542
|
+
normalizeAnalysis(raw, job, tasks, inputText) {
|
|
543
|
+
const summary = normalizeTextField(raw?.summary);
|
|
544
|
+
const currentState = normalizeTextField(raw?.currentState);
|
|
545
|
+
const todo = normalizeTextField(raw?.todo);
|
|
546
|
+
const reasoningSummary = normalizeTextField(raw?.reasoningSummary) ?? "";
|
|
547
|
+
const understanding = normalizeTextField(raw?.understanding) ?? "";
|
|
548
|
+
const plan = normalizeList(raw?.plan);
|
|
549
|
+
const filesLikelyTouched = normalizeFileList(raw?.filesLikelyTouched);
|
|
550
|
+
const filesToCreate = normalizeFileList(raw?.filesToCreate);
|
|
551
|
+
const complexityRaw = Number(raw?.complexity);
|
|
552
|
+
const complexity = Number.isFinite(complexityRaw) ? clamp(Math.round(complexityRaw), 1, 10) : 5;
|
|
553
|
+
const discipline = normalizeDiscipline(typeof raw?.discipline === "string" ? raw.discipline : undefined) ??
|
|
554
|
+
inferDiscipline(job, tasks.map((t) => t.title), inputText);
|
|
555
|
+
const fallbackSummary = summary ??
|
|
556
|
+
(tasks.length
|
|
557
|
+
? `Handle ${tasks.length} task${tasks.length > 1 ? "s" : ""}: ${tasks.map((t) => t.key).join(", ")}.`
|
|
558
|
+
: inputText?.slice(0, 200) ?? "Summarize the requested job.");
|
|
559
|
+
const fallbackState = currentState
|
|
560
|
+
? currentState
|
|
561
|
+
: tasks.length
|
|
562
|
+
? tasks.map((t) => `${t.key} is ${t.status ?? "unknown"} (${t.title})`).join("; ")
|
|
563
|
+
: "Current state unknown; requires investigation.";
|
|
564
|
+
const fallbackTodo = todo
|
|
565
|
+
? todo
|
|
566
|
+
: tasks.length
|
|
567
|
+
? tasks.map((t) => t.title).join("; ")
|
|
568
|
+
: "Determine remaining work based on provided input and docs.";
|
|
569
|
+
return {
|
|
570
|
+
summary: fallbackSummary,
|
|
571
|
+
reasoningSummary,
|
|
572
|
+
currentState: fallbackState,
|
|
573
|
+
todo: fallbackTodo,
|
|
574
|
+
understanding,
|
|
575
|
+
plan: plan.length ? plan : ["Review requirements and docs", "Execute the job", "Verify outcomes"],
|
|
576
|
+
complexity,
|
|
577
|
+
discipline,
|
|
578
|
+
filesLikelyTouched,
|
|
579
|
+
filesToCreate,
|
|
580
|
+
assumptions: normalizeList(raw?.assumptions),
|
|
581
|
+
risks: normalizeList(raw?.risks),
|
|
582
|
+
docdexNotes: normalizeList(raw?.docdexNotes),
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
async validateFilePlan(analysis, warnings) {
|
|
586
|
+
const root = this.workspace.workspaceRoot;
|
|
587
|
+
const normalize = (file) => {
|
|
588
|
+
const resolved = path.resolve(root, file);
|
|
589
|
+
const relative = path.relative(root, resolved);
|
|
590
|
+
return { relative, resolved };
|
|
591
|
+
};
|
|
592
|
+
const isInside = (relative) => !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
593
|
+
const touched = [];
|
|
594
|
+
const created = [];
|
|
595
|
+
for (const file of analysis.filesLikelyTouched) {
|
|
596
|
+
const { relative, resolved } = normalize(file);
|
|
597
|
+
if (!isInside(relative)) {
|
|
598
|
+
warnings.push(`Gateway file path outside workspace ignored: ${file}`);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
try {
|
|
602
|
+
const stat = await fs.promises.stat(resolved);
|
|
603
|
+
if (stat.isFile()) {
|
|
604
|
+
touched.push(relative.replace(/\\/g, "/"));
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
warnings.push(`Gateway file path is not a file: ${file}`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
warnings.push(`Gateway file path does not exist: ${file}`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
for (const file of analysis.filesToCreate) {
|
|
615
|
+
const { relative, resolved } = normalize(file);
|
|
616
|
+
if (!isInside(relative)) {
|
|
617
|
+
warnings.push(`Gateway create path outside workspace ignored: ${file}`);
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
const parent = path.dirname(resolved);
|
|
621
|
+
try {
|
|
622
|
+
const stat = await fs.promises.stat(parent);
|
|
623
|
+
if (!stat.isDirectory()) {
|
|
624
|
+
warnings.push(`Gateway create path parent is not a directory: ${file}`);
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
warnings.push(`Gateway create path parent does not exist: ${file}`);
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
try {
|
|
633
|
+
const stat = await fs.promises.stat(resolved);
|
|
634
|
+
if (stat.isFile()) {
|
|
635
|
+
warnings.push(`Gateway create path already exists; treating as touch: ${file}`);
|
|
636
|
+
touched.push(relative.replace(/\\/g, "/"));
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
/* file does not exist; ok */
|
|
642
|
+
}
|
|
643
|
+
created.push(relative.replace(/\\/g, "/"));
|
|
644
|
+
}
|
|
645
|
+
return {
|
|
646
|
+
...analysis,
|
|
647
|
+
filesLikelyTouched: touched,
|
|
648
|
+
filesToCreate: created,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
async listCandidates(requiredCaps, discipline) {
|
|
652
|
+
const agents = await this.deps.globalRepo.listAgents();
|
|
653
|
+
if (agents.length === 0) {
|
|
654
|
+
throw new Error("No agents available; register one with mcoda agent add");
|
|
655
|
+
}
|
|
656
|
+
const health = await this.deps.globalRepo.listAgentHealthSummary();
|
|
657
|
+
const healthById = new Map(health.map((row) => [row.agentId, row]));
|
|
658
|
+
const candidates = [];
|
|
659
|
+
for (const agent of agents) {
|
|
660
|
+
const capabilities = await this.deps.globalRepo.getAgentCapabilities(agent.id);
|
|
661
|
+
const missing = requiredCaps.filter((cap) => !capabilities.includes(cap));
|
|
662
|
+
if (missing.length)
|
|
663
|
+
continue;
|
|
664
|
+
const healthEntry = healthById.get(agent.id);
|
|
665
|
+
if (healthEntry?.status === "unreachable")
|
|
666
|
+
continue;
|
|
667
|
+
const rating = agent.rating ?? 0;
|
|
668
|
+
const reasoning = agent.reasoningRating ?? rating;
|
|
669
|
+
const quality = discipline === "architecture" || discipline === "planning"
|
|
670
|
+
? reasoning || rating || 5
|
|
671
|
+
: rating || reasoning || 5;
|
|
672
|
+
const usageScore = scoreUsage(discipline, agent.bestUsage, capabilities);
|
|
673
|
+
const cost = agent.costPerMillion ?? Number.POSITIVE_INFINITY;
|
|
674
|
+
const adjustedQuality = healthEntry?.status === "degraded" ? quality - 0.5 : quality;
|
|
675
|
+
candidates.push({
|
|
676
|
+
agent,
|
|
677
|
+
capabilities,
|
|
678
|
+
health: healthEntry,
|
|
679
|
+
quality: adjustedQuality,
|
|
680
|
+
reasoning,
|
|
681
|
+
usageScore,
|
|
682
|
+
cost,
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
return candidates;
|
|
686
|
+
}
|
|
687
|
+
chooseCandidate(candidates, complexity, discipline) {
|
|
688
|
+
if (candidates.length === 0) {
|
|
689
|
+
throw new Error("No eligible agents available for this job");
|
|
690
|
+
}
|
|
691
|
+
const sortedQuality = candidates.map((c) => c.quality);
|
|
692
|
+
const maxQuality = Math.max(...sortedQuality);
|
|
693
|
+
if (complexity >= 9) {
|
|
694
|
+
const pick = candidates
|
|
695
|
+
.slice()
|
|
696
|
+
.sort((a, b) => {
|
|
697
|
+
if (b.quality !== a.quality)
|
|
698
|
+
return b.quality - a.quality;
|
|
699
|
+
if (b.usageScore !== a.usageScore)
|
|
700
|
+
return b.usageScore - a.usageScore;
|
|
701
|
+
if (b.reasoning !== a.reasoning)
|
|
702
|
+
return b.reasoning - a.reasoning;
|
|
703
|
+
return a.cost - b.cost;
|
|
704
|
+
})[0];
|
|
705
|
+
return {
|
|
706
|
+
pick,
|
|
707
|
+
rationale: `Complexity ${complexity}/10 requires the highest capability; selected top-rated agent with best fit for ${discipline}.`,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
if (complexity >= 8) {
|
|
711
|
+
const pool = candidates.filter((c) => c.quality >= maxQuality - 1);
|
|
712
|
+
const pick = (pool.length ? pool : candidates)
|
|
713
|
+
.slice()
|
|
714
|
+
.sort((a, b) => {
|
|
715
|
+
if (b.usageScore !== a.usageScore)
|
|
716
|
+
return b.usageScore - a.usageScore;
|
|
717
|
+
if (a.cost !== b.cost)
|
|
718
|
+
return a.cost - b.cost;
|
|
719
|
+
return b.quality - a.quality;
|
|
720
|
+
})[0];
|
|
721
|
+
return {
|
|
722
|
+
pick,
|
|
723
|
+
rationale: `Complexity ${complexity}/10 favors strong agents with good cost/fit balance; selected best-fit candidate.`,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
const target = complexity;
|
|
727
|
+
const pool = candidates.filter((c) => c.quality >= target);
|
|
728
|
+
const base = pool.length ? pool : candidates;
|
|
729
|
+
const pick = base
|
|
730
|
+
.slice()
|
|
731
|
+
.sort((a, b) => {
|
|
732
|
+
const diffA = Math.abs(a.quality - target);
|
|
733
|
+
const diffB = Math.abs(b.quality - target);
|
|
734
|
+
if (diffA !== diffB)
|
|
735
|
+
return diffA - diffB;
|
|
736
|
+
if (b.usageScore !== a.usageScore)
|
|
737
|
+
return b.usageScore - a.usageScore;
|
|
738
|
+
if (a.cost !== b.cost)
|
|
739
|
+
return a.cost - b.cost;
|
|
740
|
+
return a.quality - b.quality;
|
|
741
|
+
})[0];
|
|
742
|
+
return {
|
|
743
|
+
pick,
|
|
744
|
+
rationale: `Complexity ${complexity}/10 targets a comparable tier agent; selected closest match with discipline fit and cost awareness.`,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
async selectAgentForJob(job, analysis) {
|
|
748
|
+
const normalizedJob = canonicalizeCommandName(job);
|
|
749
|
+
const requiredCaps = getCommandRequiredCapabilities(normalizedJob);
|
|
750
|
+
const candidates = await this.listCandidates(requiredCaps, analysis.discipline);
|
|
751
|
+
const { pick, rationale } = this.chooseCandidate(candidates, analysis.complexity, analysis.discipline);
|
|
752
|
+
return {
|
|
753
|
+
agentId: pick.agent.id,
|
|
754
|
+
agentSlug: pick.agent.slug ?? pick.agent.id,
|
|
755
|
+
rating: pick.agent.rating ?? undefined,
|
|
756
|
+
reasoningRating: pick.agent.reasoningRating ?? undefined,
|
|
757
|
+
bestUsage: pick.agent.bestUsage ?? undefined,
|
|
758
|
+
costPerMillion: Number.isFinite(pick.cost) ? pick.cost : undefined,
|
|
759
|
+
rationale,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
async run(request) {
|
|
763
|
+
const warnings = [];
|
|
764
|
+
const normalizedJob = canonicalizeCommandName(request.job);
|
|
765
|
+
const commandRun = await this.deps.jobService.startCommandRun("gateway-agent", request.projectKey);
|
|
766
|
+
try {
|
|
767
|
+
const tasks = await this.buildTasksSummary(request, warnings);
|
|
768
|
+
const docs = await this.buildDocSummaries(request, tasks, warnings);
|
|
769
|
+
const gatewayAgent = await this.resolveGatewayAgent(request.gatewayAgentName, warnings);
|
|
770
|
+
const prompts = await this.loadGatewayPrompts(gatewayAgent.id);
|
|
771
|
+
const prompt = [
|
|
772
|
+
prompts.jobPrompt,
|
|
773
|
+
prompts.characterPrompt,
|
|
774
|
+
prompts.commandPrompt,
|
|
775
|
+
this.buildGatewayPrompt(normalizedJob, tasks, docs, request.inputText),
|
|
776
|
+
]
|
|
777
|
+
.filter(Boolean)
|
|
778
|
+
.join("\n\n");
|
|
779
|
+
const recordUsage = async (promptText, outputText, durationSeconds, action) => {
|
|
780
|
+
const promptTokens = estimateTokens(promptText);
|
|
781
|
+
const completionTokens = estimateTokens(outputText ?? "");
|
|
782
|
+
await this.deps.jobService.recordTokenUsage({
|
|
783
|
+
timestamp: new Date().toISOString(),
|
|
784
|
+
workspaceId: this.workspace.workspaceId,
|
|
785
|
+
commandName: "gateway-agent",
|
|
786
|
+
commandRunId: commandRun.id,
|
|
787
|
+
agentId: gatewayAgent.id,
|
|
788
|
+
modelName: gatewayAgent.defaultModel,
|
|
789
|
+
promptTokens,
|
|
790
|
+
completionTokens,
|
|
791
|
+
tokensPrompt: promptTokens,
|
|
792
|
+
tokensCompletion: completionTokens,
|
|
793
|
+
tokensTotal: promptTokens + completionTokens,
|
|
794
|
+
durationSeconds,
|
|
795
|
+
metadata: { action, job: normalizedJob },
|
|
796
|
+
});
|
|
797
|
+
};
|
|
798
|
+
const response = await this.invokeGatewayAgent(gatewayAgent, prompt, normalizedJob, {
|
|
799
|
+
stream: request.agentStream !== false,
|
|
800
|
+
onChunk: request.onStreamChunk,
|
|
801
|
+
});
|
|
802
|
+
await recordUsage(prompt, response.output ?? "", response.durationSeconds, "gateway_summary");
|
|
803
|
+
let parsed = extractJson(response.output);
|
|
804
|
+
let missingFields = parsed
|
|
805
|
+
? listMissingFields(parsed)
|
|
806
|
+
: ["summary", "reasoningSummary", "currentState", "todo", "understanding", "plan", "files"];
|
|
807
|
+
if (!parsed) {
|
|
808
|
+
warnings.push("Gateway analysis response was not valid JSON; falling back to defaults.");
|
|
809
|
+
}
|
|
810
|
+
if (missingFields.length) {
|
|
811
|
+
const repairPrompt = [
|
|
812
|
+
prompt,
|
|
813
|
+
"",
|
|
814
|
+
"Your previous response was incomplete or invalid. Return JSON only with the exact schema.",
|
|
815
|
+
`Missing fields: ${missingFields.join(", ")}.`,
|
|
816
|
+
"Ensure reasoningSummary, currentState, todo, understanding, plan, and filesLikelyTouched/filesToCreate are populated.",
|
|
817
|
+
"Use real file paths only (no placeholders like (unknown), TBD, or glob patterns).",
|
|
818
|
+
"If docdex returned no results, say so in docdexNotes.",
|
|
819
|
+
].join("\n");
|
|
820
|
+
if (request.onStreamChunk) {
|
|
821
|
+
request.onStreamChunk("\n[gateway-agent] Retrying for missing fields...\n");
|
|
822
|
+
}
|
|
823
|
+
const repairResponse = await this.invokeGatewayAgent(gatewayAgent, repairPrompt, normalizedJob, {
|
|
824
|
+
stream: request.agentStream !== false,
|
|
825
|
+
onChunk: request.onStreamChunk,
|
|
826
|
+
});
|
|
827
|
+
await recordUsage(repairPrompt, repairResponse.output ?? "", repairResponse.durationSeconds, "gateway_summary_repair");
|
|
828
|
+
const repaired = extractJson(repairResponse.output);
|
|
829
|
+
if (repaired) {
|
|
830
|
+
parsed = repaired;
|
|
831
|
+
missingFields = listMissingFields(parsed);
|
|
832
|
+
}
|
|
833
|
+
else {
|
|
834
|
+
warnings.push("Gateway repair response was not valid JSON; using fallback analysis.");
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
if (missingFields.length) {
|
|
838
|
+
warnings.push(`Gateway analysis missing fields: ${missingFields.join(", ")}.`);
|
|
839
|
+
}
|
|
840
|
+
let analysis = this.normalizeAnalysis(parsed ?? {}, normalizedJob, tasks, request.inputText);
|
|
841
|
+
if (analysis.docdexNotes.length === 0) {
|
|
842
|
+
if (docs.length === 0) {
|
|
843
|
+
analysis.docdexNotes.push("Docdex: no matching documents found.");
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
warnings.push("Gateway analysis missing docdexNotes for retrieved docdex context.");
|
|
847
|
+
}
|
|
848
|
+
const docdexWarnings = warnings.filter((w) => w.toLowerCase().includes("docdex"));
|
|
849
|
+
analysis.docdexNotes.push(...docdexWarnings);
|
|
850
|
+
}
|
|
851
|
+
analysis = await this.validateFilePlan(analysis, warnings);
|
|
852
|
+
const chosenAgent = await this.selectAgentForJob(normalizedJob, analysis);
|
|
853
|
+
await this.deps.jobService.finishCommandRun(commandRun.id, "succeeded");
|
|
854
|
+
return {
|
|
855
|
+
commandRunId: commandRun.id,
|
|
856
|
+
job: normalizedJob,
|
|
857
|
+
gatewayAgent: { id: gatewayAgent.id, slug: gatewayAgent.slug ?? gatewayAgent.id },
|
|
858
|
+
tasks,
|
|
859
|
+
docdex: docs,
|
|
860
|
+
analysis,
|
|
861
|
+
chosenAgent,
|
|
862
|
+
warnings,
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
catch (error) {
|
|
866
|
+
await this.deps.jobService.finishCommandRun(commandRun.id, "failed", error.message);
|
|
867
|
+
throw error;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|