Skip to content

Commit 0e49480

Browse files
Copilothuberp
andauthored
fix: resolve CI log errors — token overflow, hallucinated files, misnamed tool, missing profileRegistry (#108)
* fix: file-list recursive excludes build dirs, workspace exploration before planning, code-run name, profileRegistry in graph adapter Agent-Logs-Url: https://github.com/huberp/agentloop/sessions/3b124bf9-fd1e-4d37-8483-0cbef986cab9 Co-authored-by: huberp <4027454+huberp@users.noreply.github.com> * fix: add CJS shims for jsdom@29 ESM-only deps, fix mock sequences for planNode workspace exploration Agent-Logs-Url: https://github.com/huberp/agentloop/sessions/f9216c8d-20db-4b33-9e63-2b37f76b0ff8 Co-authored-by: huberp <4027454+huberp@users.noreply.github.com> * chore: improve shim comments — fix spelling, document selector limitations Agent-Logs-Url: https://github.com/huberp/agentloop/sessions/f9216c8d-20db-4b33-9e63-2b37f76b0ff8 Co-authored-by: huberp <4027454+huberp@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: huberp <4027454+huberp@users.noreply.github.com>
1 parent d553d74 commit 0e49480

9 files changed

Lines changed: 288 additions & 13 deletions

File tree

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,10 @@
8181
"moduleNameMapper": {
8282
"^@modelcontextprotocol/sdk/(.*)$": "<rootDir>/node_modules/@modelcontextprotocol/sdk/dist/cjs/$1",
8383
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
84-
"^@exodus/bytes/(.*)$": "<rootDir>/tests/jest-shims/exodus-bytes-encoding.cjs"
84+
"^@exodus/bytes/(.*)$": "<rootDir>/tests/jest-shims/exodus-bytes-encoding.cjs",
85+
"^@asamuzakjp/css-color$": "<rootDir>/tests/jest-shims/asamuzakjp-css-color.cjs",
86+
"^@asamuzakjp/dom-selector$": "<rootDir>/tests/jest-shims/asamuzakjp-dom-selector.cjs",
87+
"^parse5$": "<rootDir>/node_modules/parse5/dist/cjs/index.js"
8588
}
8689
}
8790
}

src/__tests__/langgraph.test.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -601,9 +601,12 @@ describe("invokeGraph — integration with mock LLM", () => {
601601
{ type: "step", description: "do thing 2", toolsNeeded: [], estimatedComplexity: "low" },
602602
],
603603
});
604+
const workspaceCtxJson = JSON.stringify({
605+
workspaceInfo: { language: "node", framework: "none", packageManager: "npm", hasTests: true, testCommand: "", lintCommand: "", buildCommand: "", entryPoints: [], gitInitialized: true },
606+
});
604607

605-
// Mock: planner returns the plan, then each step returns "done"
606-
const llm = makeMockLlm(planJson, "step 1 done", "step 2 done");
608+
// Mock: explorer returns workspace context, planner returns the plan, then each step returns "done"
609+
const llm = makeMockLlm(workspaceCtxJson, planJson, "step 1 done", "step 2 done");
607610
const registry = new ToolRegistry();
608611

609612
const events: GraphEvent[] = [];
@@ -635,8 +638,12 @@ describe("invokeGraph — integration with mock LLM", () => {
635638
},
636639
],
637640
});
641+
const workspaceCtxJson = JSON.stringify({
642+
workspaceInfo: { language: "node", framework: "none", packageManager: "npm", hasTests: true, testCommand: "", lintCommand: "", buildCommand: "", entryPoints: [], gitInitialized: true },
643+
});
638644

639-
const llm = makeMockLlm(planJson, "fast wins!", "slow result");
645+
// Explorer → workspace context; planner → plan; steps → results
646+
const llm = makeMockLlm(workspaceCtxJson, planJson, "fast wins!", "slow result");
640647
const registry = new ToolRegistry();
641648

642649
// maxConcurrency=1 forces sequential execution: first branch completes,
@@ -658,12 +665,19 @@ describe("invokeGraph — integration with mock LLM", () => {
658665
{ type: "step", description: "will fail", toolsNeeded: [], estimatedComplexity: "low" },
659666
],
660667
});
668+
const workspaceCtxJson = JSON.stringify({
669+
workspaceInfo: { language: "node", framework: "none", packageManager: "npm", hasTests: true, testCommand: "", lintCommand: "", buildCommand: "", entryPoints: [], gitInitialized: true },
670+
});
661671

662-
// Planner returns the plan; step LLM throws
672+
// Explorer returns workspace context (call 1); planner returns plan (call 2); step throws (call 3+)
663673
let callCount = 0;
664674
const invoke = jest.fn().mockImplementation(() => {
665675
callCount++;
666676
if (callCount === 1) {
677+
// Project-explorer call: return workspace context
678+
return Promise.resolve({ content: workspaceCtxJson, tool_calls: [] });
679+
}
680+
if (callCount === 2) {
667681
// Planner call: return the plan
668682
return Promise.resolve({ content: planJson, tool_calls: [] });
669683
}

src/__tests__/start-oneshot.test.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -544,13 +544,18 @@ describe("start-oneshot: getActiveExecutor", () => {
544544
const original = config.appConfig.orchestrator;
545545
(config.appConfig as any).orchestrator = "langgraph";
546546

547-
// Override mock LLM to return a valid blocks plan then a step response
547+
// Override mock LLM to return workspace context (explorer), then a valid
548+
// blocks plan (planner), then a step response.
549+
const workspaceCtxJson = JSON.stringify({
550+
workspaceInfo: { language: "node", framework: "none", packageManager: "npm", hasTests: true, testCommand: "", lintCommand: "", buildCommand: "", entryPoints: [], gitInitialized: true },
551+
});
548552
const planJson = JSON.stringify({
549553
version: "2.0",
550554
goal: "say hello",
551555
blocks: [{ type: "step", description: "greet", toolsNeeded: [], estimatedComplexity: "low" }],
552556
});
553557
mockLlmInvoke
558+
.mockResolvedValueOnce({ content: workspaceCtxJson, tool_calls: [] })
554559
.mockResolvedValueOnce({ content: planJson, tool_calls: [] })
555560
.mockResolvedValueOnce({ content: "Hello!", tool_calls: [] });
556561

@@ -571,13 +576,18 @@ describe("start-oneshot: getActiveExecutor", () => {
571576
const original = config.appConfig.orchestrator;
572577
(config.appConfig as any).orchestrator = "langgraph";
573578

574-
// Override mock LLM to return a valid blocks plan then a step response
579+
// Override mock LLM to return workspace context (explorer), then a valid
580+
// blocks plan (planner), then a step response.
581+
const workspaceCtxJson = JSON.stringify({
582+
workspaceInfo: { language: "node", framework: "none", packageManager: "npm", hasTests: true, testCommand: "", lintCommand: "", buildCommand: "", entryPoints: [], gitInitialized: true },
583+
});
575584
const planJson = JSON.stringify({
576585
version: "2.0",
577586
goal: "say hello",
578587
blocks: [{ type: "step", description: "greet", toolsNeeded: [], estimatedComplexity: "low" }],
579588
});
580589
mockLlmInvoke
590+
.mockResolvedValueOnce({ content: workspaceCtxJson, tool_calls: [] })
581591
.mockResolvedValueOnce({ content: planJson, tool_calls: [] })
582592
.mockResolvedValueOnce({ content: "Hello!", tool_calls: [] });
583593

src/agents/builtin/coder.agent.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"model": "gpt-4o",
66
"temperature": 0.2,
77
"skills": ["typescript-expert"],
8-
"tools": ["file-read", "file-write", "file-edit", "file-list", "file-delete", "code-run", "code-search", "shell", "calculate"],
8+
"tools": ["file-read", "file-write", "file-edit", "file-list", "file-delete", "code_run", "code-search", "shell", "calculate"],
99
"maxIterations": 20,
1010
"promptTemplate": "system",
1111
"constraints": {

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,7 @@ function createGraphExecutorAdapter() {
470470
const result = await graphExecutor.invoke(
471471
input,
472472
{ sharedContext: conversationHistory ? { conversationHistory } : {} },
473-
{ registry: toolRegistry },
473+
{ registry: toolRegistry, profileRegistry: agentProfileRegistry },
474474
);
475475
await chatHistory.addMessage(new AIMessage(result.output));
476476
return { output: result.output };
@@ -482,7 +482,7 @@ function createGraphExecutorAdapter() {
482482
const result = await graphExecutor.invoke(
483483
input,
484484
{ sharedContext: conversationHistory ? { conversationHistory } : {} },
485-
{ registry: toolRegistry },
485+
{ registry: toolRegistry, profileRegistry: agentProfileRegistry },
486486
);
487487
await chatHistory.addMessage(new AIMessage(result.output));
488488
yield result.output;

src/langgraph/graph.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { logger } from "../logger";
2222
import { ToolRegistry } from "../tools/registry";
2323
import { runSubagent } from "../subagents/runner";
2424
import type { AgentProfileRegistry } from "../agents/registry";
25+
import { exploreWorkspace } from "../agents/project-explorer";
2526
import { validateBlocksPlan, compileBlocksPlanToDag } from "./compiler";
2627
import { buildRuntimeContextBody } from "../prompts/context";
2728
import {
@@ -154,11 +155,29 @@ export function buildGraphNodes(deps: GraphDeps, progressCb?: (evt: GraphEvent)
154155
async function planNode(state: GraphState): Promise<Partial<GraphState>> {
155156
logger.info({ request: state.request }, "Graph: generating blocks plan");
156157
const availableTools = deps.registry.list().map((t) => t.name);
158+
159+
// Explore the workspace so the planner knows what files/build-systems exist.
160+
// This prevents the planner from hallucinating file paths that don't exist.
161+
let workspaceCtxStr = "";
162+
try {
163+
const workspaceCtx = await exploreWorkspace({ registry: deps.registry, llm: deps.llm });
164+
workspaceCtxStr = JSON.stringify(workspaceCtx, null, 2);
165+
logger.info({ workspaceCtx }, "Graph: workspace exploration complete");
166+
} catch (err) {
167+
logger.warn(
168+
{ error: (err as Error).message },
169+
"Graph: workspace exploration failed; proceeding without workspace context",
170+
);
171+
}
172+
157173
const parts: string[] = [
158174
`Task: ${state.request}`,
159175
`Available tools: ${availableTools.join(", ") || "(none)"}`,
160176
buildRuntimeContextBody(),
161177
];
178+
if (workspaceCtxStr) {
179+
parts.push(`Workspace context (use this to understand the actual repo structure before planning steps):\n${workspaceCtxStr}`);
180+
}
162181
const convHistory = state.sharedContext.conversationHistory as string | undefined;
163182
if (convHistory) {
164183
parts.push(`Previous conversation context:\n${convHistory}`);

src/tools/file-list.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,38 @@ import type { ToolDefinition } from "./registry";
55
import { resolveSafe, globToRegExp } from "./file-utils";
66
import { appConfig } from "../config";
77

8+
/**
9+
* Directories that are always skipped during recursive traversal.
10+
* These are large build-artifact / dependency trees that have no value for
11+
* AI-driven code exploration and can easily produce millions of tokens.
12+
*/
13+
const DEFAULT_EXCLUDE_DIRS = new Set([
14+
"node_modules",
15+
".git",
16+
"dist",
17+
"build",
18+
"out",
19+
"target", // Rust / Maven
20+
".venv", // Python virtualenv
21+
"venv",
22+
"__pycache__",
23+
".cache",
24+
".tox",
25+
"coverage",
26+
".nyc_output",
27+
]);
28+
829
const schema = z.object({
930
path: z.string().default(".").describe("Directory path relative to the workspace root (default: workspace root)"),
1031
glob: z.string().optional().describe("Optional glob pattern to filter entries (e.g. '*.ts', '**/*.json')"),
1132
recursive: z.boolean().optional().default(false).describe("List entries recursively (default: false)"),
33+
exclude: z
34+
.array(z.string())
35+
.optional()
36+
.describe(
37+
"Additional directory names to exclude from recursive traversal " +
38+
"(node_modules, .git, dist, build, and other common build-artifact directories are always excluded)"
39+
),
1240
});
1341

1442
/** A single directory entry returned by file-list. */
@@ -22,8 +50,13 @@ interface FileEntry {
2250

2351
/**
2452
* Recursively collect entries under `dir`, returning paths relative to `baseDir`.
53+
* Directories whose base name appears in `excludeDirs` are silently skipped.
2554
*/
26-
async function collectEntries(dir: string, baseDir: string): Promise<FileEntry[]> {
55+
async function collectEntries(
56+
dir: string,
57+
baseDir: string,
58+
excludeDirs: Set<string>
59+
): Promise<FileEntry[]> {
2760
const entries = await fs.readdir(dir, { withFileTypes: true });
2861
const results: FileEntry[] = [];
2962

@@ -32,9 +65,11 @@ async function collectEntries(dir: string, baseDir: string): Promise<FileEntry[]
3265
const relativePath = path.relative(baseDir, fullPath);
3366

3467
if (entry.isDirectory()) {
68+
// Skip excluded directories (e.g. node_modules, .git, dist)
69+
if (excludeDirs.has(entry.name)) continue;
3570
results.push({ path: relativePath, type: "directory" });
3671
// Recurse into sub-directory.
37-
results.push(...(await collectEntries(fullPath, baseDir)));
72+
results.push(...(await collectEntries(fullPath, baseDir, excludeDirs)));
3873
} else if (entry.isFile()) {
3974
const stat = await fs.stat(fullPath);
4075
results.push({ path: relativePath, type: "file", sizeBytes: stat.size });
@@ -55,17 +90,21 @@ export const toolDefinition: ToolDefinition = {
5590
path: dirPath = ".",
5691
glob,
5792
recursive = false,
93+
exclude = [],
5894
}: {
5995
path?: string;
6096
glob?: string;
6197
recursive?: boolean;
98+
exclude?: string[];
6299
}): Promise<string> => {
63100
const resolved = resolveSafe(appConfig.workspaceRoot, dirPath);
64101

65102
let entries: FileEntry[];
66103

67104
if (recursive) {
68-
entries = await collectEntries(resolved, resolved);
105+
// Merge caller-supplied exclusions with the built-in defaults
106+
const excludeDirs = new Set([...DEFAULT_EXCLUDE_DIRS, ...exclude]);
107+
entries = await collectEntries(resolved, resolved, excludeDirs);
69108
} else {
70109
const rawEntries = await fs.readdir(resolved, { withFileTypes: true });
71110
entries = await Promise.all(
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"use strict";
2+
// CJS shim for @asamuzakjp/css-color (ESM-only in npm package).
3+
// jsdom@29 imports this module for CSS color resolution. The agent-loop
4+
// tests do not exercise CSS rendering, so stub implementations that pass
5+
// through / return safe defaults are sufficient.
6+
//
7+
// NOTE: This shim returns pass-through values only. Full CSS color parsing
8+
// is not implemented; tests that require accurate color computation should
9+
// not rely on this shim.
10+
11+
/**
12+
* Resolve a CSS colour value to a canonical form.
13+
* Returns the input unchanged when resolution is not needed.
14+
*/
15+
function resolve(value, opt) {
16+
if (typeof value === "string") return value;
17+
return "";
18+
}
19+
20+
/**
21+
* Convert a colour between spaces.
22+
* Returns null (indicating "not resolved") so jsdom falls back gracefully.
23+
*/
24+
function convert(value, opt) {
25+
return null;
26+
}
27+
28+
/** Utility helpers exposed by the real package. Stubbed as no-ops. */
29+
const utils = {
30+
cssCalc: (value) => value,
31+
cssVar: (value) => value,
32+
extractDashedIdent: () => [],
33+
isAbsoluteFontSize: () => false,
34+
isAbsoluteSizeOrLength: () => false,
35+
isColor: (value) => typeof value === "string",
36+
isGradient: () => false,
37+
resolveGradient: (value) => value,
38+
resolveLengthInPixels: () => 0,
39+
splitValue: (value) => (typeof value === "string" ? [value] : []),
40+
};
41+
42+
module.exports = { resolve, convert, utils };

0 commit comments

Comments
 (0)