@xn-intenton-z2a/agentic-lib 7.4.5 → 7.4.7

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.
@@ -1,142 +1,27 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-only
2
2
  // Copyright (C) 2025-2026 Polycode Limited
3
- // tools.js — Shared tool definitions for agentic-step task handlers
3
+ // tools.js — Thin re-export from shared src/copilot/tools.js
4
4
  //
5
- // Defines file I/O and shell tools using the Copilot SDK's defineTool().
6
- // The agent has no built-in filesystem access — these tools give it the
7
- // ability to read, write, list files, and run commands.
5
+ // Phase 4: Tool definitions now live in src/copilot/tools.js.
6
+ // This file re-exports for backwards compatibility.
7
+ //
8
+ // Note: The shared tools.js uses a (writablePaths, logger, defineToolFn) signature.
9
+ // The old Actions tools.js used (writablePaths) with @actions/core and @github/copilot-sdk.
10
+ // This wrapper adapts the signature for existing callers in copilot.js.
8
11
 
9
- import { defineTool } from "@github/copilot-sdk";
10
- import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from "fs";
11
- import { execSync } from "child_process";
12
- import { dirname, resolve } from "path";
13
- import { isPathWritable } from "./safety.js";
14
12
  import * as core from "@actions/core";
13
+ import { defineTool } from "@github/copilot-sdk";
14
+ import { createAgentTools as _createAgentTools, isPathWritable } from "../../copilot/tools.js";
15
15
 
16
- /**
17
- * Create the standard set of agent tools.
18
- *
19
- * @param {string[]} writablePaths - Paths the agent is allowed to write to
20
- * @returns {import('@github/copilot-sdk').Tool[]} Array of tools for createSession()
21
- */
22
- export function createAgentTools(writablePaths) {
23
- const readFile = defineTool("read_file", {
24
- description: "Read the contents of a file at the given path.",
25
- parameters: {
26
- type: "object",
27
- properties: {
28
- path: { type: "string", description: "Absolute or relative file path to read" },
29
- },
30
- required: ["path"],
31
- },
32
- handler: ({ path }) => {
33
- const resolved = resolve(path);
34
- core.info(`[tool] read_file: ${resolved}`);
35
- if (!existsSync(resolved)) {
36
- return { error: `File not found: ${resolved}` };
37
- }
38
- try {
39
- const content = readFileSync(resolved, "utf8");
40
- return { content };
41
- } catch (err) {
42
- return { error: `Failed to read ${resolved}: ${err.message}` };
43
- }
44
- },
45
- });
46
-
47
- const writeFile = defineTool("write_file", {
48
- description:
49
- "Write content to a file. The file will be created if it does not exist. Parent directories will be created automatically. Only writable paths are allowed.",
50
- parameters: {
51
- type: "object",
52
- properties: {
53
- path: { type: "string", description: "Absolute or relative file path to write" },
54
- content: { type: "string", description: "The full content to write to the file" },
55
- },
56
- required: ["path", "content"],
57
- },
58
- handler: ({ path, content }) => {
59
- const resolved = resolve(path);
60
- core.info(`[tool] write_file: ${resolved}`);
61
- if (!isPathWritable(resolved, writablePaths)) {
62
- return { error: `Path is not writable: ${path}. Writable paths: ${writablePaths.join(", ")}` };
63
- }
64
- try {
65
- const dir = dirname(resolved);
66
- if (!existsSync(dir)) {
67
- mkdirSync(dir, { recursive: true });
68
- }
69
- writeFileSync(resolved, content, "utf8");
70
- return { success: true, path: resolved };
71
- } catch (err) {
72
- return { error: `Failed to write ${resolved}: ${err.message}` };
73
- }
74
- },
75
- });
76
-
77
- const listFiles = defineTool("list_files", {
78
- description: "List files and directories at the given path. Returns names with a trailing / for directories.",
79
- parameters: {
80
- type: "object",
81
- properties: {
82
- path: { type: "string", description: "Directory path to list" },
83
- recursive: { type: "boolean", description: "Whether to list recursively (default false)" },
84
- },
85
- required: ["path"],
86
- },
87
- handler: ({ path, recursive }) => {
88
- const resolved = resolve(path);
89
- core.info(`[tool] list_files: ${resolved} (recursive=${!!recursive})`);
90
- if (!existsSync(resolved)) {
91
- return { error: `Directory not found: ${resolved}` };
92
- }
93
- try {
94
- const entries = readdirSync(resolved, { withFileTypes: true, recursive: !!recursive });
95
- const names = entries.map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
96
- return { files: names };
97
- } catch (err) {
98
- return { error: `Failed to list ${resolved}: ${err.message}` };
99
- }
100
- },
101
- });
102
-
103
- const runCommand = defineTool("run_command", {
104
- description:
105
- "Run a shell command and return its stdout and stderr. Use this to run tests, build, lint, or inspect the environment.",
106
- parameters: {
107
- type: "object",
108
- properties: {
109
- command: { type: "string", description: "The shell command to execute" },
110
- cwd: { type: "string", description: "Working directory for the command (default: current directory)" },
111
- },
112
- required: ["command"],
113
- },
114
- handler: ({ command, cwd }) => {
115
- const workDir = cwd ? resolve(cwd) : process.cwd();
116
- core.info(`[tool] run_command: ${command} (cwd=${workDir})`);
117
- const blocked = /\bgit\s+(commit|push|add|reset|checkout|rebase|merge|stash)\b/;
118
- if (blocked.test(command)) {
119
- core.info(`[tool] BLOCKED git write command: ${command}`);
120
- return { error: "Git write commands are not allowed. Use read_file/write_file tools instead." };
121
- }
122
- try {
123
- const stdout = execSync(command, {
124
- cwd: workDir,
125
- encoding: "utf8",
126
- timeout: 120000,
127
- maxBuffer: 1024 * 1024,
128
- });
129
- return { stdout, exitCode: 0 };
130
- } catch (err) {
131
- return {
132
- stdout: err.stdout || "",
133
- stderr: err.stderr || "",
134
- exitCode: err.status || 1,
135
- error: err.message,
136
- };
137
- }
138
- },
139
- });
16
+ const actionsLogger = {
17
+ info: (...args) => core.info(args.join(" ")),
18
+ warning: (...args) => core.warning(args.join(" ")),
19
+ error: (...args) => core.error(args.join(" ")),
20
+ debug: (...args) => core.debug(args.join(" ")),
21
+ };
140
22
 
141
- return [readFile, writeFile, listFiles, runCommand];
23
+ export function createAgentTools(writablePaths) {
24
+ return _createAgentTools(writablePaths, actionsLogger, defineTool);
142
25
  }
26
+
27
+ export { isPathWritable };
@@ -8,8 +8,33 @@
8
8
  import { resolve } from "path";
9
9
  import { execSync } from "child_process";
10
10
  import { scanDirectory, readOptionalFile, extractFeatureSummary, formatPathsSection, summariseIssue, filterIssues } from "./session.js";
11
+ import { readCumulativeCost } from "./telemetry.js";
11
12
  import { defaultLogger } from "./logger.js";
12
13
 
14
+ /**
15
+ * Per-agent refinements that control context precision and prompt quality.
16
+ * These restore the tight scoping that existed in the old per-task handlers.
17
+ */
18
+ const AGENT_REFINEMENTS = {
19
+ "agent-issue-resolution": {
20
+ sortFeatures: "incomplete-first",
21
+ includeWebFiles: true,
22
+ highlightTargetIssue: true,
23
+ trackPromptBudget: true,
24
+ },
25
+ "agent-apply-fix": {
26
+ emphasizeTestOutput: true,
27
+ },
28
+ "agent-maintain-features": {
29
+ sortFeatures: "incomplete-first",
30
+ injectLimit: "features",
31
+ },
32
+ "agent-maintain-library": {
33
+ checkSourcesForUrls: true,
34
+ injectLimit: "library",
35
+ },
36
+ };
37
+
13
38
  /**
14
39
  * Context requirements per agent. Defines what context each agent needs.
15
40
  * All fields are optional — the builder includes whatever is available.
@@ -90,6 +115,16 @@ export function gatherLocalContext(workspacePath, config, { logger = defaultLogg
90
115
  const sourcesPath = paths.librarySources?.path || "SOURCES.md";
91
116
  context.librarySources = readOptionalFile(resolve(wsPath, sourcesPath));
92
117
 
118
+ // Web files
119
+ const webPath = paths.web?.path || "src/web/";
120
+ const webDir = resolve(wsPath, webPath);
121
+ context.webFiles = scanDirectory(webDir, [".html", ".css", ".js"], {
122
+ fileLimit: tuning.sourceScan || 10,
123
+ contentLimit: tuning.sourceContent || 5000,
124
+ sortByMtime: true,
125
+ clean: true,
126
+ }, logger);
127
+
93
128
  // Contributing guide
94
129
  const contributingPath = paths.contributing?.path || "CONTRIBUTING.md";
95
130
  context.contributing = readOptionalFile(resolve(wsPath, contributingPath), 2000);
@@ -202,26 +237,60 @@ export function gatherGitHubContext({ issueNumber, prNumber, discussionUrl, work
202
237
  /**
203
238
  * Build a user prompt for the given agent from available context.
204
239
  *
240
+ * Returns an object with the assembled prompt and optional promptBudget metadata.
241
+ * The prompt includes config-driven limits and per-agent refinements that restore
242
+ * the context precision from the old per-task handlers.
243
+ *
205
244
  * @param {string} agentName - Agent name (e.g. "agent-iterate")
206
245
  * @param {Object} localContext - From gatherLocalContext()
207
246
  * @param {Object} [githubContext] - From gatherGitHubContext() (optional)
208
247
  * @param {Object} [options]
209
248
  * @param {Object} [options.tuning] - Tuning config for limits
210
- * @returns {string} Assembled user prompt
249
+ * @param {Object} [options.config] - Full parsed config (for limits injection)
250
+ * @returns {{ prompt: string, promptBudget: Array|null }}
211
251
  */
212
- export function buildUserPrompt(agentName, localContext, githubContext, { tuning } = {}) {
252
+ export function buildUserPrompt(agentName, localContext, githubContext, { tuning, config } = {}) {
213
253
  const needs = AGENT_CONTEXT[agentName] || AGENT_CONTEXT["agent-iterate"];
254
+ const refinements = AGENT_REFINEMENTS[agentName] || {};
214
255
  const sections = [];
256
+ const promptBudget = refinements.trackPromptBudget ? [] : null;
257
+
258
+ // Target issue — placed prominently when highlightTargetIssue is set
259
+ if (refinements.highlightTargetIssue && githubContext?.issueDetail) {
260
+ const issue = githubContext.issueDetail;
261
+ const issueSection = [
262
+ `# Target Issue #${issue.number}: ${issue.title}`,
263
+ issue.body || "(no description)",
264
+ `Labels: ${(issue.labels || []).map((l) => typeof l === "string" ? l : l.name).join(", ") || "none"}`,
265
+ "",
266
+ "**Focus your transformation on resolving this specific issue.**",
267
+ ];
268
+ if (issue.comments?.length > 0) {
269
+ issueSection.push("\n## Comments");
270
+ for (const c of issue.comments.slice(-5)) {
271
+ issueSection.push(`**${c.author?.login || "unknown"}**: ${c.body}`);
272
+ }
273
+ }
274
+ const text = issueSection.join("\n");
275
+ sections.push(text);
276
+ if (promptBudget) promptBudget.push({ section: "target-issue", size: text.length, files: "1", notes: "" });
277
+ }
215
278
 
216
279
  // Mission
217
280
  if (needs.mission && localContext.mission) {
218
- sections.push(`# Mission\n\n${localContext.mission}`);
281
+ const text = `# Mission\n\n${localContext.mission}`;
282
+ sections.push(text);
283
+ if (promptBudget) promptBudget.push({ section: "mission", size: localContext.mission.length, files: "1", notes: "full" });
219
284
  }
220
285
 
221
- // Current test state
286
+ // Current test state — emphasised for fix-code agent
222
287
  if (localContext.testOutput) {
223
288
  const testPreview = localContext.testOutput.substring(0, 4000);
224
- sections.push(`# Current Test State\n\n\`\`\`\n${testPreview}\n\`\`\``);
289
+ if (refinements.emphasizeTestOutput) {
290
+ sections.push(`# Failing Test Output\n\nThe tests are currently failing. Fix the root cause.\n\n\`\`\`\n${testPreview}\n\`\`\``);
291
+ } else {
292
+ sections.push(`# Current Test State\n\n\`\`\`\n${testPreview}\n\`\`\``);
293
+ }
225
294
  }
226
295
 
227
296
  // Source files
@@ -230,7 +299,20 @@ export function buildUserPrompt(agentName, localContext, githubContext, { tuning
230
299
  for (const f of localContext.sourceFiles) {
231
300
  sourceSection.push(`## ${f.name}\n\`\`\`\n${f.content}\n\`\`\``);
232
301
  }
233
- sections.push(sourceSection.join("\n\n"));
302
+ const text = sourceSection.join("\n\n");
303
+ sections.push(text);
304
+ if (promptBudget) promptBudget.push({ section: "source", size: text.length, files: `${localContext.sourceFiles.length}`, notes: "" });
305
+ }
306
+
307
+ // Web files — only when refinements request it
308
+ if (refinements.includeWebFiles && localContext.webFiles?.length > 0) {
309
+ const webSection = [`# Website Files (${localContext.webFiles.length})`];
310
+ for (const f of localContext.webFiles) {
311
+ webSection.push(`## ${f.name}\n\`\`\`\n${f.content}\n\`\`\``);
312
+ }
313
+ const text = webSection.join("\n\n");
314
+ sections.push(text);
315
+ if (promptBudget) promptBudget.push({ section: "web", size: text.length, files: `${localContext.webFiles.length}`, notes: "" });
234
316
  }
235
317
 
236
318
  // Test files
@@ -239,43 +321,75 @@ export function buildUserPrompt(agentName, localContext, githubContext, { tuning
239
321
  for (const f of localContext.testFiles) {
240
322
  testSection.push(`## ${f.name}\n\`\`\`\n${f.content}\n\`\`\``);
241
323
  }
242
- sections.push(testSection.join("\n\n"));
324
+ const text = testSection.join("\n\n");
325
+ sections.push(text);
326
+ if (promptBudget) promptBudget.push({ section: "tests", size: text.length, files: `${localContext.testFiles.length}`, notes: "" });
243
327
  }
244
328
 
245
- // Features
329
+ // Features — sorted incomplete-first when refinements request it
246
330
  if (needs.features && localContext.features?.length > 0) {
247
- const featureSection = [`# Features (${localContext.features.length})`];
248
- for (const f of localContext.features) {
331
+ let features = [...localContext.features];
332
+ if (refinements.sortFeatures === "incomplete-first") {
333
+ features.sort((a, b) => {
334
+ const aIncomplete = /Remaining:/.test(a) || /\[ \]/.test(a) ? 0 : 1;
335
+ const bIncomplete = /Remaining:/.test(b) || /\[ \]/.test(b) ? 0 : 1;
336
+ return aIncomplete - bIncomplete;
337
+ });
338
+ }
339
+
340
+ const limit = config?.paths?.features?.limit;
341
+ const header = limit
342
+ ? `# Features (${features.length}/${limit} max)`
343
+ : `# Features (${features.length})`;
344
+ const featureSection = [header];
345
+ for (const f of features) {
249
346
  featureSection.push(f);
250
347
  }
251
- sections.push(featureSection.join("\n\n"));
348
+ const text = featureSection.join("\n\n");
349
+ sections.push(text);
350
+ if (promptBudget) promptBudget.push({ section: "features", size: text.length, files: `${features.length}`, notes: "" });
252
351
  }
253
352
 
254
353
  // Library
255
354
  if (needs.library && localContext.libraryFiles?.length > 0) {
256
- const libSection = [`# Library Files (${localContext.libraryFiles.length})`];
355
+ const libLimit = config?.paths?.library?.limit;
356
+ const header = libLimit
357
+ ? `# Library Files (${localContext.libraryFiles.length}/${libLimit} max)`
358
+ : `# Library Files (${localContext.libraryFiles.length})`;
359
+ const libSection = [header];
257
360
  for (const f of localContext.libraryFiles) {
258
361
  libSection.push(`## ${f.name}\n${f.content}`);
259
362
  }
260
363
  sections.push(libSection.join("\n\n"));
261
364
  }
262
365
 
263
- // Library sources
366
+ // Library sources — vary strategy based on URL presence
264
367
  if (needs.librarySources && localContext.librarySources) {
265
- sections.push(`# Sources\n\n${localContext.librarySources}`);
368
+ if (refinements.checkSourcesForUrls) {
369
+ const hasUrls = /https?:\/\//.test(localContext.librarySources);
370
+ if (!hasUrls) {
371
+ sections.push(`# Sources\n\n${localContext.librarySources}\n\nPopulate SOURCES.md with 3-8 relevant reference URLs aligned with the mission.`);
372
+ } else {
373
+ sections.push(`# Sources\n\n${localContext.librarySources}`);
374
+ }
375
+ } else {
376
+ sections.push(`# Sources\n\n${localContext.librarySources}`);
377
+ }
266
378
  }
267
379
 
268
- // Issues (from GitHub context)
380
+ // Issues (from GitHub context) — not duplicated if target issue already shown
269
381
  if (needs.issues && githubContext?.issues?.length > 0) {
270
382
  const issueSection = [`# Open Issues (${githubContext.issues.length})`];
271
383
  for (const issue of githubContext.issues) {
272
384
  issueSection.push(summariseIssue(issue, tuning?.issueBodyLimit || 500));
273
385
  }
274
- sections.push(issueSection.join("\n\n"));
386
+ const text = issueSection.join("\n\n");
387
+ sections.push(text);
388
+ if (promptBudget) promptBudget.push({ section: "issues", size: text.length, files: `${githubContext.issues.length}`, notes: "" });
275
389
  }
276
390
 
277
- // Specific issue detail
278
- if (githubContext?.issueDetail) {
391
+ // Specific issue detail (only if not already highlighted as target issue)
392
+ if (!refinements.highlightTargetIssue && githubContext?.issueDetail) {
279
393
  const issue = githubContext.issueDetail;
280
394
  const issueSection = [`# Issue #${issue.number}: ${issue.title}\n\n${issue.body || "(no body)"}`];
281
395
  if (issue.comments?.length > 0) {
@@ -306,6 +420,31 @@ export function buildUserPrompt(agentName, localContext, githubContext, { tuning
306
420
  ));
307
421
  }
308
422
 
423
+ // Limits section — inject concrete numbers from agentic-lib.toml
424
+ if (config) {
425
+ const limitsLines = ["# Limits (from agentic-lib.toml)", ""];
426
+ const budget = config.transformationBudget || 0;
427
+ const featLimit = config.paths?.features?.limit;
428
+ const libLimit = config.paths?.library?.limit;
429
+
430
+ if (featLimit) limitsLines.push(`- Maximum feature files: ${featLimit}`);
431
+ if (libLimit) limitsLines.push(`- Maximum library documents: ${libLimit}`);
432
+ if (budget > 0) {
433
+ const intentionPath = config.intentionBot?.intentionFilepath;
434
+ const cumulativeCost = readCumulativeCost(intentionPath);
435
+ const remaining = Math.max(0, budget - cumulativeCost);
436
+ limitsLines.push(`- Transformation budget: ${budget} (used: ${cumulativeCost}, remaining: ${remaining})`);
437
+ }
438
+ if (config.featureDevelopmentIssuesWipLimit) limitsLines.push(`- Maximum concurrent feature issues: ${config.featureDevelopmentIssuesWipLimit}`);
439
+ if (config.maintenanceIssuesWipLimit) limitsLines.push(`- Maximum concurrent maintenance issues: ${config.maintenanceIssuesWipLimit}`);
440
+ if (config.attemptsPerBranch) limitsLines.push(`- Maximum attempts per branch: ${config.attemptsPerBranch}`);
441
+ if (config.attemptsPerIssue) limitsLines.push(`- Maximum attempts per issue: ${config.attemptsPerIssue}`);
442
+
443
+ if (limitsLines.length > 2) {
444
+ sections.push(limitsLines.join("\n"));
445
+ }
446
+ }
447
+
309
448
  // Instructions
310
449
  sections.push([
311
450
  "Implement this mission. Read the existing source code and tests,",
@@ -314,5 +453,5 @@ export function buildUserPrompt(agentName, localContext, githubContext, { tuning
314
453
  "Start by reading the existing files, then implement the solution.",
315
454
  ].join("\n"));
316
455
 
317
- return sections.join("\n\n");
456
+ return { prompt: sections.join("\n\n"), promptBudget };
318
457
  }
@@ -0,0 +1,87 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2025-2026 Polycode Limited
3
+ // guards.js — Short-circuit checks that skip LLM invocation when unnecessary
4
+ //
5
+ // Phase 4 Step 10a: Restores the guards that existed in the per-task handlers
6
+ // (transform.js, fix-code.js, maintain-features.js, maintain-library.js)
7
+ // before Phase 4 convergence replaced them with unconditional runHybridSession().
8
+
9
+ import { existsSync } from "fs";
10
+ import { resolve } from "path";
11
+ import { execSync } from "child_process";
12
+ import { readCumulativeCost } from "./telemetry.js";
13
+
14
+ /**
15
+ * Task-to-guard mapping. Each task has an ordered list of guards.
16
+ * Guards are checked in order; the first that triggers returns skip.
17
+ */
18
+ const TASK_GUARDS = {
19
+ "transform": ["no-mission", "mission-complete", "budget-exhausted"],
20
+ "fix-code": ["tests-pass", "budget-exhausted"],
21
+ "maintain-features": ["mission-complete", "budget-exhausted"],
22
+ "maintain-library": ["mission-complete", "budget-exhausted"],
23
+ };
24
+
25
+ /**
26
+ * Check whether a task should be skipped before invoking the LLM.
27
+ *
28
+ * @param {string} taskName - Task name (e.g. "transform", "fix-code")
29
+ * @param {Object} config - Parsed agentic-lib config
30
+ * @param {string} workspacePath - Path to the workspace
31
+ * @param {Object} [options]
32
+ * @param {Object} [options.logger] - Logger interface
33
+ * @returns {{ skip: boolean, reason: string }}
34
+ */
35
+ export function checkGuards(taskName, config, workspacePath, { logger } = {}) {
36
+ const guards = TASK_GUARDS[taskName];
37
+ if (!guards) return { skip: false, reason: "" };
38
+
39
+ const wsPath = resolve(workspacePath);
40
+
41
+ for (const guard of guards) {
42
+ switch (guard) {
43
+ case "no-mission": {
44
+ const missionPath = config.paths?.mission?.path || "MISSION.md";
45
+ if (!existsSync(resolve(wsPath, missionPath))) {
46
+ return { skip: true, reason: `No mission file found at ${missionPath}` };
47
+ }
48
+ break;
49
+ }
50
+
51
+ case "mission-complete": {
52
+ // Match the old behaviour: skip if MISSION_COMPLETE.md exists
53
+ // unless supervisor is in maintenance mode
54
+ if (existsSync(resolve(wsPath, "MISSION_COMPLETE.md")) && config.supervisor !== "maintenance") {
55
+ return { skip: true, reason: "Mission already complete (MISSION_COMPLETE.md exists)" };
56
+ }
57
+ break;
58
+ }
59
+
60
+ case "tests-pass": {
61
+ const testCommand = config.testScript || "npm test";
62
+ try {
63
+ execSync(testCommand, { cwd: wsPath, encoding: "utf8", timeout: 120000, stdio: "pipe" });
64
+ return { skip: true, reason: "Tests already pass — nothing to fix" };
65
+ } catch {
66
+ // Tests fail — proceed with fix-code
67
+ }
68
+ break;
69
+ }
70
+
71
+ case "budget-exhausted": {
72
+ const budget = config.transformationBudget || 0;
73
+ if (budget > 0) {
74
+ const intentionFilepath = config.intentionBot?.intentionFilepath;
75
+ const filePath = intentionFilepath ? resolve(wsPath, intentionFilepath) : null;
76
+ const cumulativeCost = readCumulativeCost(filePath);
77
+ if (cumulativeCost >= budget) {
78
+ return { skip: true, reason: `Transformation budget exhausted (${cumulativeCost}/${budget})` };
79
+ }
80
+ }
81
+ break;
82
+ }
83
+ }
84
+ }
85
+
86
+ return { skip: false, reason: "" };
87
+ }
@@ -62,6 +62,7 @@ function formatToolArgs(toolName, args) {
62
62
  * @param {string} [options.userPrompt] - Override user prompt (instead of default mission prompt)
63
63
  * @param {string[]} [options.writablePaths] - Writable paths for tool safety (default: workspace)
64
64
  * @param {number} [options.maxRetries=2] - Max retries on rate-limit errors
65
+ * @param {number} [options.maxToolCalls] - Max tool calls before graceful stop (undefined = unlimited)
65
66
  * @param {Object} [options.logger]
66
67
  * @returns {Promise<HybridResult>}
67
68
  */
@@ -75,6 +76,7 @@ export async function runHybridSession({
75
76
  userPrompt,
76
77
  writablePaths,
77
78
  maxRetries = 2,
79
+ maxToolCalls,
78
80
  logger = defaultLogger,
79
81
  }) {
80
82
  const { CopilotClient, approveAll, defineTool } = await getSDK();
@@ -145,7 +147,7 @@ export async function runHybridSession({
145
147
  const systemPrompt = basePrompt + NARRATIVE_INSTRUCTION;
146
148
 
147
149
  // ── Session config ─────────────────────────────────────────────────
148
- logger.info(`[hybrid] Creating session (model=${model}, workspace=${wsPath})`);
150
+ logger.info(`[agentic-lib] Creating session (model=${model}, workspace=${wsPath})`);
149
151
 
150
152
  const client = new CopilotClient({
151
153
  env: { ...process.env, GITHUB_TOKEN: copilotToken, GH_TOKEN: copilotToken },
@@ -159,6 +161,11 @@ export async function runHybridSession({
159
161
  workingDirectory: wsPath,
160
162
  hooks: {
161
163
  onPreToolUse: (input) => {
164
+ // Enforce tool-call budget
165
+ if (maxToolCalls && metrics.toolCalls.length >= maxToolCalls) {
166
+ logger.warning(` [tool] Budget reached (${maxToolCalls} calls) — denying ${input.toolName}`);
167
+ return { action: "deny", reason: `Tool call budget exhausted (${maxToolCalls} max). Wrap up your work.` };
168
+ }
162
169
  const n = metrics.toolCalls.length + 1;
163
170
  const elapsed = ((Date.now() - metrics.startTime) / 1000).toFixed(0);
164
171
  metrics.toolCalls.push({ tool: input.toolName, time: Date.now(), args: input.toolArgs });
@@ -211,15 +218,15 @@ export async function runHybridSession({
211
218
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
212
219
  try {
213
220
  session = await client.createSession(sessionConfig);
214
- logger.info(`[hybrid] Session: ${session.sessionId}`);
221
+ logger.info(`[agentic-lib] Session: ${session.sessionId}`);
215
222
  break;
216
223
  } catch (err) {
217
224
  if (isRateLimitError(err) && attempt < maxRetries) {
218
225
  const delayMs = retryDelayMs(err, attempt);
219
- logger.warning(`[hybrid] Rate limit on session creation — waiting ${Math.round(delayMs / 1000)}s (retry ${attempt + 1}/${maxRetries})`);
226
+ logger.warning(`[agentic-lib] Rate limit on session creation — waiting ${Math.round(delayMs / 1000)}s (retry ${attempt + 1}/${maxRetries})`);
220
227
  await new Promise((r) => setTimeout(r, delayMs));
221
228
  } else {
222
- logger.error(`[hybrid] Failed to create session: ${err.message}`);
229
+ logger.error(`[agentic-lib] Failed to create session: ${err.message}`);
223
230
  await client.stop();
224
231
  throw err;
225
232
  }
@@ -251,13 +258,13 @@ export async function runHybridSession({
251
258
  // ── Try autopilot mode ──────────────────────────────────────────────
252
259
  try {
253
260
  await session.rpc.mode.set({ mode: "autopilot" });
254
- logger.info("[hybrid] Autopilot mode: active");
261
+ logger.info("[agentic-lib] Autopilot mode: active");
255
262
  } catch {
256
- logger.info("[hybrid] Autopilot mode not available — using default mode");
263
+ logger.info("[agentic-lib] Autopilot mode not available — using default mode");
257
264
  }
258
265
 
259
266
  // ── Send mission prompt ─────────────────────────────────────────────
260
- logger.info("[hybrid] Sending mission...\n");
267
+ logger.info("[agentic-lib] Sending mission...\n");
261
268
 
262
269
  const prompt = userPrompt || [
263
270
  `# Mission\n\n${missionText}`,
@@ -281,10 +288,10 @@ export async function runHybridSession({
281
288
  } catch (err) {
282
289
  if (isRateLimitError(err) && attempt < maxRetries) {
283
290
  const delayMs = retryDelayMs(err, attempt);
284
- logger.warning(`[hybrid] Rate limit on sendAndWait — waiting ${Math.round(delayMs / 1000)}s (retry ${attempt + 1}/${maxRetries})`);
291
+ logger.warning(`[agentic-lib] Rate limit on sendAndWait — waiting ${Math.round(delayMs / 1000)}s (retry ${attempt + 1}/${maxRetries})`);
285
292
  await new Promise((r) => setTimeout(r, delayMs));
286
293
  } else {
287
- logger.error(`[hybrid] Session error: ${err.message}`);
294
+ logger.error(`[agentic-lib] Session error: ${err.message}`);
288
295
  response = null;
289
296
  endReason = "error";
290
297
  break;
@@ -10,11 +10,13 @@ import { resolve, dirname } from "path";
10
10
  import { fileURLToPath } from "url";
11
11
 
12
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
- const pkgRoot = resolve(__dirname, "../..");
14
13
 
14
+ // Search for the SDK relative to this file's location. Works in both:
15
+ // - npm package: src/copilot/sdk.js → ../../node_modules/ or ../actions/agentic-step/node_modules/
16
+ // - consumer repo: .github/agentic-lib/copilot/sdk.js → ../actions/agentic-step/node_modules/
15
17
  const SDK_LOCATIONS = [
16
- resolve(pkgRoot, "node_modules/@github/copilot-sdk/dist/index.js"),
17
- resolve(pkgRoot, "src/actions/agentic-step/node_modules/@github/copilot-sdk/dist/index.js"),
18
+ resolve(__dirname, "../../node_modules/@github/copilot-sdk/dist/index.js"),
19
+ resolve(__dirname, "../actions/agentic-step/node_modules/@github/copilot-sdk/dist/index.js"),
18
20
  ];
19
21
 
20
22
  let _sdk = null;