@xn-intenton-z2a/agentic-lib 7.2.6 → 7.2.8
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/README.md +88 -17
- package/bin/agentic-lib.js +260 -496
- package/package.json +2 -3
- package/src/actions/agentic-step/tasks/direct.js +7 -0
- package/src/actions/agentic-step/tasks/supervise.js +7 -0
- package/src/agents/agent-apply-fix.md +5 -2
- package/src/agents/agent-discovery.md +52 -0
- package/src/agents/agent-issue-resolution.md +18 -0
- package/src/agents/agent-iterate.md +45 -0
- package/src/copilot/agents.js +39 -0
- package/src/copilot/config.js +308 -0
- package/src/copilot/context.js +318 -0
- package/src/copilot/hybrid-session.js +330 -0
- package/src/copilot/logger.js +43 -0
- package/src/copilot/sdk.js +36 -0
- package/src/copilot/session.js +372 -0
- package/src/copilot/tasks/fix-code.js +73 -0
- package/src/copilot/tasks/maintain-features.js +61 -0
- package/src/copilot/tasks/maintain-library.js +66 -0
- package/src/copilot/tasks/transform.js +120 -0
- package/src/copilot/tools.js +141 -0
- package/src/mcp/server.js +43 -25
- package/src/seeds/zero-README.md +31 -0
- package/src/seeds/zero-behaviour.test.js +8 -0
- package/src/seeds/zero-package.json +1 -1
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
// Copyright (C) 2025-2026 Polycode Limited
|
|
3
|
+
// src/copilot/context.js — Context gathering and user prompt assembly
|
|
4
|
+
//
|
|
5
|
+
// Builds user prompts for each agent type from available local and GitHub context.
|
|
6
|
+
// Works with or without GitHub data — local-only context is always sufficient.
|
|
7
|
+
|
|
8
|
+
import { resolve } from "path";
|
|
9
|
+
import { execSync } from "child_process";
|
|
10
|
+
import { scanDirectory, readOptionalFile, extractFeatureSummary, formatPathsSection, summariseIssue, filterIssues } from "./session.js";
|
|
11
|
+
import { defaultLogger } from "./logger.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Context requirements per agent. Defines what context each agent needs.
|
|
15
|
+
* All fields are optional — the builder includes whatever is available.
|
|
16
|
+
*/
|
|
17
|
+
const AGENT_CONTEXT = {
|
|
18
|
+
"agent-iterate": { mission: true, source: true, tests: true, features: true },
|
|
19
|
+
"agent-discovery": { source: true, tests: true },
|
|
20
|
+
"agent-issue-resolution": { mission: true, source: true, tests: true, features: true, issues: true },
|
|
21
|
+
"agent-apply-fix": { source: true, tests: true },
|
|
22
|
+
"agent-maintain-features": { mission: true, features: true, issues: true },
|
|
23
|
+
"agent-maintain-library": { library: true, librarySources: true },
|
|
24
|
+
"agent-ready-issue": { mission: true, features: true, issues: true },
|
|
25
|
+
"agent-review-issue": { source: true, tests: true, issues: true },
|
|
26
|
+
"agent-discussion-bot": { mission: true, features: true },
|
|
27
|
+
"agent-supervisor": { mission: true, features: true, issues: true },
|
|
28
|
+
"agent-director": { mission: true, features: true, issues: true, source: true, tests: true },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Gather local context from the workspace filesystem.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} workspacePath - Path to the workspace
|
|
35
|
+
* @param {Object} config - Parsed agentic config (from config.js)
|
|
36
|
+
* @param {Object} [options]
|
|
37
|
+
* @param {Object} [options.logger]
|
|
38
|
+
* @returns {Object} Context object with all available local data
|
|
39
|
+
*/
|
|
40
|
+
export function gatherLocalContext(workspacePath, config, { logger = defaultLogger } = {}) {
|
|
41
|
+
const wsPath = resolve(workspacePath);
|
|
42
|
+
const paths = config.paths || {};
|
|
43
|
+
const tuning = config.tuning || {};
|
|
44
|
+
|
|
45
|
+
const context = {};
|
|
46
|
+
|
|
47
|
+
// Mission
|
|
48
|
+
const missionPath = paths.mission?.path || "MISSION.md";
|
|
49
|
+
context.mission = readOptionalFile(resolve(wsPath, missionPath));
|
|
50
|
+
|
|
51
|
+
// Source files
|
|
52
|
+
const sourcePath = paths.source?.path || "src/lib/";
|
|
53
|
+
const sourceDir = resolve(wsPath, sourcePath);
|
|
54
|
+
context.sourceFiles = scanDirectory(sourceDir, [".js", ".ts", ".mjs", ".cjs"], {
|
|
55
|
+
fileLimit: tuning.sourceScan || 10,
|
|
56
|
+
contentLimit: tuning.sourceContent || 5000,
|
|
57
|
+
sortByMtime: true,
|
|
58
|
+
clean: true,
|
|
59
|
+
outline: true,
|
|
60
|
+
}, logger);
|
|
61
|
+
|
|
62
|
+
// Test files
|
|
63
|
+
const testsPath = paths.tests?.path || "tests/";
|
|
64
|
+
const testsDir = resolve(wsPath, testsPath);
|
|
65
|
+
context.testFiles = scanDirectory(testsDir, [".js", ".ts", ".test.js", ".test.ts", ".spec.js"], {
|
|
66
|
+
fileLimit: tuning.sourceScan || 10,
|
|
67
|
+
contentLimit: tuning.testContent || 3000,
|
|
68
|
+
sortByMtime: true,
|
|
69
|
+
clean: true,
|
|
70
|
+
}, logger);
|
|
71
|
+
|
|
72
|
+
// Features
|
|
73
|
+
const featuresPath = paths.features?.path || "features/";
|
|
74
|
+
const featuresDir = resolve(wsPath, featuresPath);
|
|
75
|
+
const featureFiles = scanDirectory(featuresDir, [".md"], {
|
|
76
|
+
fileLimit: tuning.featuresScan || 10,
|
|
77
|
+
sortByMtime: true,
|
|
78
|
+
}, logger);
|
|
79
|
+
context.features = featureFiles.map((f) => extractFeatureSummary(f.content, f.name));
|
|
80
|
+
|
|
81
|
+
// Library
|
|
82
|
+
const libraryPath = paths.library?.path || "library/";
|
|
83
|
+
const libraryDir = resolve(wsPath, libraryPath);
|
|
84
|
+
context.libraryFiles = scanDirectory(libraryDir, [".md"], {
|
|
85
|
+
fileLimit: 10,
|
|
86
|
+
contentLimit: tuning.documentSummary || 2000,
|
|
87
|
+
}, logger);
|
|
88
|
+
|
|
89
|
+
// Library sources
|
|
90
|
+
const sourcesPath = paths.librarySources?.path || "SOURCES.md";
|
|
91
|
+
context.librarySources = readOptionalFile(resolve(wsPath, sourcesPath));
|
|
92
|
+
|
|
93
|
+
// Contributing guide
|
|
94
|
+
const contributingPath = paths.contributing?.path || "CONTRIBUTING.md";
|
|
95
|
+
context.contributing = readOptionalFile(resolve(wsPath, contributingPath), 2000);
|
|
96
|
+
|
|
97
|
+
// Package.json
|
|
98
|
+
context.packageJson = config.packageJson || readOptionalFile(resolve(wsPath, "package.json"), 3000);
|
|
99
|
+
|
|
100
|
+
// Config TOML
|
|
101
|
+
context.configToml = config.configToml || "";
|
|
102
|
+
|
|
103
|
+
// Paths
|
|
104
|
+
context.writablePaths = config.writablePaths || [];
|
|
105
|
+
context.readOnlyPaths = config.readOnlyPaths || [];
|
|
106
|
+
|
|
107
|
+
// Initial test output
|
|
108
|
+
try {
|
|
109
|
+
context.testOutput = execSync("npm test 2>&1", { cwd: wsPath, encoding: "utf8", timeout: 120000 });
|
|
110
|
+
} catch (err) {
|
|
111
|
+
context.testOutput = `STDOUT:\n${err.stdout || ""}\nSTDERR:\n${err.stderr || ""}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return context;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Fetch GitHub context using the `gh` CLI.
|
|
119
|
+
* Returns null fields when gh is unavailable or data can't be fetched.
|
|
120
|
+
*
|
|
121
|
+
* @param {Object} options
|
|
122
|
+
* @param {number} [options.issueNumber] - Issue number to fetch
|
|
123
|
+
* @param {number} [options.prNumber] - PR number to fetch
|
|
124
|
+
* @param {string} [options.discussionUrl] - Discussion URL to fetch
|
|
125
|
+
* @param {string} [options.workspacePath] - CWD for gh commands
|
|
126
|
+
* @param {Object} [options.logger]
|
|
127
|
+
* @returns {Object} GitHub context
|
|
128
|
+
*/
|
|
129
|
+
export function gatherGitHubContext({ issueNumber, prNumber, discussionUrl, workspacePath, logger = defaultLogger } = {}) {
|
|
130
|
+
const github = { issues: [], issueDetail: null, prDetail: null, discussionDetail: null };
|
|
131
|
+
const cwd = workspacePath || process.cwd();
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
// Fetch open issues list
|
|
135
|
+
const issuesJson = execSync("gh issue list --state open --limit 20 --json number,title,labels,body,createdAt,updatedAt", {
|
|
136
|
+
cwd,
|
|
137
|
+
encoding: "utf8",
|
|
138
|
+
timeout: 30000,
|
|
139
|
+
});
|
|
140
|
+
const rawIssues = JSON.parse(issuesJson);
|
|
141
|
+
github.issues = filterIssues(rawIssues.map((i) => ({
|
|
142
|
+
number: i.number,
|
|
143
|
+
title: i.title,
|
|
144
|
+
body: i.body,
|
|
145
|
+
labels: i.labels,
|
|
146
|
+
created_at: i.createdAt,
|
|
147
|
+
updated_at: i.updatedAt,
|
|
148
|
+
})));
|
|
149
|
+
} catch (err) {
|
|
150
|
+
logger.info(`[context] Could not fetch issues: ${err.message}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Fetch specific issue detail
|
|
154
|
+
if (issueNumber) {
|
|
155
|
+
try {
|
|
156
|
+
const issueJson = execSync(`gh issue view ${issueNumber} --json number,title,body,labels,comments,createdAt`, {
|
|
157
|
+
cwd,
|
|
158
|
+
encoding: "utf8",
|
|
159
|
+
timeout: 30000,
|
|
160
|
+
});
|
|
161
|
+
github.issueDetail = JSON.parse(issueJson);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
logger.info(`[context] Could not fetch issue #${issueNumber}: ${err.message}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Fetch specific PR detail
|
|
168
|
+
if (prNumber) {
|
|
169
|
+
try {
|
|
170
|
+
const prJson = execSync(`gh pr view ${prNumber} --json number,title,body,files,statusCheckRollup`, {
|
|
171
|
+
cwd,
|
|
172
|
+
encoding: "utf8",
|
|
173
|
+
timeout: 30000,
|
|
174
|
+
});
|
|
175
|
+
github.prDetail = JSON.parse(prJson);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
logger.info(`[context] Could not fetch PR #${prNumber}: ${err.message}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Fetch discussion
|
|
182
|
+
if (discussionUrl) {
|
|
183
|
+
try {
|
|
184
|
+
// Extract discussion number from URL
|
|
185
|
+
const match = discussionUrl.match(/discussions\/(\d+)/);
|
|
186
|
+
if (match) {
|
|
187
|
+
const num = match[1];
|
|
188
|
+
const discussionJson = execSync(
|
|
189
|
+
`gh api graphql -f query='{ repository(owner:"{owner}", name:"{repo}") { discussion(number: ${num}) { title body comments(last: 10) { nodes { body author { login } createdAt } } } } }'`,
|
|
190
|
+
{ cwd, encoding: "utf8", timeout: 30000 },
|
|
191
|
+
);
|
|
192
|
+
github.discussionDetail = JSON.parse(discussionJson);
|
|
193
|
+
}
|
|
194
|
+
} catch (err) {
|
|
195
|
+
logger.info(`[context] Could not fetch discussion: ${err.message}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return github;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Build a user prompt for the given agent from available context.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} agentName - Agent name (e.g. "agent-iterate")
|
|
206
|
+
* @param {Object} localContext - From gatherLocalContext()
|
|
207
|
+
* @param {Object} [githubContext] - From gatherGitHubContext() (optional)
|
|
208
|
+
* @param {Object} [options]
|
|
209
|
+
* @param {Object} [options.tuning] - Tuning config for limits
|
|
210
|
+
* @returns {string} Assembled user prompt
|
|
211
|
+
*/
|
|
212
|
+
export function buildUserPrompt(agentName, localContext, githubContext, { tuning } = {}) {
|
|
213
|
+
const needs = AGENT_CONTEXT[agentName] || AGENT_CONTEXT["agent-iterate"];
|
|
214
|
+
const sections = [];
|
|
215
|
+
|
|
216
|
+
// Mission
|
|
217
|
+
if (needs.mission && localContext.mission) {
|
|
218
|
+
sections.push(`# Mission\n\n${localContext.mission}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Current test state
|
|
222
|
+
if (localContext.testOutput) {
|
|
223
|
+
const testPreview = localContext.testOutput.substring(0, 4000);
|
|
224
|
+
sections.push(`# Current Test State\n\n\`\`\`\n${testPreview}\n\`\`\``);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Source files
|
|
228
|
+
if (needs.source && localContext.sourceFiles?.length > 0) {
|
|
229
|
+
const sourceSection = [`# Source Files (${localContext.sourceFiles.length})`];
|
|
230
|
+
for (const f of localContext.sourceFiles) {
|
|
231
|
+
sourceSection.push(`## ${f.name}\n\`\`\`\n${f.content}\n\`\`\``);
|
|
232
|
+
}
|
|
233
|
+
sections.push(sourceSection.join("\n\n"));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Test files
|
|
237
|
+
if (needs.tests && localContext.testFiles?.length > 0) {
|
|
238
|
+
const testSection = [`# Test Files (${localContext.testFiles.length})`];
|
|
239
|
+
for (const f of localContext.testFiles) {
|
|
240
|
+
testSection.push(`## ${f.name}\n\`\`\`\n${f.content}\n\`\`\``);
|
|
241
|
+
}
|
|
242
|
+
sections.push(testSection.join("\n\n"));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Features
|
|
246
|
+
if (needs.features && localContext.features?.length > 0) {
|
|
247
|
+
const featureSection = [`# Features (${localContext.features.length})`];
|
|
248
|
+
for (const f of localContext.features) {
|
|
249
|
+
featureSection.push(f);
|
|
250
|
+
}
|
|
251
|
+
sections.push(featureSection.join("\n\n"));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Library
|
|
255
|
+
if (needs.library && localContext.libraryFiles?.length > 0) {
|
|
256
|
+
const libSection = [`# Library Files (${localContext.libraryFiles.length})`];
|
|
257
|
+
for (const f of localContext.libraryFiles) {
|
|
258
|
+
libSection.push(`## ${f.name}\n${f.content}`);
|
|
259
|
+
}
|
|
260
|
+
sections.push(libSection.join("\n\n"));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Library sources
|
|
264
|
+
if (needs.librarySources && localContext.librarySources) {
|
|
265
|
+
sections.push(`# Sources\n\n${localContext.librarySources}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Issues (from GitHub context)
|
|
269
|
+
if (needs.issues && githubContext?.issues?.length > 0) {
|
|
270
|
+
const issueSection = [`# Open Issues (${githubContext.issues.length})`];
|
|
271
|
+
for (const issue of githubContext.issues) {
|
|
272
|
+
issueSection.push(summariseIssue(issue, tuning?.issueBodyLimit || 500));
|
|
273
|
+
}
|
|
274
|
+
sections.push(issueSection.join("\n\n"));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Specific issue detail
|
|
278
|
+
if (githubContext?.issueDetail) {
|
|
279
|
+
const issue = githubContext.issueDetail;
|
|
280
|
+
const issueSection = [`# Issue #${issue.number}: ${issue.title}\n\n${issue.body || "(no body)"}`];
|
|
281
|
+
if (issue.comments?.length > 0) {
|
|
282
|
+
issueSection.push("## Comments");
|
|
283
|
+
for (const c of issue.comments.slice(-10)) {
|
|
284
|
+
issueSection.push(`**${c.author?.login || "unknown"}**: ${c.body}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
sections.push(issueSection.join("\n\n"));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Specific PR detail
|
|
291
|
+
if (githubContext?.prDetail) {
|
|
292
|
+
const pr = githubContext.prDetail;
|
|
293
|
+
const prSection = [`# PR #${pr.number}: ${pr.title}\n\n${pr.body || "(no body)"}`];
|
|
294
|
+
if (pr.files?.length > 0) {
|
|
295
|
+
prSection.push(`## Changed Files\n${pr.files.map((f) => `- ${f.path}`).join("\n")}`);
|
|
296
|
+
}
|
|
297
|
+
sections.push(prSection.join("\n\n"));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// File paths section
|
|
301
|
+
if (localContext.writablePaths?.length > 0 || localContext.readOnlyPaths?.length > 0) {
|
|
302
|
+
sections.push(formatPathsSection(
|
|
303
|
+
localContext.writablePaths || [],
|
|
304
|
+
localContext.readOnlyPaths || [],
|
|
305
|
+
{ configToml: localContext.configToml, packageJson: localContext.packageJson },
|
|
306
|
+
));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Instructions
|
|
310
|
+
sections.push([
|
|
311
|
+
"Implement this mission. Read the existing source code and tests,",
|
|
312
|
+
"make the required changes, run run_tests to verify, and iterate until all tests pass.",
|
|
313
|
+
"",
|
|
314
|
+
"Start by reading the existing files, then implement the solution.",
|
|
315
|
+
].join("\n"));
|
|
316
|
+
|
|
317
|
+
return sections.join("\n\n");
|
|
318
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
// Copyright (C) 2025-2026 Polycode Limited
|
|
3
|
+
// src/copilot/hybrid-session.js — Single-session hybrid iterator (Phase 2)
|
|
4
|
+
//
|
|
5
|
+
// Replaces the old multi-session runIterationLoop with a single persistent
|
|
6
|
+
// Copilot SDK session that drives its own tool loop. Hooks provide
|
|
7
|
+
// observability and budget control.
|
|
8
|
+
//
|
|
9
|
+
// Phase 1b: Full tool set, writable-path safety, narrative extraction,
|
|
10
|
+
// rate-limit retry, config-driven context.
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync } from "fs";
|
|
13
|
+
import { resolve } from "path";
|
|
14
|
+
import { execSync } from "child_process";
|
|
15
|
+
import { defaultLogger } from "./logger.js";
|
|
16
|
+
import { getSDK } from "./sdk.js";
|
|
17
|
+
import { createAgentTools, isPathWritable } from "./tools.js";
|
|
18
|
+
import { readOptionalFile, extractNarrative, NARRATIVE_INSTRUCTION, isRateLimitError, retryDelayMs } from "./session.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Format tool arguments for human-readable logging.
|
|
22
|
+
*/
|
|
23
|
+
function formatToolArgs(toolName, args) {
|
|
24
|
+
if (!args) return "";
|
|
25
|
+
switch (toolName) {
|
|
26
|
+
case "view":
|
|
27
|
+
return args.filePath ? ` → ${args.filePath}` : (args.path ? ` → ${args.path}` : "");
|
|
28
|
+
case "bash":
|
|
29
|
+
return args.command ? ` → ${args.command.substring(0, 120)}` : "";
|
|
30
|
+
case "write_file":
|
|
31
|
+
case "create_file":
|
|
32
|
+
case "edit_file":
|
|
33
|
+
return args.file_path ? ` → ${args.file_path}` : (args.path ? ` → ${args.path}` : "");
|
|
34
|
+
case "read_file":
|
|
35
|
+
return args.file_path ? ` → ${args.file_path}` : (args.path ? ` → ${args.path}` : "");
|
|
36
|
+
case "run_tests":
|
|
37
|
+
return "";
|
|
38
|
+
case "run_command":
|
|
39
|
+
return args.command ? ` → ${args.command.substring(0, 120)}` : "";
|
|
40
|
+
case "list_files":
|
|
41
|
+
return args.path ? ` → ${args.path}` : "";
|
|
42
|
+
case "report_intent":
|
|
43
|
+
return args.intent ? ` → "${args.intent.substring(0, 80)}"` : "";
|
|
44
|
+
default: {
|
|
45
|
+
// Generic: show first string-valued arg
|
|
46
|
+
const firstVal = Object.values(args).find((v) => typeof v === "string");
|
|
47
|
+
return firstVal ? ` → ${firstVal.substring(0, 100)}` : "";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run a hybrid iteration: single Copilot SDK session drives mission to completion.
|
|
54
|
+
*
|
|
55
|
+
* @param {Object} options
|
|
56
|
+
* @param {string} options.workspacePath - Path to the workspace
|
|
57
|
+
* @param {string} [options.model="gpt-5-mini"] - Copilot SDK model
|
|
58
|
+
* @param {string} [options.githubToken] - COPILOT_GITHUB_TOKEN
|
|
59
|
+
* @param {Object} [options.tuning] - Tuning config (reasoningEffort, infiniteSessions)
|
|
60
|
+
* @param {number} [options.timeoutMs=600000] - Session timeout
|
|
61
|
+
* @param {string} [options.agentPrompt] - Agent system prompt (loaded from agent .md file)
|
|
62
|
+
* @param {string} [options.userPrompt] - Override user prompt (instead of default mission prompt)
|
|
63
|
+
* @param {string[]} [options.writablePaths] - Writable paths for tool safety (default: workspace)
|
|
64
|
+
* @param {number} [options.maxRetries=2] - Max retries on rate-limit errors
|
|
65
|
+
* @param {Object} [options.logger]
|
|
66
|
+
* @returns {Promise<HybridResult>}
|
|
67
|
+
*/
|
|
68
|
+
export async function runHybridSession({
|
|
69
|
+
workspacePath,
|
|
70
|
+
model = "gpt-5-mini",
|
|
71
|
+
githubToken,
|
|
72
|
+
tuning = {},
|
|
73
|
+
timeoutMs = 600000,
|
|
74
|
+
agentPrompt,
|
|
75
|
+
userPrompt,
|
|
76
|
+
writablePaths,
|
|
77
|
+
maxRetries = 2,
|
|
78
|
+
logger = defaultLogger,
|
|
79
|
+
}) {
|
|
80
|
+
const { CopilotClient, approveAll, defineTool } = await getSDK();
|
|
81
|
+
|
|
82
|
+
const copilotToken = githubToken || process.env.COPILOT_GITHUB_TOKEN;
|
|
83
|
+
if (!copilotToken) {
|
|
84
|
+
throw new Error("COPILOT_GITHUB_TOKEN is required. Set it in your environment.");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const wsPath = resolve(workspacePath);
|
|
88
|
+
|
|
89
|
+
// ── Writable paths ──────────────────────────────────────────────────
|
|
90
|
+
// Default: entire workspace is writable (local CLI mode)
|
|
91
|
+
const effectiveWritablePaths = writablePaths || [wsPath + "/"];
|
|
92
|
+
|
|
93
|
+
// ── Read mission context (only if no userPrompt override) ─────────
|
|
94
|
+
let missionText;
|
|
95
|
+
let initialTestOutput;
|
|
96
|
+
if (!userPrompt) {
|
|
97
|
+
const missionPath = resolve(wsPath, "MISSION.md");
|
|
98
|
+
missionText = existsSync(missionPath) ? readFileSync(missionPath, "utf8") : "No MISSION.md found";
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
initialTestOutput = execSync("npm test 2>&1", { cwd: wsPath, encoding: "utf8", timeout: 120000 });
|
|
102
|
+
} catch (err) {
|
|
103
|
+
initialTestOutput = `STDOUT:\n${err.stdout || ""}\nSTDERR:\n${err.stderr || ""}`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Metrics ─────────────────────────────────────────────────────────
|
|
108
|
+
const metrics = {
|
|
109
|
+
toolCalls: [],
|
|
110
|
+
testRuns: 0,
|
|
111
|
+
filesWritten: new Set(),
|
|
112
|
+
errors: [],
|
|
113
|
+
startTime: Date.now(),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// ── Define run_tests tool ───────────────────────────────────────────
|
|
117
|
+
const runTestsTool = defineTool("run_tests", {
|
|
118
|
+
description: "Run the test suite (npm test) and return pass/fail with output. Call this after making changes to verify correctness.",
|
|
119
|
+
parameters: { type: "object", properties: {}, required: [] },
|
|
120
|
+
handler: async () => {
|
|
121
|
+
metrics.testRuns++;
|
|
122
|
+
try {
|
|
123
|
+
const stdout = execSync("npm test 2>&1", { cwd: wsPath, encoding: "utf8", timeout: 120000 });
|
|
124
|
+
return { textResultForLlm: `TESTS PASSED:\n${stdout}`, resultType: "success" };
|
|
125
|
+
} catch (err) {
|
|
126
|
+
const output = `STDOUT:\n${err.stdout || ""}\nSTDERR:\n${err.stderr || ""}`;
|
|
127
|
+
return { textResultForLlm: `TESTS FAILED:\n${output}`, resultType: "success" };
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ── Build full tool set ─────────────────────────────────────────────
|
|
133
|
+
// 4 standard tools (read_file, write_file, list_files, run_command) + run_tests
|
|
134
|
+
const agentTools = createAgentTools(effectiveWritablePaths, logger, defineTool);
|
|
135
|
+
const allTools = [...agentTools, runTestsTool];
|
|
136
|
+
|
|
137
|
+
// ── Build system prompt with narrative instruction ─────────────────
|
|
138
|
+
const basePrompt = agentPrompt || [
|
|
139
|
+
"You are an autonomous code transformation agent.",
|
|
140
|
+
"Your workspace is the current working directory.",
|
|
141
|
+
"Implement the MISSION described in the user prompt.",
|
|
142
|
+
"Read existing code, write implementations and tests, then run run_tests to verify.",
|
|
143
|
+
"Keep going until all tests pass or you've exhausted your options.",
|
|
144
|
+
].join("\n");
|
|
145
|
+
const systemPrompt = basePrompt + NARRATIVE_INSTRUCTION;
|
|
146
|
+
|
|
147
|
+
// ── Session config ─────────────────────────────────────────────────
|
|
148
|
+
logger.info(`[hybrid] Creating session (model=${model}, workspace=${wsPath})`);
|
|
149
|
+
|
|
150
|
+
const client = new CopilotClient({
|
|
151
|
+
env: { ...process.env, GITHUB_TOKEN: copilotToken, GH_TOKEN: copilotToken },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const sessionConfig = {
|
|
155
|
+
model,
|
|
156
|
+
systemMessage: { mode: "replace", content: systemPrompt },
|
|
157
|
+
tools: allTools,
|
|
158
|
+
onPermissionRequest: approveAll,
|
|
159
|
+
workingDirectory: wsPath,
|
|
160
|
+
hooks: {
|
|
161
|
+
onPreToolUse: (input) => {
|
|
162
|
+
const n = metrics.toolCalls.length + 1;
|
|
163
|
+
const elapsed = ((Date.now() - metrics.startTime) / 1000).toFixed(0);
|
|
164
|
+
metrics.toolCalls.push({ tool: input.toolName, time: Date.now(), args: input.toolArgs });
|
|
165
|
+
const detail = formatToolArgs(input.toolName, input.toolArgs);
|
|
166
|
+
logger.info(` [tool #${n} +${elapsed}s] ${input.toolName}${detail}`);
|
|
167
|
+
},
|
|
168
|
+
onPostToolUse: (input) => {
|
|
169
|
+
if (/write|edit|create/i.test(input.toolName)) {
|
|
170
|
+
const path = input.toolArgs?.file_path || input.toolArgs?.path || "unknown";
|
|
171
|
+
metrics.filesWritten.add(path);
|
|
172
|
+
logger.info(` → wrote ${path}`);
|
|
173
|
+
}
|
|
174
|
+
if (input.toolName === "run_tests" || input.toolName === "run_command" || input.toolName === "bash") {
|
|
175
|
+
const result = input.toolResult?.textResultForLlm || input.toolResult || "";
|
|
176
|
+
const resultStr = typeof result === "string" ? result : JSON.stringify(result);
|
|
177
|
+
const passed = /TESTS PASSED|passed|✓|0 fail/i.test(resultStr);
|
|
178
|
+
const failed = /TESTS FAILED|failed|✗|FAIL/i.test(resultStr);
|
|
179
|
+
if (passed && !failed) {
|
|
180
|
+
logger.info(` → tests PASSED`);
|
|
181
|
+
} else if (failed) {
|
|
182
|
+
const failMatch = resultStr.match(/(\d+)\s*(failed|fail)/i);
|
|
183
|
+
logger.info(` → tests FAILED${failMatch ? ` (${failMatch[1]} failures)` : ""}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
onErrorOccurred: (input) => {
|
|
188
|
+
metrics.errors.push({ error: input.error, context: input.errorContext, time: Date.now() });
|
|
189
|
+
logger.error(` [error] ${input.errorContext}: ${input.error}`);
|
|
190
|
+
if (input.recoverable) return { errorHandling: "retry", retryCount: 2 };
|
|
191
|
+
return { errorHandling: "abort" };
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// Infinite sessions for context management
|
|
197
|
+
if (tuning.infiniteSessions !== false) {
|
|
198
|
+
sessionConfig.infiniteSessions = { enabled: true };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Reasoning effort
|
|
202
|
+
if (tuning.reasoningEffort && tuning.reasoningEffort !== "none") {
|
|
203
|
+
const SUPPORTED = new Set(["gpt-5-mini", "o4-mini"]);
|
|
204
|
+
if (SUPPORTED.has(model)) {
|
|
205
|
+
sessionConfig.reasoningEffort = tuning.reasoningEffort;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Create session with rate-limit retry ───────────────────────────
|
|
210
|
+
let session;
|
|
211
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
212
|
+
try {
|
|
213
|
+
session = await client.createSession(sessionConfig);
|
|
214
|
+
logger.info(`[hybrid] Session: ${session.sessionId}`);
|
|
215
|
+
break;
|
|
216
|
+
} catch (err) {
|
|
217
|
+
if (isRateLimitError(err) && attempt < maxRetries) {
|
|
218
|
+
const delayMs = retryDelayMs(err, attempt);
|
|
219
|
+
logger.warning(`[hybrid] Rate limit on session creation — waiting ${Math.round(delayMs / 1000)}s (retry ${attempt + 1}/${maxRetries})`);
|
|
220
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
221
|
+
} else {
|
|
222
|
+
logger.error(`[hybrid] Failed to create session: ${err.message}`);
|
|
223
|
+
await client.stop();
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Token tracking ──────────────────────────────────────────────────
|
|
230
|
+
let tokensIn = 0;
|
|
231
|
+
let tokensOut = 0;
|
|
232
|
+
|
|
233
|
+
session.on("assistant.usage", (event) => {
|
|
234
|
+
const inTok = event.data?.inputTokens || 0;
|
|
235
|
+
const outTok = event.data?.outputTokens || 0;
|
|
236
|
+
tokensIn += inTok;
|
|
237
|
+
tokensOut += outTok;
|
|
238
|
+
if (inTok || outTok) {
|
|
239
|
+
logger.info(` [tokens] +${inTok} in / +${outTok} out (cumulative: ${tokensIn} in / ${tokensOut} out)`);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
session.on("assistant.message", (event) => {
|
|
243
|
+
const content = (event.data?.content || "").trim();
|
|
244
|
+
if (content) {
|
|
245
|
+
const firstLine = content.split("\n")[0];
|
|
246
|
+
const preview = firstLine.length > 200 ? firstLine.substring(0, 200) + "..." : firstLine;
|
|
247
|
+
logger.info(` [assistant] ${preview}`);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ── Try autopilot mode ──────────────────────────────────────────────
|
|
252
|
+
try {
|
|
253
|
+
await session.rpc.mode.set({ mode: "autopilot" });
|
|
254
|
+
logger.info("[hybrid] Autopilot mode: active");
|
|
255
|
+
} catch {
|
|
256
|
+
logger.info("[hybrid] Autopilot mode not available — using default mode");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Send mission prompt ─────────────────────────────────────────────
|
|
260
|
+
logger.info("[hybrid] Sending mission...\n");
|
|
261
|
+
|
|
262
|
+
const prompt = userPrompt || [
|
|
263
|
+
`# Mission\n\n${missionText}`,
|
|
264
|
+
`# Current test state\n\n\`\`\`\n${initialTestOutput.substring(0, 4000)}\n\`\`\``,
|
|
265
|
+
"",
|
|
266
|
+
"Implement this mission. Read the existing source code and tests,",
|
|
267
|
+
"make the required changes, run run_tests to verify, and iterate until all tests pass.",
|
|
268
|
+
"",
|
|
269
|
+
"Start by reading the existing files, then implement the solution.",
|
|
270
|
+
].join("\n\n");
|
|
271
|
+
|
|
272
|
+
const t0 = Date.now();
|
|
273
|
+
let response;
|
|
274
|
+
let endReason = "complete";
|
|
275
|
+
|
|
276
|
+
// Send with rate-limit retry
|
|
277
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
278
|
+
try {
|
|
279
|
+
response = await session.sendAndWait({ prompt }, timeoutMs);
|
|
280
|
+
break;
|
|
281
|
+
} catch (err) {
|
|
282
|
+
if (isRateLimitError(err) && attempt < maxRetries) {
|
|
283
|
+
const delayMs = retryDelayMs(err, attempt);
|
|
284
|
+
logger.warning(`[hybrid] Rate limit on sendAndWait — waiting ${Math.round(delayMs / 1000)}s (retry ${attempt + 1}/${maxRetries})`);
|
|
285
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
286
|
+
} else {
|
|
287
|
+
logger.error(`[hybrid] Session error: ${err.message}`);
|
|
288
|
+
response = null;
|
|
289
|
+
endReason = "error";
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const sessionTime = (Date.now() - t0) / 1000;
|
|
295
|
+
|
|
296
|
+
// ── Extract narrative from response ────────────────────────────────
|
|
297
|
+
const agentContent = response?.data?.content || "";
|
|
298
|
+
const narrative = extractNarrative(agentContent, null);
|
|
299
|
+
|
|
300
|
+
// ── Final test run ──────────────────────────────────────────────────
|
|
301
|
+
let finalTestsPassed = false;
|
|
302
|
+
try {
|
|
303
|
+
execSync("npm test 2>&1", { cwd: wsPath, encoding: "utf8", timeout: 120000 });
|
|
304
|
+
finalTestsPassed = true;
|
|
305
|
+
} catch {
|
|
306
|
+
// Tests still failing
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Cleanup ─────────────────────────────────────────────────────────
|
|
310
|
+
await client.stop();
|
|
311
|
+
|
|
312
|
+
const totalTime = (Date.now() - metrics.startTime) / 1000;
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
success: finalTestsPassed,
|
|
316
|
+
testsPassed: finalTestsPassed,
|
|
317
|
+
sessionTime: Math.round(sessionTime),
|
|
318
|
+
totalTime: Math.round(totalTime),
|
|
319
|
+
toolCalls: metrics.toolCalls.length,
|
|
320
|
+
testRuns: metrics.testRuns,
|
|
321
|
+
filesWritten: metrics.filesWritten.size,
|
|
322
|
+
tokensIn,
|
|
323
|
+
tokensOut,
|
|
324
|
+
errors: metrics.errors,
|
|
325
|
+
endReason,
|
|
326
|
+
model,
|
|
327
|
+
narrative,
|
|
328
|
+
agentMessage: agentContent.substring(0, 500) || null,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
// Copyright (C) 2025-2026 Polycode Limited
|
|
3
|
+
// src/copilot/logger.js — Logger abstraction for shared Copilot module
|
|
4
|
+
//
|
|
5
|
+
// In Actions: wraps @actions/core. In CLI: wraps console.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create a logger instance.
|
|
9
|
+
* @param {"actions"|"console"} [backend="console"]
|
|
10
|
+
* @returns {{ info: Function, warning: Function, error: Function, debug: Function }}
|
|
11
|
+
*/
|
|
12
|
+
export function createLogger(backend = "console") {
|
|
13
|
+
if (backend === "console") {
|
|
14
|
+
return {
|
|
15
|
+
info: (...args) => console.log(...args),
|
|
16
|
+
warning: (...args) => console.warn(...args),
|
|
17
|
+
error: (...args) => console.error(...args),
|
|
18
|
+
debug: () => {},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
// "actions" backend — lazy-load @actions/core
|
|
22
|
+
let _core;
|
|
23
|
+
const getCore = () => {
|
|
24
|
+
if (!_core) {
|
|
25
|
+
try {
|
|
26
|
+
// Dynamic require — only available in Actions runtime
|
|
27
|
+
_core = require("@actions/core");
|
|
28
|
+
} catch {
|
|
29
|
+
_core = { info: console.log, warning: console.warn, error: console.error, debug: () => {} };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return _core;
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
info: (...args) => getCore().info(args.join(" ")),
|
|
36
|
+
warning: (...args) => getCore().warning(args.join(" ")),
|
|
37
|
+
error: (...args) => getCore().error(args.join(" ")),
|
|
38
|
+
debug: (...args) => getCore().debug(args.join(" ")),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Default console logger */
|
|
43
|
+
export const defaultLogger = createLogger("console");
|