summaryrefslogtreecommitdiff
path: root/packages/core/src/tools/tools.ts
diff options
context:
space:
mode:
authorJacob MacDonald <[email protected]>2025-08-05 18:48:00 -0700
committerGitHub <[email protected]>2025-08-06 01:48:00 +0000
commit7e5a5e2da79783554dc4e3f00787317db29a589a (patch)
tree065c53a2037fd6b9f42ba32f00bc5a576a8f1cfa /packages/core/src/tools/tools.ts
parent9db5aab4987ad64317a2f6724880265f8124250d (diff)
Detect and warn about cyclic tool refs when schema depth errors are encountered (#5609)
Co-authored-by: Jacob Richman <[email protected]>
Diffstat (limited to 'packages/core/src/tools/tools.ts')
-rw-r--r--packages/core/src/tools/tools.ts85
1 files changed, 85 insertions, 0 deletions
diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts
index 0e3ffabf..5d9d9253 100644
--- a/packages/core/src/tools/tools.ts
+++ b/packages/core/src/tools/tools.ts
@@ -228,6 +228,91 @@ export interface ToolResult {
};
}
+/**
+ * Detects cycles in a JSON schemas due to `$ref`s.
+ * @param schema The root of the JSON schema.
+ * @returns `true` if a cycle is detected, `false` otherwise.
+ */
+export function hasCycleInSchema(schema: object): boolean {
+ function resolveRef(ref: string): object | null {
+ if (!ref.startsWith('#/')) {
+ return null;
+ }
+ const path = ref.substring(2).split('/');
+ let current: unknown = schema;
+ for (const segment of path) {
+ if (
+ typeof current !== 'object' ||
+ current === null ||
+ !Object.prototype.hasOwnProperty.call(current, segment)
+ ) {
+ return null;
+ }
+ current = (current as Record<string, unknown>)[segment];
+ }
+ return current as object;
+ }
+
+ function traverse(
+ node: unknown,
+ visitedRefs: Set<string>,
+ pathRefs: Set<string>,
+ ): boolean {
+ if (typeof node !== 'object' || node === null) {
+ return false;
+ }
+
+ if (Array.isArray(node)) {
+ for (const item of node) {
+ if (traverse(item, visitedRefs, pathRefs)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ if ('$ref' in node && typeof node.$ref === 'string') {
+ const ref = node.$ref;
+ if (ref === '#/' || pathRefs.has(ref)) {
+ // A ref to just '#/' is always a cycle.
+ return true; // Cycle detected!
+ }
+ if (visitedRefs.has(ref)) {
+ return false; // Bail early, we have checked this ref before.
+ }
+
+ const resolvedNode = resolveRef(ref);
+ if (resolvedNode) {
+ // Add it to both visited and the current path
+ visitedRefs.add(ref);
+ pathRefs.add(ref);
+ const hasCycle = traverse(resolvedNode, visitedRefs, pathRefs);
+ pathRefs.delete(ref); // Backtrack, leaving it in visited
+ return hasCycle;
+ }
+ }
+
+ // Crawl all the properties of node
+ for (const key in node) {
+ if (Object.prototype.hasOwnProperty.call(node, key)) {
+ if (
+ traverse(
+ (node as Record<string, unknown>)[key],
+ visitedRefs,
+ pathRefs,
+ )
+ ) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ return traverse(schema, new Set<string>(), new Set<string>());
+}
+
export type ToolResultDisplay = string | FileDiff;
export interface FileDiff {