diff --git a/dialect/agentfabric/package.json b/dialect/agentfabric/package.json index d05f1036..e91641d5 100644 --- a/dialect/agentfabric/package.json +++ b/dialect/agentfabric/package.json @@ -1,6 +1,6 @@ { "name": "@agentscript/agentfabric-dialect", - "version": "0.1.24", + "version": "0.1.30", "description": "AgentFabric dialect — schema, lint rules, compiler, and dialect config", "type": "module", "main": "dist/index.js", @@ -22,19 +22,18 @@ "dev": "tsc --watch", "test": "vitest run --coverage --coverage.reporter=lcov --coverage.reporter=json-summary --coverage.reportsDirectory=coverage", "test:watch": "vitest", - "pretypecheck": "node ../../scripts/sync-pkg-meta.mjs", "typecheck": "tsc --noEmit", "clean": "rm -rf dist" }, "dependencies": { - "@agentscript/language": "workspace:*", - "@agentscript/agentscript-dialect": "workspace:*" + "@agentscript/agentscript-dialect": "workspace:*", + "@agentscript/language": "workspace:*" }, "devDependencies": { "@agentscript/parser": "workspace:*", "tree-sitter": "^0.25.0", "typescript": "^5.8.3", - "vitest": "^3.0.0" + "vitest": "^3.2.6" }, "keywords": [ "agentscript", diff --git a/dialect/agentfabric/src/graph/extractor.test.ts b/dialect/agentfabric/src/graph/extractor.test.ts new file mode 100644 index 00000000..d8ed57ec --- /dev/null +++ b/dialect/agentfabric/src/graph/extractor.test.ts @@ -0,0 +1,366 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { + NamedBlock, + NamedCollectionBlock, + ProcedureValue, + ReferenceValue, + StringValue, + SymbolKind, +} from '@agentscript/language'; +import type { SchemaInfo } from '@agentscript/language'; +import { parseDocument, parseWithSchema } from '../tests/test-utils.js'; +import { AgentFabricSchemaInfo } from '../schema.js'; +import { extractGraph } from './extractor.js'; +import type { ExtractedGraph, GraphEdge } from './extractor.js'; +import type { Range } from '@agentscript/language'; + +const FIXTURE_PATH = resolve( + dirname(fileURLToPath(import.meta.url)), + '../tests/resources/it-help-investigation.agent' +); + +function hasEdge( + graph: ExtractedGraph, + predicate: Partial +): boolean { + return graph.edges.some(e => + Object.entries(predicate).every( + ([k, v]) => (e as unknown as Record)[k] === v + ) + ); +} + +function outgoingEdgesOf(graph: ExtractedGraph, fromId: string): GraphEdge[] { + return graph.edges.filter(e => e.from === fromId); +} + +/** + * Slice the source text by a `Range`. Single-line ranges only — multi-line + * ranges in the test fixture are not asserted. + */ +function sliceSource(source: string, range: Range): string { + const lines = source.split(/\r?\n/); + const { start, end } = range; + if (start.line !== end.line) { + throw new Error( + `multi-line ranges not supported in test slice (start ${start.line} end ${end.line})` + ); + } + return lines[start.line].slice(start.character, end.character); +} + +describe('extractGraph (agentfabric fixture)', () => { + const source = readFileSync(FIXTURE_PATH, 'utf8'); + const parsed = parseDocument(source); + const graph = extractGraph(parsed, AgentFabricSchemaInfo); + + it('discovers the trigger as a node with blockKind: TriggerBlock', () => { + const triggers = graph.nodes.filter(n => n.blockKind === 'TriggerBlock'); + expect(triggers).toHaveLength(1); + expect(triggers[0]).toMatchObject({ + id: 'trigger.ticketTrigger', + namespace: 'trigger', + name: 'ticketTrigger', + blockKind: 'TriggerBlock', + }); + }); + + it('collects every entry (triggers + transition-target blocks) in the fixture', () => { + const ids = graph.nodes.map(n => n.id).sort(); + expect(ids).toEqual( + [ + 'echo.escalationResponse', + 'echo.helpResponse', + 'echo.licenseResponse', + 'echo.unresolvedResponse', + 'executor.escalateTicket', + 'executor.escalateUnresolved', + 'generator.classifySeverity', + 'generator.helpSummary', + 'generator.licenseSummary', + 'orchestrator.crossPlatformTriage', + 'router.resolutionRouter', + 'router.severityRouter', + 'trigger.ticketTrigger', + ].sort() + ); + }); + + it('emits the trigger entry edge via transitionContainer (tagged trigger)', () => { + expect( + hasEdge(graph, { + from: 'trigger.ticketTrigger', + to: 'generator.classifySeverity', + via: 'trigger', + }) + ).toBe(true); + }); + + it('emits transitionContainer edges from generators', () => { + expect( + hasEdge(graph, { + from: 'generator.classifySeverity', + to: 'router.severityRouter', + via: 'transitionContainer', + }) + ).toBe(true); + expect( + hasEdge(graph, { + from: 'generator.helpSummary', + to: 'echo.helpResponse', + via: 'transitionContainer', + }) + ).toBe(true); + }); + + it('emits transitionTarget edges from a router (route + otherwise)', () => { + expect( + hasEdge(graph, { + from: 'router.severityRouter', + to: 'executor.escalateTicket', + via: 'transitionTarget', + }) + ).toBe(true); + expect( + hasEdge(graph, { + from: 'router.severityRouter', + to: 'orchestrator.crossPlatformTriage', + via: 'transitionTarget', + }) + ).toBe(true); + // resolutionRouter has two routes + one otherwise — three outgoing edges. + expect(outgoingEdgesOf(graph, 'router.resolutionRouter')).toHaveLength(3); + }); + + it('captures sibling primitive properties on router-route edges (label + when expression)', () => { + // Route edge → both `label` (StringLiteral) and `when` (expression + // source text) collected as sibling primitives. + const routeEdge = graph.edges.find( + e => + e.from === 'router.severityRouter' && e.to === 'executor.escalateTicket' + ); + expect(routeEdge).toBeDefined(); + expect(routeEdge?.properties).toEqual({ + label: 'High', + when: '@generator.classifySeverity.output.severity == "high"', + }); + // The schema-marked siblings are also surfaced as dedicated fields so + // adapters don't reach into `properties` by hardcoded names. + expect(routeEdge?.outputName).toBe('High'); + expect(routeEdge?.predicate).toBe( + '@generator.classifySeverity.output.severity == "high"' + ); + + // Otherwise has no `label` and no `when` — properties bag absent and + // the structured surfaces are undefined. + const otherwiseEdge = graph.edges.find( + e => + e.from === 'router.severityRouter' && + e.to === 'orchestrator.crossPlatformTriage' + ); + expect(otherwiseEdge).toBeDefined(); + expect(otherwiseEdge?.properties).toBeUndefined(); + expect(otherwiseEdge?.outputName).toBeUndefined(); + expect(otherwiseEdge?.predicate).toBeUndefined(); + + // Non-router edges (transitionContainer) get no properties bag either. + const triggerEdge = graph.edges.find( + e => e.from === 'trigger.ticketTrigger' + ); + expect(triggerEdge?.properties).toBeUndefined(); + expect(triggerEdge?.outputName).toBeUndefined(); + expect(triggerEdge?.predicate).toBeUndefined(); + }); + + it('captures top-level string-literal fields on graph nodes', () => { + const classifySeverity = graph.nodes.find( + n => n.id === 'generator.classifySeverity' + ); + // GeneratorBlock carries `description` and `label` as StringValue. + expect(classifySeverity?.properties).toMatchObject({ + label: 'Classify Severity', + }); + expect(classifySeverity?.properties?.description).toContain('Classifies'); + }); + + it('emits an edge from executor.escalateTicket to its echo on_exit', () => { + expect( + hasEdge(graph, { + from: 'executor.escalateTicket', + to: 'echo.escalationResponse', + via: 'transitionContainer', + }) + ).toBe(true); + }); + + it('produces no outgoing edges for terminal echo nodes (no on_exit)', () => { + expect(outgoingEdgesOf(graph, 'echo.escalationResponse')).toHaveLength(0); + expect(outgoingEdgesOf(graph, 'echo.helpResponse')).toHaveLength(0); + expect(outgoingEdgesOf(graph, 'echo.licenseResponse')).toHaveLength(0); + expect(outgoingEdgesOf(graph, 'echo.unresolvedResponse')).toHaveLength(0); + }); + + it('attaches a lexicalRange to every node and edge', () => { + for (const node of graph.nodes) { + expect(node.lexicalRange, `node ${node.id}`).toBeDefined(); + } + for (const edge of graph.edges) { + expect(edge.lexicalRange, `edge ${edge.from}->${edge.to}`).toBeDefined(); + } + }); + + it('uses the MemberExpression range for transitionTarget edges and the ToClause range for transitionContainer edges', () => { + // Router route → MemberExpression (`@executor.escalateTicket`). + const refEdge = graph.edges.find( + e => + e.from === 'router.severityRouter' && + e.to === 'executor.escalateTicket' && + e.via === 'transitionTarget' + )!; + expect(sliceSource(source, refEdge.lexicalRange!)).toBe( + '@executor.escalateTicket' + ); + + // Generator → router via `to @router.severityRouter` — the ToClause + // covers the `to ` keyword plus the MemberExpression. + const containerEdge = graph.edges.find( + e => + e.from === 'generator.classifySeverity' && + e.to === 'router.severityRouter' && + e.via === 'transitionContainer' + )!; + expect(sliceSource(source, containerEdge.lexicalRange!)).toBe( + 'to @router.severityRouter' + ); + }); +}); + +// --------------------------------------------------------------------------- +// Schema-extensibility test: prove the extractor is generic +// --------------------------------------------------------------------------- + +describe('extractGraph (schema extensibility)', () => { + /** + * A synthetic dialect with one trigger kind and one custom node kind + * (`widget`). Neither name nor any other identifier in the extractor + * code knows anything about these blocks — they're discovered purely + * from `capabilities` + `transitionContainer` markers. + */ + const SyntheticTriggerBlock = NamedBlock('SyntheticTriggerBlock', { + target: StringValue.describe('Trigger target.'), + on_message: ProcedureValue.describe('On message procedure.') + .required() + .transitionContainer(), + }).describe('Synthetic trigger block.'); + + const WidgetBlock = NamedBlock( + 'WidgetBlock', + { + label: StringValue.describe('Widget label.'), + next: ReferenceValue.describe('Next widget reference.') + .allowedNamespaces(['widget']) + .resolvedType('transitionTarget'), + after: ProcedureValue.describe( + 'Procedure to run after.' + ).transitionContainer(), + }, + { + capabilities: ['transitionTarget'], + symbol: { kind: SymbolKind.Namespace }, + } + ).describe('Custom widget node.'); + + const SyntheticSchema = { + sigtrigger: NamedCollectionBlock(SyntheticTriggerBlock), + widget: NamedCollectionBlock(WidgetBlock), + }; + + const SyntheticSchemaInfo: SchemaInfo = { + schema: SyntheticSchema, + aliases: {}, + }; + + it('discovers a custom trigger + node kind without any extractor change', () => { + const source = ` +sigtrigger entry: + target: "stub://entry" + on_message: -> + transition to @widget.first + +widget first: + label: "first" + after: -> + transition to @widget.second + +widget second: + label: "second" + next: @widget.third + +widget third: + label: "third" +`; + const parsed = parseWithSchema(source, SyntheticSchema); + const graph = extractGraph(parsed, SyntheticSchemaInfo); + + const triggers = graph.nodes.filter( + n => n.blockKind === 'SyntheticTriggerBlock' + ); + expect(triggers).toHaveLength(1); + expect(triggers[0].id).toBe('sigtrigger.entry'); + + const widgetIds = graph.nodes + .filter(n => n.blockKind === 'WidgetBlock') + .map(n => n.id) + .sort(); + expect(widgetIds).toEqual([ + 'widget.first', + 'widget.second', + 'widget.third', + ]); + + // Trigger entry edge tagged 'trigger'. + expect( + hasEdge(graph, { + from: 'sigtrigger.entry', + to: 'widget.first', + via: 'trigger', + }) + ).toBe(true); + + // transitionContainer edge inside a widget. + expect( + hasEdge(graph, { + from: 'widget.first', + to: 'widget.second', + via: 'transitionContainer', + }) + ).toBe(true); + + // resolvedType: transitionTarget edge from a reference field. + expect( + hasEdge(graph, { + from: 'widget.second', + to: 'widget.third', + via: 'transitionTarget', + }) + ).toBe(true); + + // Terminal widget — no outgoing edges. + expect(outgoingEdgesOf(graph, 'widget.third')).toHaveLength(0); + + // Generic property collection works for an unknown schema. The + // `label` sibling on widget.second is picked up purely from + // schema-driven primitive introspection. + const refEdge = graph.edges.find( + e => e.from === 'widget.second' && e.to === 'widget.third' + ); + expect(refEdge?.properties).toEqual({ label: 'second' }); + + // Top-level node properties surface widget labels too. + const widgetFirst = graph.nodes.find(n => n.id === 'widget.first'); + expect(widgetFirst?.properties).toEqual({ label: 'first' }); + }); +}); diff --git a/dialect/agentfabric/src/graph/extractor.ts b/dialect/agentfabric/src/graph/extractor.ts new file mode 100644 index 00000000..c08977fb --- /dev/null +++ b/dialect/agentfabric/src/graph/extractor.ts @@ -0,0 +1,582 @@ +/** + * Schema-driven graph extractor. + * + * Walks a parsed AgentFabric (or any compatible) document and builds a + * directed graph of nodes + edges purely from schema metadata. No field + * or block names are hardcoded — adding a new node kind to a dialect is + * a schema change and nothing else. + * + * Nodes: + * - Every entry of any top-level NamedCollection whose entry block either + * declares the `'transitionTarget'` block-level capability OR owns a + * field marked `transitionContainer`. Both kinds share the same + * `GraphNode` shape; consumers can derive trigger-vs-step from edge + * topology (a trigger has no incoming edges). + * + * Edges come from two field-level markers: + * - `__metadata.transitionContainer === true` on a ProcedureValue + * field — the procedure body's TransitionStatements / ToClauses are + * walked; each clause target produces an edge. + * - `__metadata.constraints.resolvedType === 'transitionTarget'` on a + * ReferenceValue field — the MemberExpression value `@ns.name` + * resolves directly to the destination id. + * + * Per-node and per-edge metadata: + * - `lexicalRange` is the source range of the AST element that defines + * the entity (entry instance / MemberExpression / ToClause). + * - `properties` on nodes captures top-level string-literal sibling fields + * (e.g. `label`, `description`) for display. + * - `properties` on edges captures every primitive sibling on the parent + * block instance — string literals contribute their unwrapped value, + * expressions contribute their source text. This is how router routes + * surface both `label` and `when` (the predicate) without naming them. + */ +import { + decomposeAtMemberExpression, + isCollectionFieldType, + isNamedCollectionFieldType, + isNamedMap, + SequenceNode, +} from '@agentscript/language'; +import type { + AstNodeLike, + FieldType, + Range, + Schema, + SchemaInfo, +} from '@agentscript/language'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface GraphNode { + /** Qualified id, e.g. "generator.classifySeverity". */ + id: string; + /** Schema key (namespace), e.g. "generator". */ + namespace: string; + /** Instance name, e.g. "classifySeverity". */ + name: string; + /** Block-factory kind, e.g. "GeneratorBlock", "TriggerBlock". */ + blockKind: string; + /** + * String-literal primitive fields collected from the entry's top-level + * schema (e.g. `label`, `description`). Generic — no field name is + * hardcoded. Undefined when no such fields are present. + */ + properties?: Record; + /** + * Human-readable display label — copied from the StringLiteral value + * of any top-level field marked `displayLabelField` in the schema. + * Convenience surface so adapters don't reach into `properties` by a + * hardcoded field name. Undefined when no such field is present or + * the field has no value. + */ + label?: string; + /** + * Source range of the AST element that defines this node — i.e. the + * NamedCollection entry instance. Undefined for synthetic nodes that + * have no `__cst` attached. + */ + lexicalRange?: Range; +} + +export type EdgeProvenance = + | 'trigger' + | 'transitionContainer' + | 'transitionTarget'; + +export interface GraphEdge { + /** Source node id, e.g. "generator.classifySeverity". */ + from: string; + /** Target node id, e.g. "generator.classifySeverity". */ + to: string; + /** Where this edge was discovered (best-effort label for debugging). */ + via: EdgeProvenance; + /** + * Sibling primitive fields collected from the parent block instance + * that owned the `transitionTarget` field. String literals contribute + * their unwrapped value; expressions contribute their source text. + * Driven by the schema, no field name is hardcoded. + * + * Example: a router route captures `{ label: "High", when: "@x == 1" }`. + * Undefined when the parent has no qualifying siblings (e.g. router + * `otherwise`, or edges from a transitionContainer). + */ + properties?: Record; + /** + * Source text of the predicate that gates this edge — copied from any + * sibling primitive field marked `predicateField` in the schema. This + * is a convenience surface so consumers don't need schema access to + * locate the gating expression among `properties`. Undefined when no + * such field is present (e.g. `otherwise` routes, transitionContainer + * edges). + */ + predicate?: string; + /** + * Human-readable name of the output this edge represents — copied from + * the StringLiteral value of any sibling primitive field marked + * `outputNameField` in the schema (e.g. a router route's `label`). + * Convenience surface so adapters don't reach into `properties` by a + * hardcoded field name. Undefined when no such field is present. + */ + outputName?: string; + /** + * Source range of the AST element that defines this edge: + * - For `transitionTarget` ref edges, the MemberExpression value + * (`@namespace.name`). + * - For `transitionContainer` and trigger edges, the ToClause that + * introduces the target (`to @namespace.name`). + * Undefined when the originating CST node has no range attached. + */ + lexicalRange?: Range; +} + +export interface ExtractedGraph { + /** + * Every entry produced by the extractor — both transition-target + * blocks (regular graph nodes) and entry-point blocks (triggers). + * Both share the same `GraphNode` shape; consumers identify + * triggers structurally (no incoming edges) rather than by inspecting + * `blockKind`. Edges from a trigger entry are still tagged + * `via: 'trigger'` for debugging convenience. + */ + nodes: GraphNode[]; + edges: GraphEdge[]; +} + +// --------------------------------------------------------------------------- +// Local guards +// --------------------------------------------------------------------------- + +function isAstNodeLike(value: unknown): value is AstNodeLike { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Extract the parser-attached source range from any AST node, when present. + * Synthetic / error-recovery nodes may lack `__cst`, so callers must treat + * the result as optional. + */ +function rangeOf(node: unknown): Range | undefined { + if (!isAstNodeLike(node)) return undefined; + return node.__cst?.range; +} + +// --------------------------------------------------------------------------- +// Schema introspection helpers +// --------------------------------------------------------------------------- + +/** Resolve a possibly-array-wrapped schema entry to a single FieldType. */ +function resolveFieldType(ft: FieldType | FieldType[]): FieldType { + return Array.isArray(ft) ? ft[0] : ft; +} + +interface ResolvedEntryBlock { + kind: string; + schema: Schema; + capabilities: readonly string[]; +} + +/** + * For a top-level schema field (e.g. `generator: NamedCollectionBlock(...)`), + * resolve the entry block factory whose `capabilities` and `schema` describe + * each instance. Returns undefined when the field is not a collection. + */ +function resolveEntryBlock( + fieldType: FieldType +): ResolvedEntryBlock | undefined { + if (!isCollectionFieldType(fieldType)) return undefined; + const entry = fieldType.entryBlock; + return { + kind: entry.kind, + schema: entry.schema, + capabilities: + (entry as { capabilities?: readonly string[] }).capabilities ?? [], + }; +} + +function declaresTransitionTarget(capabilities: readonly string[]): boolean { + return capabilities.includes('transitionTarget'); +} + +/** + * Walk a block schema and report whether it (or any nested block) owns a + * `transitionContainer` field. Used to discover trigger-like blocks. + */ +function schemaContainsTransitionContainer(schema: Schema): boolean { + for (const rawFt of Object.values(schema)) { + const ft = resolveFieldType(rawFt); + if (ft.__metadata?.transitionContainer === true) return true; + // Recurse into nested block schemas — keeps discovery schema-driven. + if (ft.schema && schemaContainsTransitionContainer(ft.schema)) return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// Sibling primitive collection +// --------------------------------------------------------------------------- + +/** + * Extract the string value of a `StringLiteral` AST node, or undefined + * when the value isn't a string literal. Used for human-facing display + * text (e.g. node `label`, `description`) where we want the literal + * payload, not the surrounding quotes. + */ +function stringLiteralText(value: AstNodeLike): string | undefined { + if ((value as { __kind?: string }).__kind !== 'StringLiteral') + return undefined; + const literal = (value as { value?: unknown }).value; + return typeof literal === 'string' ? literal : undefined; +} + +/** + * Extract the source text of any primitive AST node — string literals + * use their unwrapped value, everything else (expressions, identifiers, + * references) falls back to `__cst.node.text` so the surfaced text + * matches what the user wrote. Schema-driven: callers walk Primitive + * fields by `__fieldKind`; this helper has no opinion on which `__kind` + * values are acceptable. + */ +function primitiveSourceText(value: AstNodeLike): string | undefined { + const literal = stringLiteralText(value); + if (literal !== undefined) return literal; + return (value as { __cst?: { node?: { text?: string } } }).__cst?.node?.text; +} + +/** + * Walk every Primitive field declared in `schema`, applying `extract` + * to the matching value on `instance`. Skips `exceptField` and any + * value that isn't an AstNodeLike (e.g. unparsed/synthetic fields). + * Returns undefined when nothing qualifies so callers don't emit empty + * `{}` bags. + */ +function collectSiblingProps( + instance: AstNodeLike, + schema: Schema, + extract: (value: AstNodeLike) => string | undefined, + exceptField?: string +): Record | undefined { + let props: Record | undefined; + for (const [fieldName, rawFt] of Object.entries(schema)) { + if (fieldName === exceptField) continue; + const ft = resolveFieldType(rawFt); + if (ft.__fieldKind !== 'Primitive') continue; + const value = instance[fieldName]; + if (!isAstNodeLike(value)) continue; + const text = extract(value); + if (text === undefined) continue; + if (!props) props = {}; + props[fieldName] = text; + } + return props; +} + +/** Boolean `FieldMetadata` markers that the graph extractor consults. */ +type GraphFieldMarker = + | 'predicateField' + | 'outputNameField' + | 'displayLabelField'; + +/** + * Find the first primitive sibling on `instance` whose schema field + * carries the requested boolean metadata marker (e.g. `predicateField`, + * `outputNameField`), and return its extracted text. Returns undefined + * when no field is marked, or the marked field's value is missing / + * unparsed. Schema-driven: callers never reference field names directly. + */ +function markedFieldText( + instance: AstNodeLike, + schema: Schema, + marker: GraphFieldMarker, + extract: (value: AstNodeLike) => string | undefined +): string | undefined { + for (const [fieldName, rawFt] of Object.entries(schema)) { + const ft = resolveFieldType(rawFt); + if (ft.__metadata?.[marker] !== true) continue; + const value = instance[fieldName]; + if (!isAstNodeLike(value)) continue; + return extract(value); + } + return undefined; +} + +// --------------------------------------------------------------------------- +// Member-expression → qualified id +// --------------------------------------------------------------------------- + +/** + * Resolve an Expression that should reference a graph node into its + * qualified id (`namespace.name`). Returns undefined when the expression + * isn't a `@namespace.name` MemberExpression. + */ +function qualifiedIdOf(expr: unknown): string | undefined { + const decomposed = decomposeAtMemberExpression(expr); + if (!decomposed) return undefined; + return `${decomposed.namespace}.${decomposed.property}`; +} + +// --------------------------------------------------------------------------- +// Edge discovery within a parsed entry instance +// --------------------------------------------------------------------------- + +/** + * Discover all transition edges originating at `instance` by walking its + * schema. Recurses into nested blocks (e.g. router routes/otherwise) so + * that field markers anywhere in the subtree are honoured. + * + * @param fromId Qualified id of the source node (the entry whose outgoing + * edges are being discovered). + * @param instance The parsed entry instance whose fields are walked. + * @param schema The block schema describing `instance` (the field-type + * metadata is the source of truth for marker discovery). + * @param triggerOverride When provided, every discovered edge is tagged + * with this provenance instead of the field-derived label. This is + * how trigger-rooted edges keep their `'trigger'` label even when + * the underlying field is a `transitionContainer`. + */ +function collectOutgoingEdges( + fromId: string, + instance: AstNodeLike, + schema: Schema, + triggerOverride?: EdgeProvenance +): GraphEdge[] { + const edges: GraphEdge[] = []; + + for (const [fieldName, rawFt] of Object.entries(schema)) { + const ft = resolveFieldType(rawFt); + const value = instance[fieldName]; + + // Field-level marker #1: transitionContainer ProcedureValue. + if (ft.__metadata?.transitionContainer === true) { + const via: EdgeProvenance = triggerOverride ?? 'transitionContainer'; + edges.push(...edgesFromTransitionContainer(fromId, value, via)); + continue; + } + + // Field-level marker #2: resolvedType === 'transitionTarget'. + if (ft.__metadata?.constraints?.resolvedType === 'transitionTarget') { + const targetId = qualifiedIdOf(value); + if (targetId) { + const via: EdgeProvenance = triggerOverride ?? 'transitionTarget'; + // Capture sibling primitives on the same parent instance — string + // literals contribute their unwrapped value, expressions contribute + // their source text. Schema-driven: every Primitive sibling field is + // a candidate, no field name is hardcoded. + const properties = collectSiblingProps( + instance, + schema, + primitiveSourceText, + fieldName + ); + const predicate = markedFieldText( + instance, + schema, + 'predicateField', + primitiveSourceText + ); + const outputName = markedFieldText( + instance, + schema, + 'outputNameField', + stringLiteralText + ); + const lexicalRange = rangeOf(value); + edges.push({ + from: fromId, + to: targetId, + via, + ...(properties ? { properties } : {}), + ...(predicate !== undefined ? { predicate } : {}), + ...(outputName !== undefined ? { outputName } : {}), + ...(lexicalRange ? { lexicalRange } : {}), + }); + } + continue; + } + + // Recurse into nested structure when the field is itself a Block / + // Sequence / Collection of blocks. This is what lets router routes (a + // Sequence of RouterRouteBlock) and otherwise (a single block) be + // discovered without ever naming "routes" or "otherwise". + if (value === undefined || value === null) continue; + if (ft.schema) { + edges.push( + ...recurseIntoNested(fromId, value, ft, ft.schema, triggerOverride) + ); + } + } + + return edges; +} + +/** + * Walk a single ProcedureValue's statements, finding TransitionStatement + * → ToClause targets and producing one edge per resolved target. + */ +function edgesFromTransitionContainer( + fromId: string, + procedureValue: unknown, + via: EdgeProvenance +): GraphEdge[] { + const edges: GraphEdge[] = []; + if (!isAstNodeLike(procedureValue)) return edges; + const statements = procedureValue.statements; + if (!Array.isArray(statements)) return edges; + + for (const stmt of statements) { + if (!isAstNodeLike(stmt)) continue; + if (stmt.__kind !== 'TransitionStatement') continue; + const clauses = stmt.clauses; + if (!Array.isArray(clauses)) continue; + for (const clause of clauses) { + if (!isAstNodeLike(clause)) continue; + if (clause.__kind !== 'ToClause') continue; + const targetId = qualifiedIdOf(clause.target); + if (!targetId) continue; + const lexicalRange = rangeOf(clause); + edges.push({ + from: fromId, + to: targetId, + via, + ...(lexicalRange ? { lexicalRange } : {}), + }); + } + } + return edges; +} + +/** + * Recurse from a non-leaf field value (Block / Sequence / Collection) into + * its inner schema to keep collecting edges. Handles three cases: + * 1. Sequence of blocks — iterate `items`, treating each as an instance. + * 2. Collection (NamedMap of blocks) — iterate values. + * 3. Plain nested Block — recurse into it directly. + */ +function recurseIntoNested( + fromId: string, + value: unknown, + fieldType: FieldType, + innerSchema: Schema, + triggerOverride?: EdgeProvenance +): GraphEdge[] { + const edges: GraphEdge[] = []; + + if (value instanceof SequenceNode) { + for (const item of value.items) { + if (isAstNodeLike(item)) { + edges.push( + ...collectOutgoingEdges(fromId, item, innerSchema, triggerOverride) + ); + } + } + return edges; + } + + if (isCollectionFieldType(fieldType) && isNamedMap(value)) { + for (const [, entry] of value as Iterable<[string, unknown]>) { + if (isAstNodeLike(entry)) { + edges.push( + ...collectOutgoingEdges(fromId, entry, innerSchema, triggerOverride) + ); + } + } + return edges; + } + + // Plain nested Block — single instance. + if (isAstNodeLike(value) && !(value instanceof SequenceNode)) { + edges.push( + ...collectOutgoingEdges(fromId, value, innerSchema, triggerOverride) + ); + } + + return edges; +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +/** + * Build a `{ nodes, edges }` graph from a parsed document, driven entirely + * by schema metadata. Adding a new node kind (or a new transition field) + * requires zero changes here as long as the schema is correctly tagged + * with `'transitionTarget'` capability and `transitionContainer` / + * `resolvedType: 'transitionTarget'` field markers. + * + * Triggers and graph nodes share the same `GraphNode` shape — the only + * difference inside the extractor is that a trigger entry's block + * doesn't declare `'transitionTarget'`. Consumers identify triggers + * structurally (no incoming edges); the `via: 'trigger'` provenance on + * outgoing edges is retained as a debugging aid only. + */ +export function extractGraph( + parsed: AstNodeLike, + schemaInfo: SchemaInfo +): ExtractedGraph { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + + for (const [namespace, rawFt] of Object.entries(schemaInfo.schema)) { + const fieldType = resolveFieldType(rawFt); + + // Limit to NamedCollections so each instance has a stable __name we can + // use as the qualified-id suffix. + if (!isNamedCollectionFieldType(fieldType)) continue; + + const entry = resolveEntryBlock(fieldType); + if (!entry) continue; + + const isGraphNode = declaresTransitionTarget(entry.capabilities); + const isTrigger = + !isGraphNode && schemaContainsTransitionContainer(entry.schema); + if (!isGraphNode && !isTrigger) continue; + + const collection = parsed[namespace]; + if (!isNamedMap(collection)) continue; + + for (const [name, instance] of collection as Iterable<[string, unknown]>) { + if (!isAstNodeLike(instance)) continue; + const id = `${namespace}.${name}`; + + // Top-level string-literal fields on the entry block (e.g. label, + // description). Intentionally only inspects the entry's own schema — + // we do NOT recurse into nested blocks here, so a router's per-route + // labels never bubble up to the router node itself. + const properties = collectSiblingProps( + instance, + entry.schema, + stringLiteralText + ); + const label = markedFieldText( + instance, + entry.schema, + 'displayLabelField', + stringLiteralText + ); + const lexicalRange = rangeOf(instance); + + nodes.push({ + id, + namespace, + name, + blockKind: entry.kind, + ...(properties ? { properties } : {}), + ...(label !== undefined ? { label } : {}), + ...(lexicalRange ? { lexicalRange } : {}), + }); + edges.push( + ...collectOutgoingEdges( + id, + instance, + entry.schema, + isTrigger ? 'trigger' : undefined + ) + ); + } + } + + return { nodes, edges }; +} diff --git a/dialect/agentfabric/src/graph/get-graph.test.ts b/dialect/agentfabric/src/graph/get-graph.test.ts new file mode 100644 index 00000000..279772ae --- /dev/null +++ b/dialect/agentfabric/src/graph/get-graph.test.ts @@ -0,0 +1,375 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { + Block, + ExpressionValue, + NamedBlock, + NamedCollectionBlock, + ProcedureValue, + ReferenceValue, + Sequence, + StringValue, + SymbolKind, +} from '@agentscript/language'; +import type { SchemaInfo } from '@agentscript/language'; +import { parseDocument, parseWithSchema } from '../tests/test-utils.js'; +import { getGraph } from './get-graph.js'; +import type { Graph, ProtocolNode, ProtocolEdge } from './get-graph.js'; + +const FIXTURE_PATH = resolve( + dirname(fileURLToPath(import.meta.url)), + '../tests/resources/it-help-investigation.agent' +); +const GOLDEN_PATH = resolve( + dirname(fileURLToPath(import.meta.url)), + '../tests/resources/it-help-investigation.graph.json' +); + +function findNode(graph: Graph, id: string): ProtocolNode | undefined { + return graph.nodes.find(n => n.id === id); +} + +function findEdge( + graph: Graph, + from: string, + to: string +): ProtocolEdge | undefined { + return graph.edges.find(e => e.from === from && e.to === to); +} + +describe('getGraph (agentfabric protocol adapter)', () => { + const source = readFileSync(FIXTURE_PATH, 'utf8'); + const parsed = parseDocument(source); + const graph = getGraph(parsed); + + it('emits the trigger as a protocol node with kind: trigger', () => { + const trigger = findNode(graph, 'trigger.ticketTrigger'); + expect(trigger).toBeDefined(); + expect(trigger?.kind).toBe('trigger'); + }); + + it('emits a router with kind: router and a populated outputs string', () => { + const router = findNode(graph, 'router.severityRouter'); + expect(router?.kind).toBe('router'); + // severityRouter has one route ("High") + otherwise. + const outputs = router?.additionalProperties?.outputs; + expect(outputs).toBeDefined(); + const tokens = outputs!.split(', '); + expect(tokens).toContain('High'); + expect(tokens).toContain('otherwise'); + }); + + it('multi-route router preserves encounter order in outputs', () => { + const router = findNode(graph, 'router.resolutionRouter'); + expect(router?.kind).toBe('router'); + // Two routes ("License Given", "Unresolved") followed by otherwise. + expect(router?.additionalProperties?.outputs).toBe( + 'License Given, Unresolved, otherwise' + ); + }); + + it('uses the schema namespace as kind for each non-trigger node', () => { + const cases: Array<[string, string]> = [ + ['echo.escalationResponse', 'echo'], + ['executor.escalateTicket', 'executor'], + ['generator.classifySeverity', 'generator'], + ['orchestrator.crossPlatformTriage', 'orchestrator'], + ['router.severityRouter', 'router'], + ]; + for (const [id, expectedKind] of cases) { + expect(findNode(graph, id)?.kind, `kind for ${id}`).toBe(expectedKind); + } + }); + + it('does not duplicate kind under additionalProperties', () => { + for (const node of graph.nodes) { + expect(node.additionalProperties?.kind).toBeUndefined(); + } + }); + + it('surfaces the AST label as additionalProperties.label', () => { + const generator = findNode(graph, 'generator.classifySeverity'); + expect(generator?.additionalProperties?.label).toBe('Classify Severity'); + }); + + it('routes from a router carry additionalProperties.output (route label) and predicate (when expression)', () => { + const edge = findEdge( + graph, + 'router.severityRouter', + 'executor.escalateTicket' + ); + expect(edge).toBeDefined(); + expect(edge?.additionalProperties?.output).toBe('High'); + expect(edge?.additionalProperties?.predicate).toBe( + '@generator.classifySeverity.output.severity == "high"' + ); + }); + + it('otherwise edges from a router carry output: "otherwise" and no predicate', () => { + const edge = findEdge( + graph, + 'router.severityRouter', + 'orchestrator.crossPlatformTriage' + ); + expect(edge).toBeDefined(); + expect(edge?.additionalProperties?.output).toBe('otherwise'); + expect(edge?.additionalProperties?.predicate).toBeUndefined(); + }); + + it('non-router edges only expose lexical positions, no output', () => { + const edge = findEdge( + graph, + 'generator.classifySeverity', + 'router.severityRouter' + ); + expect(edge).toBeDefined(); + expect(edge?.additionalProperties?.output).toBeUndefined(); + expect( + edge?.additionalProperties?.['lexical-start-position'] + ).toBeDefined(); + expect(edge?.additionalProperties?.['lexical-end-position']).toBeDefined(); + }); + + it('never emits additionalProperties.label on any edge', () => { + for (const edge of graph.edges) { + expect(edge.additionalProperties?.label).toBeUndefined(); + } + }); + + it('only emits a predicate on edges whose source declared a predicateField sibling', () => { + // Router routes (RouterRouteBlock has `when` marked predicateField) → + // predicate present. Otherwise (RouterOtherwiseBlock has no `when`) + + // every transitionContainer-sourced edge → predicate absent. + for (const edge of graph.edges) { + const isRouterRoute = edge.additionalProperties?.predicate !== undefined; + if (isRouterRoute) { + const isOtherwise = edge.additionalProperties?.output === 'otherwise'; + expect(isOtherwise).toBe(false); + } + } + }); + + it('emits "line,character" lexical-{start,end}-position pairs for every node and edge', () => { + // Both halves of the range encoded as 0-indexed `"line,character"` per + // the protocol (e.g. "10,2"). Both must appear together on any item + // that exposes either. + const positionPattern = /^\d+,\d+$/; + for (const item of [...graph.nodes, ...graph.edges]) { + const start = item.additionalProperties?.['lexical-start-position']; + const end = item.additionalProperties?.['lexical-end-position']; + expect(start).toBeDefined(); + expect(end).toBeDefined(); + expect(start).toMatch(positionPattern); + expect(end).toMatch(positionPattern); + } + }); + + // ── Golden snapshot ────────────────────────────────────────────────── + // End-to-end check that the it-help-investigation fixture produces the + // exact protocol Graph we expect. Update the snapshot with + // `vitest run -u` after intentional schema/extractor changes. + it('matches the golden protocol Graph for the it-help-investigation fixture', async () => { + await expect(JSON.stringify(graph, null, 2) + '\n').toMatchFileSnapshot( + GOLDEN_PATH + ); + }); +}); + +// --------------------------------------------------------------------------- +// Schema-extensibility — proves getGraph is dialect-agnostic +// --------------------------------------------------------------------------- + +describe('getGraph (schema extensibility)', () => { + /** + * Synthetic dialect with one trigger kind and one branching node kind. + * The branching block embeds a Sequence of routes (`when` predicate + + * target reference) plus an `otherwise` fallback — the same shape a + * router takes in any dialect, but using the names `start`, `branch`, + * `route`, and `cond` to confirm `getGraph` doesn't latch onto + * agentfabric-specific identifiers. + */ + const SyntheticTriggerBlock = NamedBlock('SyntheticTriggerBlock', { + on_message: ProcedureValue.describe('On message procedure.') + .required() + .transitionContainer(), + }).describe('Synthetic trigger.'); + + const RouteBlock = Block('RouteBlock', { + target: ReferenceValue.describe('Route target.') + .allowedNamespaces(['branch']) + .resolvedType('transitionTarget') + .required(), + cond: ExpressionValue.describe('Route condition.') + .required() + .predicateField(), + label: StringValue.describe('Route label.').outputNameField(), + }); + + const OtherwiseBlock = Block('OtherwiseBlock', { + target: ReferenceValue.describe('Default route target.') + .allowedNamespaces(['branch']) + .resolvedType('transitionTarget') + .required(), + }); + + const BranchBlock = NamedBlock( + 'BranchBlock', + { + label: StringValue.describe('Branch label.').displayLabelField(), + routes: Sequence(RouteBlock).describe('Conditional routes.'), + otherwise: OtherwiseBlock.describe('Fallback route.'), + }, + { + capabilities: ['transitionTarget'], + symbol: { kind: SymbolKind.Namespace }, + } + ); + + const LeafBlock = NamedBlock( + 'LeafBlock', + { + label: StringValue.describe('Leaf label.').displayLabelField(), + }, + { + capabilities: ['transitionTarget'], + symbol: { kind: SymbolKind.Namespace }, + } + ); + + const SyntheticSchema = { + start: NamedCollectionBlock(SyntheticTriggerBlock), + branch: NamedCollectionBlock(BranchBlock), + leaf: NamedCollectionBlock(LeafBlock), + }; + + const SyntheticSchemaInfo: SchemaInfo = { + schema: SyntheticSchema, + aliases: {}, + }; + + const source = ` +start kickoff: + on_message: -> + transition to @branch.choose + +branch choose: + label: "choose" + routes: + - target: @leaf.first + cond: @x == 1 + label: "is one" + - target: @leaf.second + cond: @x == 2 + label: "is two" + otherwise: + target: @leaf.fallback + +leaf first: + label: "first" + +leaf second: + label: "second" + +leaf fallback: + label: "fallback" +`; + + const parsed = parseWithSchema(source, SyntheticSchema); + const graph = getGraph(parsed, SyntheticSchemaInfo); + + it('uses the schema namespace as kind for every node, including the trigger', () => { + // The trigger lives under `start` — its kind comes from that namespace, + // not from a hardcoded `'trigger'` literal in the adapter. + expect(graph.nodes.find(n => n.id === 'start.kickoff')?.kind).toBe('start'); + expect(graph.nodes.find(n => n.id === 'branch.choose')?.kind).toBe( + 'branch' + ); + expect(graph.nodes.find(n => n.id === 'leaf.first')?.kind).toBe('leaf'); + }); + + it('emits per-route edges with predicate sourced from any predicateField', () => { + const firstRoute = graph.edges.find( + e => e.from === 'branch.choose' && e.to === 'leaf.first' + ); + expect(firstRoute?.additionalProperties?.output).toBe('is one'); + expect(firstRoute?.additionalProperties?.predicate).toBe('@x == 1'); + + const secondRoute = graph.edges.find( + e => e.from === 'branch.choose' && e.to === 'leaf.second' + ); + expect(secondRoute?.additionalProperties?.predicate).toBe('@x == 2'); + + const otherwise = graph.edges.find( + e => e.from === 'branch.choose' && e.to === 'leaf.fallback' + ); + expect(otherwise?.additionalProperties?.output).toBe('otherwise'); + expect(otherwise?.additionalProperties?.predicate).toBeUndefined(); + }); + + it('summarises every router-style node via `outputs` regardless of dialect', () => { + const branch = graph.nodes.find(n => n.id === 'branch.choose'); + expect(branch?.additionalProperties?.outputs).toBe( + 'is one, is two, otherwise' + ); + }); + + it('surfaces displayLabelField as additionalProperties.label', () => { + expect( + graph.nodes.find(n => n.id === 'branch.choose')?.additionalProperties + ?.label + ).toBe('choose'); + expect( + graph.nodes.find(n => n.id === 'leaf.first')?.additionalProperties?.label + ).toBe('first'); + }); + + it('does not duplicate kind under additionalProperties', () => { + for (const node of graph.nodes) { + expect(node.additionalProperties?.kind).toBeUndefined(); + } + }); + + it('escapes commas in route labels when joining the outputs string', () => { + // A route label containing a literal comma would otherwise split into + // two phantom router rows on the consumer side, since `outputs` is + // joined with `', '`. The dialect emits `\,` for embedded commas. + const sourceWithComma = ` +start kickoff: + on_message: -> + transition to @branch.choose + +branch choose: + label: "choose" + routes: + - target: @leaf.first + cond: @x == 1 + label: "yes, sir" + otherwise: + target: @leaf.fallback + +leaf first: + label: "first" + +leaf fallback: + label: "fallback" +`; + const parsedComma = parseWithSchema(sourceWithComma, SyntheticSchema); + const graphComma = getGraph(parsedComma, SyntheticSchemaInfo); + const branch = graphComma.nodes.find(n => n.id === 'branch.choose'); + // The comma in the label is escaped so the consumer can recover the + // single label `yes, sir` instead of seeing `yes` and `sir`. + expect(branch?.additionalProperties?.outputs).toBe('yes\\, sir, otherwise'); + + // Per-edge `output` is left unescaped so the UI's + // `routerOutputHandleId(edge.output)` produces a handle id that + // matches the per-row handle the router renders after parsing the + // (escaped) `outputs` summary. If a future change starts escaping + // both, this asserts the round-trip still lands on `'yes, sir'`. + const route = graphComma.edges.find( + e => e.from === 'branch.choose' && e.to === 'leaf.first' + ); + expect(route?.additionalProperties?.output).toBe('yes, sir'); + }); +}); diff --git a/dialect/agentfabric/src/graph/get-graph.ts b/dialect/agentfabric/src/graph/get-graph.ts new file mode 100644 index 00000000..55a88755 --- /dev/null +++ b/dialect/agentfabric/src/graph/get-graph.ts @@ -0,0 +1,205 @@ +/** + * Public adapter that translates an agentfabric `ParsedDocument` into the + * agent-graph protocol shape consumed by the VS Code agent-graph canvas. + * + * The protocol is documented at + * `~/mulesoft/poc-graph/agent-graph-protocol.md`. Each node has a + * first-class `kind` plus an open `additionalProperties: Record` bag; each edge has only the bag. This file is the boundary + * between the schema-driven internal extractor (which exposes provenance, + * sibling primitive props, and predicates) and that protocol shape. + * + * All needed information comes from `extractGraph` — this adapter never + * re-walks the AST and never names dialect-specific blocks or fields. + */ +import { extractGraph } from './extractor.js'; +import type { GraphEdge, GraphNode } from './extractor.js'; +import { AgentFabricSchemaInfo } from '../schema.js'; +import type { ParsedDocument } from '../index.js'; +import type { AstNodeLike, SchemaInfo } from '@agentscript/language'; + +// --------------------------------------------------------------------------- +// Protocol types — match ~/mulesoft/poc-graph/agent-graph-protocol.md +// --------------------------------------------------------------------------- + +export interface ProtocolNode { + id: string; + /** + * Specific node type — set to the schema namespace of the entry block + * (e.g. `'trigger'`, `'router'`, `'echo'`, `'orchestrator'`, + * `'executor'`, `'generator'`, `'subagent'`). Open set — consumers + * must treat unknown values as a generic node and may infer structural + * roles (entry, leaf, router) from edge topology. + */ + kind: string; + additionalProperties?: Record; +} + +export interface ProtocolEdge { + from: string; + to: string; + additionalProperties?: Record; +} + +export interface Graph { + nodes: ProtocolNode[]; + edges: ProtocolEdge[]; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Compute the protocol `output` value for a single transition-target + * edge. Uses the extractor's schema-driven `outputName` (sourced from + * any `outputNameField`-marked sibling) when present; falls back to the + * synthesized `"otherwise"` per the protocol's known-keys table for + * routed edges that lack a named output (e.g. a router's default + * branch). Returns undefined for edges whose provenance isn't + * `transitionTarget` (those have no named output). + */ +function outputForRouterEdge(edge: GraphEdge): string | undefined { + if (edge.via !== 'transitionTarget') return undefined; + return edge.outputName ?? 'otherwise'; +} + +/** + * Escape commas (and the escape character itself) in a single output name + * so it survives round-tripping through the comma-separated `outputs` + * list. + * + * Wire grammar (must stay in sync with the UI's `parseProtocolOutputs` + * in `apps/ui/src/lib/agentfabric-graph.ts`): + * - `\` → `\\` + * - `,` → `\,` + * - all other characters pass through verbatim + * Order matters — backslashes are escaped first so a literal `\` in the + * source doesn't become an escape prefix. Per-edge `output` is left + * unescaped; only the joined `outputs` summary string uses this grammar. + */ +function escapeOutputForJoin(output: string): string { + return output.replace(/\\/g, '\\\\').replace(/,/g, '\\,'); +} + +/** + * Aggregate the named outputs across a router's outgoing edges, + * preserving encounter order and de-duplicating. Used to populate the + * `outputs` known-key per the protocol example (`"opt1, opt2, otherwise"`). + * + * Each output is escaped before joining so a literal comma in a route + * label (e.g. `label: "yes, sir"`) does not split into two phantom + * router rows on the consumer side. + */ +function collectRouterOutputs(edges: GraphEdge[]): string { + const seen = new Set(); + for (const edge of edges) { + const output = outputForRouterEdge(edge); + if (output !== undefined) seen.add(output); + } + return [...seen].map(escapeOutputForJoin).join(', '); +} + +/** + * Strip undefined entries; return undefined when the resulting bag would + * be empty so we don't emit `additionalProperties: {}`. + */ +function buildAdditionalProperties( + entries: Record +): Record | undefined { + let bag: Record | undefined; + for (const [key, value] of Object.entries(entries)) { + if (typeof value !== 'string') continue; + if (!bag) bag = {}; + bag[key] = value; + } + return bag; +} + +/** + * Build the protocol's `lexical-start-position` / `lexical-end-position` + * additionalProperties from an internal `Range`. Both positions are + * encoded as `"line,character"` strings (0-indexed) per the protocol. + * Returns an empty object when the range is undefined so callers can + * spread it unconditionally. + */ +function rangePositionProps( + range: GraphNode['lexicalRange'] +): Record { + if (!range) return {}; + return { + 'lexical-start-position': `${range.start.line},${range.start.character}`, + 'lexical-end-position': `${range.end.line},${range.end.character}`, + }; +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +/** + * Translate a parsed document into the protocol `Graph` shape, using + * any `SchemaInfo` (defaults to AgentFabric's). Each `extracted.nodes` + * entry becomes a `ProtocolNode` whose `kind` mirrors the schema + * namespace; each `extracted.edges` entry becomes a `ProtocolEdge` + * with output/predicate/range surfaced as `additionalProperties`. + * Consumers infer structural roles (entry/leaf/router) from edge + * topology. + */ +export function getGraph( + parsed: ParsedDocument | AstNodeLike, + schemaInfo: SchemaInfo = AgentFabricSchemaInfo +): Graph { + const extracted = extractGraph(parsed, schemaInfo); + + // Pre-bucket edges by source so per-router output collection is O(E) + // and we can ask "does this node have any outgoing routes?" cheaply. + const edgesByFrom = new Map(); + for (const edge of extracted.edges) { + const list = edgesByFrom.get(edge.from); + if (list) list.push(edge); + else edgesByFrom.set(edge.from, [edge]); + } + + // A node is "route-emitting" if any of its outgoing edges has a named + // output (i.e. came from a router-style transitionTarget). The protocol + // exposes the `outputs` summary only on those. + function hasRouterOutputs(nodeId: string): boolean { + const edges = edgesByFrom.get(nodeId); + if (!edges) return false; + return edges.some(e => outputForRouterEdge(e) !== undefined); + } + + const nodes: ProtocolNode[] = extracted.nodes.map(node => { + const outputs = hasRouterOutputs(node.id) + ? collectRouterOutputs(edgesByFrom.get(node.id) ?? []) + : undefined; + + const additionalProperties = buildAdditionalProperties({ + label: node.label, + outputs, + ...rangePositionProps(node.lexicalRange), + }); + + return { + id: node.id, + kind: node.namespace, + ...(additionalProperties ? { additionalProperties } : {}), + }; + }); + + const edges: ProtocolEdge[] = extracted.edges.map(edge => { + const additionalProperties = buildAdditionalProperties({ + output: outputForRouterEdge(edge), + predicate: edge.predicate, + ...rangePositionProps(edge.lexicalRange), + }); + return { + from: edge.from, + to: edge.to, + ...(additionalProperties ? { additionalProperties } : {}), + }; + }); + + return { nodes, edges }; +} diff --git a/dialect/agentfabric/src/graph/index.ts b/dialect/agentfabric/src/graph/index.ts new file mode 100644 index 00000000..2686544f --- /dev/null +++ b/dialect/agentfabric/src/graph/index.ts @@ -0,0 +1,10 @@ +export { extractGraph } from './extractor.js'; +export type { + GraphNode, + GraphEdge, + ExtractedGraph, + EdgeProvenance, +} from './extractor.js'; + +export { getGraph } from './get-graph.js'; +export type { Graph, ProtocolNode, ProtocolEdge } from './get-graph.js'; diff --git a/dialect/agentfabric/src/index.ts b/dialect/agentfabric/src/index.ts index fbf71b54..a13f3283 100644 --- a/dialect/agentfabric/src/index.ts +++ b/dialect/agentfabric/src/index.ts @@ -87,6 +87,19 @@ export type { export { defaultRules } from './lint/passes/index.js'; export { createLintEngine } from './lint/index.js'; +// ── Graph re-exports ───────────────────────────────────────────────── + +export { extractGraph, getGraph } from './graph/index.js'; +export type { + GraphNode, + GraphEdge, + ExtractedGraph, + EdgeProvenance, + Graph, + ProtocolNode, + ProtocolEdge, +} from './graph/index.js'; + // ── Dialect config ────────────────────────────────────────────────── export const agentfabricDialect: DialectConfig = { diff --git a/dialect/agentfabric/src/lint/index.ts b/dialect/agentfabric/src/lint/index.ts index aaaeb21c..649865b1 100644 --- a/dialect/agentfabric/src/lint/index.ts +++ b/dialect/agentfabric/src/lint/index.ts @@ -7,11 +7,10 @@ import { LintEngine } from '@agentscript/language'; import { defaultRules } from './passes/index.js'; +import { AGENTFABRIC_LINT_SOURCE } from './passes/rules/shared.js'; export { defaultRules } from './passes/index.js'; -const AGENTFABRIC_LINT_SOURCE = 'agentfabric-lint'; - /** Create a LintEngine pre-loaded with all default AgentFabric rules. */ export function createLintEngine(): LintEngine { return new LintEngine({ diff --git a/dialect/agentfabric/src/lint/passes/agentfabric-semantic.ts b/dialect/agentfabric/src/lint/passes/agentfabric-semantic.ts index 3a7cd95e..0ad9ab91 100644 --- a/dialect/agentfabric/src/lint/passes/agentfabric-semantic.ts +++ b/dialect/agentfabric/src/lint/passes/agentfabric-semantic.ts @@ -7,9 +7,12 @@ import { storeKey } from '@agentscript/language'; import type { LintPass, PassStore } from '@agentscript/language'; +import { checkActionBindingRules } from './rules/action-binding-rules.js'; import { checkAgenticLlmRules } from './rules/agentic-llm-rules.js'; import { checkConnectionUriRules } from './rules/connection-rules.js'; +import { checkCycleRules } from './rules/cycle-rules.js'; import { checkEchoRules } from './rules/echo-rules.js'; +import { checkExecuteRules } from './rules/execute-rules.js'; import { checkOnExitRules } from './rules/on-exit-rules.js'; import { checkOutputStructureRules } from './rules/output-structure-rules.js'; import { checkReasoningInstructionsRules } from './rules/reasoning-instructions-rules.js'; @@ -29,6 +32,9 @@ class AgentFabricSemanticPass implements LintPass { checkSwitchRules(store, root); checkEchoRules(root); checkAgenticLlmRules(root); + checkExecuteRules(root); + checkActionBindingRules(root); + checkCycleRules(root); } } diff --git a/dialect/agentfabric/src/lint/passes/index.ts b/dialect/agentfabric/src/lint/passes/index.ts index a3c377b4..7a9e5621 100644 --- a/dialect/agentfabric/src/lint/passes/index.ts +++ b/dialect/agentfabric/src/lint/passes/index.ts @@ -18,10 +18,13 @@ import { emptyBlockPass, expressionValidationPass, spreadContextPass, + unusedVariablePass, } from '@agentscript/language'; import { agentFabricSemanticPass } from './agentfabric-semantic.js'; +import { strictSchemaValidationPass } from './strict-schema-validation.js'; import { suppressActionsNamespaceUndefinedReferencePass } from './suppress-tools-namespace-undefined-reference.js'; import { spreadOperandTypePass } from './spread-operand-type.js'; +import { unusedNodePass } from './rules/unused-node.js'; import type { ExpressionValidationOptions } from '@agentscript/language/lint'; import { AgentFabricSchemaInfo } from '../../schema.js'; @@ -58,11 +61,14 @@ export function defaultRules(): LintPass[] { constraintValidationPass(), positionIndexPass(), unreachableCodePass(), + unusedVariablePass(), emptyBlockPass(), expressionValidationPass(expressionOptions), spreadContextPass(), spreadOperandTypePass(), agentFabricSemanticPass(), + unusedNodePass(), + strictSchemaValidationPass(), // Validation undefinedReferencePass(), suppressActionsNamespaceUndefinedReferencePass(), diff --git a/dialect/agentfabric/src/lint/passes/rules/action-binding-rules.ts b/dialect/agentfabric/src/lint/passes/rules/action-binding-rules.ts new file mode 100644 index 00000000..8c5dfd24 --- /dev/null +++ b/dialect/agentfabric/src/lint/passes/rules/action-binding-rules.ts @@ -0,0 +1,120 @@ +/** + * Lint rules for action bindings inside agentic nodes (orchestrator, + * subagent, generator). + * + * Each agentic node may declare `reasoning.actions` that bind to + * top-level `actions` definitions. This module validates that every + * `with ` clause in those bindings references a parameter + * that is either explicitly declared as an input on the referenced + * action def or is an implicit parameter (e.g. `message`). + */ + +import { + Ellipsis, + WithClause, + decomposeAtMemberExpression, + isNamedMap, +} from '@agentscript/language'; +import { + normalizeId, + IMPLICIT_WITH_PARAMS, + listActionDefInputNames, +} from '../../utils.js'; +import { attachError, extractStringValue, type AstLike } from './shared.js'; + +function getActionDefName( + toolEntry: Record +): string | undefined { + const rawColinear = + toolEntry.value ?? + toolEntry.__colinear ?? + toolEntry.colinear ?? + toolEntry.__value; + const ref = decomposeAtMemberExpression(rawColinear); + if (ref && ref.namespace === 'actions') { + return ref.property; + } + const strValue = extractStringValue(rawColinear); + if (strValue) { + return strValue.startsWith('@actions.') ? strValue.substring(9) : strValue; + } + return undefined; +} + +function getBodyStatements(toolEntry: Record): unknown[] { + const body = toolEntry.body as { statements?: unknown[] } | undefined; + if (body && Array.isArray(body.statements)) return body.statements; + if (Array.isArray(toolEntry.statements)) return toolEntry.statements; + return []; +} + +function validateNodeActionBindings( + nodeEntry: Record, + actionDefs: Map> +): void { + const reasoning = nodeEntry.reasoning as Record | undefined; + if (!reasoning) return; + + const actionsMap = reasoning.actions; + if (!actionsMap || typeof actionsMap !== 'object') return; + if (!isNamedMap(actionsMap) && !(Symbol.iterator in actionsMap)) return; + + const entries = + actionsMap instanceof Map + ? actionsMap.entries() + : (actionsMap as Iterable<[string, unknown]>); + + for (const [, toolEntry] of entries) { + if (toolEntry == null || typeof toolEntry !== 'object') continue; + const entry = toolEntry as Record; + const actionDefName = getActionDefName(entry); + if (!actionDefName) continue; + + const actionDef = actionDefs.get(normalizeId(actionDefName)); + if (!actionDef) continue; + + const declaredInputs = new Set(listActionDefInputNames(actionDef)); + if (declaredInputs.size === 0) continue; + + const bodyStmts = getBodyStatements(entry); + for (const stmt of bodyStmts) { + if (!(stmt instanceof WithClause)) continue; + if (stmt.value instanceof Ellipsis) continue; + if (declaredInputs.has(stmt.param)) continue; + if (IMPLICIT_WITH_PARAMS.has(stmt.param)) continue; + + attachError( + nodeEntry as AstLike, + `\`with ${stmt.param}\` is not a declared input on this action. ` + + `Declared inputs: [${[...declaredInputs].join(', ')}]. ` + + `Implicit parameters: [${[...IMPLICIT_WITH_PARAMS].join(', ')}].`, + 'action-binding-undeclared-input' + ); + } + } +} + +const AGENTIC_NODE_TYPES = ['orchestrator', 'subagent', 'generator'] as const; + +export function checkActionBindingRules(root: Record): void { + const actionDefs = isNamedMap(root.actions) + ? new Map>( + [...root.actions].map(([k, v]) => [ + normalizeId(k), + v as Record, + ]) + ) + : undefined; + + if (!actionDefs) return; + + for (const nodeType of AGENTIC_NODE_TYPES) { + const nodes = root[nodeType]; + if (!isNamedMap(nodes)) continue; + + for (const [, entry] of nodes) { + if (entry == null || typeof entry !== 'object') continue; + validateNodeActionBindings(entry as Record, actionDefs); + } + } +} diff --git a/dialect/agentfabric/src/lint/passes/rules/cycle-rules.ts b/dialect/agentfabric/src/lint/passes/rules/cycle-rules.ts new file mode 100644 index 00000000..33172d6b --- /dev/null +++ b/dialect/agentfabric/src/lint/passes/rules/cycle-rules.ts @@ -0,0 +1,172 @@ +import { isNamedMap } from '@agentscript/language'; +import { extractGraph } from '../../../graph/extractor.js'; +import type { GraphEdge } from '../../../graph/extractor.js'; +import { AgentFabricSchemaInfo } from '../../../schema.js'; +import { attachWarning, type AstLike } from './shared.js'; + +// Produce a rotation-invariant signature for a cycle so duplicates from different entry points dedupe. +function canonicalSignature(cycle: string[]): string { + if (cycle.length === 0) return ''; + let smallestIndex = 0; + for (let i = 1; i < cycle.length; i++) { + if (cycle[i] < cycle[smallestIndex]) smallestIndex = i; + } + const rotated = cycle + .slice(smallestIndex) + .concat(cycle.slice(0, smallestIndex)); + return rotated.join('|'); +} + +// Rotate a cycle so it begins at the given node, used for rendering messages +function rotateAtNode(cycle: string[], node: string): string[] { + const idx = cycle.indexOf(node); + if (idx <= 0) return cycle.slice(); + return cycle.slice(idx).concat(cycle.slice(0, idx)); +} + +// Format a cycle as an arrow-joined path closed back to the anchor node, e.g. `@a → @b → @a`. +function formatCyclePath(cycle: string[], node: string): string { + const rotated = rotateAtNode(cycle, node); + const closed = [...rotated, node]; + return closed.map(id => `@${id}`).join(' → '); +} + +type TraversalState = 'unvisited' | 'visiting' | 'visited'; + +// A frame on the DFS stack: +// `nextNeighbor` is the index of the next neighbor of `node` left to examine. +interface Frame { + node: string; + nextNeighbor: number; +} + +// Iterative DFS tracking each node's traversal state. Reaching a node still in +// the 'visiting' state closes a cycle, which is sliced off the active path and +// deduped by its canonical rotation-invariant signature. +function findCycles( + nodeIds: Set, + adjacency: Map, + seeds: string[] +): string[][] { + const traversalState = new Map(); + for (const id of nodeIds) traversalState.set(id, 'unvisited'); + + const cycles: string[][] = []; + const seenSignatures = new Set(); + + // The active DFS path — the chain of nodes currently being visited + const path: string[] = []; + const pathPositions = new Map(); + + function enter(node: string, work: Frame[]): void { + traversalState.set(node, 'visiting'); + pathPositions.set(node, path.length); + path.push(node); + work.push({ node, nextNeighbor: 0 }); + } + + // Process seeds first (in order), then any remaining nodes + for (const root of [...seeds, ...nodeIds]) { + if (traversalState.get(root) !== 'unvisited') continue; + + const work: Frame[] = []; + enter(root, work); + + while (work.length > 0) { + const frame = work[work.length - 1]; + const neighbors = adjacency.get(frame.node) ?? []; + + if (frame.nextNeighbor >= neighbors.length) { + // All neighbors explored: do the post-order bookkeeping — leave the + // path, forget the position, mark visited — then pop the frame. + path.pop(); + pathPositions.delete(frame.node); + traversalState.set(frame.node, 'visited'); + work.pop(); + continue; + } + + const next = neighbors[frame.nextNeighbor++]; + const state = traversalState.get(next); + if (state === 'visiting') { + const startIdx = pathPositions.get(next); + if (startIdx === undefined) continue; + const cycle = path.slice(startIdx); + const signature = canonicalSignature(cycle); + if (!seenSignatures.has(signature)) { + seenSignatures.add(signature); + cycles.push(cycle); + } + } else if (state === 'unvisited') { + enter(next, work); + } + } + } + + return cycles; +} + +function buildAdjacency( + nodeIds: Set, + edges: GraphEdge[] +): Map { + const adjacency = new Map(); + for (const id of nodeIds) adjacency.set(id, []); + for (const edge of edges) { + if (!nodeIds.has(edge.from) || !nodeIds.has(edge.to)) continue; + adjacency.get(edge.from)!.push(edge.to); + } + return adjacency; +} + +function buildASTNodesIndex( + root: Record, + nodeIds: Set +): Map { + const index = new Map(); + for (const [namespace, group] of Object.entries(root)) { + if (!isNamedMap(group)) continue; + for (const [name, entry] of group as Iterable<[string, unknown]>) { + const id = `${namespace}.${name}`; + if (!nodeIds.has(id)) continue; + if (entry == null || typeof entry !== 'object') continue; + index.set(id, entry as AstLike); + } + } + return index; +} + +// Entry point: detect cycles in the agent's execution graph and attach a +// warning to each cycle member that resolves to a defining AST instance. +export function checkCycleRules(root: Record): void { + const { nodes, edges } = extractGraph(root, AgentFabricSchemaInfo); + if (nodes.length === 0) return; + + const triggerIds = new Set(); + for (const edge of edges) { + if (edge.via === 'trigger') triggerIds.add(edge.from); + } + const nodeIds = new Set( + nodes.map(node => node.id).filter(id => !triggerIds.has(id)) + ); + if (nodeIds.size === 0) return; + + const adjacency = buildAdjacency(nodeIds, edges); + // Seeds: the first nodes reached from each trigger's transition container. + const seeds = edges + .filter(edge => edge.via === 'trigger' && nodeIds.has(edge.to)) + .map(edge => edge.to); + + const cycles = findCycles(nodeIds, adjacency, seeds); + if (cycles.length === 0) return; + + const astNodesIndex = buildASTNodesIndex(root, nodeIds); + for (const cycle of cycles) { + for (const nodeId of cycle) { + const astNode = astNodesIndex.get(nodeId); + if (!astNode) continue; + const message = `Cycle detected in execution flow: ${formatCyclePath(cycle, nodeId)}`; + attachWarning(astNode, message, 'cycle-detected'); + } + } +} diff --git a/dialect/agentfabric/src/lint/passes/rules/execute-rules.ts b/dialect/agentfabric/src/lint/passes/rules/execute-rules.ts new file mode 100644 index 00000000..60d6778f --- /dev/null +++ b/dialect/agentfabric/src/lint/passes/rules/execute-rules.ts @@ -0,0 +1,299 @@ +/** + * Lint rules for `executor` blocks. + * + * Validates the `do` body of each executor entry, enforcing: + * - `set` targets must be `@variables.*` or `@outputs.*`. + * - `run` targets must reference `@actions.*` with a valid action def + * whose `kind` is `a2a:send_message` or `mcp:tool`. + * - `with` parameters on `run` statements must match declared action + * inputs (or be implicit parameters like `message`). + * - Expressions must not use bare `@` identifiers or `@actions.*` as values. + * - `@outputs.*` references are disallowed outside `run` body `set` clauses. + */ + +import { + AtIdentifier, + BinaryExpression, + CallExpression, + ComparisonExpression, + DictLiteral, + isNamedMap, + ListLiteral, + MemberExpression, + RunStatement, + SetClause, + SpreadExpression, + SubscriptExpression, + TemplateExpression, + TemplateInterpolation, + TernaryExpression, + UnaryExpression, + WithClause, + decomposeAtMemberExpression, +} from '@agentscript/language'; +import type { Expression, Statement } from '@agentscript/language'; +import { + normalizeId, + IMPLICIT_WITH_PARAMS, + listActionDefInputNames, +} from '../../utils.js'; +import { + attachError, + asStatements, + extractStringValue, + type AstLike, +} from './shared.js'; + +type ExprMode = 'execute' | 'run-body'; + +function validateExpression( + expr: Expression, + mode: ExprMode, + node: AstLike +): void { + if (expr instanceof AtIdentifier) { + attachError( + node, + `Bare @${expr.name} is not allowed; use @variables.*, @request.*, or @..output.`, + 'execute-bare-at' + ); + return; + } + + if (expr instanceof MemberExpression) { + const decomposed = decomposeAtMemberExpression(expr); + if (decomposed) { + if (decomposed.namespace === 'outputs' && mode !== 'run-body') { + attachError( + node, + '@outputs. is not supported for node outputs. Use @..output.', + 'execute-outputs-unsupported' + ); + } + if (decomposed.namespace === 'actions') { + attachError( + node, + '@actions references cannot be used as values. Use `run @actions.` to invoke an action.', + 'execute-actions-ref' + ); + } + return; + } + if (expr.object && typeof (expr.object as Expression).__kind === 'string') { + validateExpression(expr.object as Expression, mode, node); + } + return; + } + + if (expr instanceof SubscriptExpression) { + if (expr.object && typeof (expr.object as Expression).__kind === 'string') { + validateExpression(expr.object as Expression, mode, node); + } + if (expr.index && typeof (expr.index as Expression).__kind === 'string') { + validateExpression(expr.index as Expression, mode, node); + } + return; + } + + if ( + expr instanceof BinaryExpression || + expr instanceof ComparisonExpression + ) { + validateExpression(expr.left, mode, node); + validateExpression(expr.right, mode, node); + return; + } + + if (expr instanceof UnaryExpression) { + validateExpression(expr.operand, mode, node); + return; + } + + if (expr instanceof TernaryExpression) { + validateExpression(expr.condition, mode, node); + validateExpression(expr.consequence, mode, node); + validateExpression(expr.alternative, mode, node); + return; + } + + if (expr instanceof CallExpression) { + if (expr.func && typeof (expr.func as Expression).__kind === 'string') { + validateExpression(expr.func as Expression, mode, node); + } + for (const arg of expr.args) { + validateExpression(arg, mode, node); + } + return; + } + + if (expr instanceof ListLiteral) { + for (const el of expr.elements) { + validateExpression(el, mode, node); + } + return; + } + + if (expr instanceof DictLiteral) { + for (const entry of expr.entries) { + validateExpression(entry.key, mode, node); + validateExpression(entry.value, mode, node); + } + return; + } + + if (expr instanceof TemplateExpression) { + for (const part of expr.parts) { + if (part instanceof TemplateInterpolation) { + validateExpression(part.expression, mode, node); + } + } + return; + } + + if (expr instanceof SpreadExpression) { + validateExpression(expr.expression, mode, node); + return; + } +} + +function validateSetTarget( + target: Expression, + node: AstLike, + code: string, + message: string +): void { + const decomposed = decomposeAtMemberExpression(target); + if ( + decomposed && + (decomposed.namespace === 'variables' || decomposed.namespace === 'outputs') + ) { + return; + } + attachError(node, message, code); +} + +function validateExecuteDo( + executorEntry: Record, + actionDefs: Map> | undefined +): void { + const node = executorEntry as AstLike; + const statements = asStatements(executorEntry.do) as unknown as Statement[]; + + for (const stmt of statements) { + if (stmt instanceof SetClause) { + validateSetTarget( + stmt.target, + node, + 'execute-set-target', + 'execute `set` target must be @variables. or @outputs..' + ); + validateExpression(stmt.value, 'execute', node); + continue; + } + + if (stmt instanceof RunStatement) { + const targetDecomposed = decomposeAtMemberExpression(stmt.target); + if (!targetDecomposed || targetDecomposed.namespace !== 'actions') { + attachError( + node, + 'execute `run` target must be @actions..', + 'execute-run-target' + ); + continue; + } + + const actionDefName = normalizeId(targetDecomposed.property); + + if (!actionDefs) { + attachError( + node, + `execute \`run\`: references @actions.${actionDefName} but no actions block is defined.`, + 'execute-action-def' + ); + continue; + } + + if (actionDefs) { + const actionDef = actionDefs.get(actionDefName); + if (!actionDef) { + attachError( + node, + `execute \`run\`: actions entry '${actionDefName}' must exist with kind a2a:send_message or mcp:tool.`, + 'execute-action-def' + ); + } else { + const kind = extractStringValue(actionDef.kind); + if (kind !== 'a2a:send_message' && kind !== 'mcp:tool') { + attachError( + node, + `execute \`run\`: actions entry '${actionDefName}' must exist with kind a2a:send_message or mcp:tool.`, + 'execute-action-def' + ); + } else { + const declaredInputs = new Set(listActionDefInputNames(actionDef)); + for (const child of stmt.body) { + if (child instanceof WithClause) { + if ( + declaredInputs.size > 0 && + !declaredInputs.has(child.param) && + !IMPLICIT_WITH_PARAMS.has(child.param) + ) { + attachError( + node, + `execute \`run\`: \`with ${child.param}\` is not a declared input on '${actionDefName}'. ` + + `Declared inputs: [${[...declaredInputs].join(', ')}]. ` + + `Implicit parameters: [${[...IMPLICIT_WITH_PARAMS].join(', ')}].`, + 'execute-undeclared-input' + ); + } + validateExpression(child.value, 'execute', node); + } else if (child instanceof SetClause) { + validateSetTarget( + child.target, + node, + 'execute-run-set-target', + '`run` body `set` target must be @variables. or @outputs..' + ); + validateExpression(child.value, 'run-body', node); + } else { + attachError( + node, + `Unsupported statement in execute \`run\` body: ${(child as Statement).__kind}.`, + 'execute-run-body-stmt' + ); + } + } + } + } + } + continue; + } + + attachError( + node, + `Unsupported statement in execute.do: ${(stmt as Statement).__kind}. Use \`set\` or \`run @actions.*\`.`, + 'execute-do-stmt' + ); + } +} + +export function checkExecuteRules(root: Record): void { + const executors = root.executor; + if (!isNamedMap(executors)) return; + + const actionDefs = isNamedMap(root.actions) + ? new Map>( + [...root.actions].map(([k, v]) => [ + normalizeId(k), + v as Record, + ]) + ) + : undefined; + + for (const [, entry] of executors) { + if (entry == null || typeof entry !== 'object') continue; + const executorEntry = entry as Record; + if (executorEntry.do == null) continue; + validateExecuteDo(executorEntry, actionDefs); + } +} diff --git a/dialect/agentfabric/src/lint/passes/rules/shared.ts b/dialect/agentfabric/src/lint/passes/rules/shared.ts index 5d5a740d..1599d8bf 100644 --- a/dialect/agentfabric/src/lint/passes/rules/shared.ts +++ b/dialect/agentfabric/src/lint/passes/rules/shared.ts @@ -16,9 +16,9 @@ import { UnaryExpression, } from '@agentscript/language'; import { normalizeId } from '../../utils.js'; - -const AGENTFABRIC_LINT_SOURCE = 'agentfabric-lint'; +export const AGENTFABRIC_LINT_SOURCE = 'agentfabric-lint'; const ERROR_SEVERITY = 1; +const WARNING_SEVERITY = 2; interface ExpressionLike { __kind?: string; @@ -43,10 +43,11 @@ export interface AstLike { __cst?: CstLike; } -export function attachError( +function attachDiagnosticAt( node: AstLike, message: string, - code: string + code: string, + severity: number ): void { if (!Array.isArray(node.__diagnostics)) return; const range = @@ -59,12 +60,28 @@ export function attachError( attachDiagnostic(node as never, { range: range as never, message, - severity: ERROR_SEVERITY, + severity: severity as never, code, source: AGENTFABRIC_LINT_SOURCE, }); } +export function attachError( + node: AstLike, + message: string, + code: string +): void { + attachDiagnosticAt(node, message, code, ERROR_SEVERITY); +} + +export function attachWarning( + node: AstLike, + message: string, + code: string +): void { + attachDiagnosticAt(node, message, code, WARNING_SEVERITY); +} + export function asStatements(value: unknown): StatementLike[] { if (value == null || typeof value !== 'object') return []; const proc = value as ProcedureLike; diff --git a/dialect/agentfabric/src/lint/passes/rules/switch-rules.ts b/dialect/agentfabric/src/lint/passes/rules/switch-rules.ts index ffcc96df..7c960014 100644 --- a/dialect/agentfabric/src/lint/passes/rules/switch-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/switch-rules.ts @@ -11,37 +11,12 @@ import type { PassStore } from '@agentscript/language'; import { asObjectList, attachError, - collectStatementKinds, extractSwitchTarget, extractWhenString, isBooleanLikeExpression, type AstLike, } from './shared.js'; -function reportDeprecatedSwitchChoices( - switchEntry: Record, - normalizedName: string -): void { - if (switchEntry.choices !== undefined) { - attachError( - switchEntry as AstLike, - `router '${normalizedName}' uses deprecated 'choices'. Use 'routes' and required 'otherwise' instead.`, - 'switch-choices-deprecated' - ); - } -} - -function validateSwitchOnExit(switchEntry: Record): void { - const onExit = switchEntry.on_exit; - if (onExit !== undefined && collectStatementKinds(onExit).length > 0) { - attachError( - switchEntry as AstLike, - 'router nodes must not define on_exit transitions.', - 'switch-on-exit' - ); - } -} - function validateSwitchRoutes( switchEntry: Record, normalizedName: string @@ -115,8 +90,6 @@ export function checkSwitchRules( const switchEntry = entry as Record; const normalizedName = normalizeId(name); - reportDeprecatedSwitchChoices(switchEntry, normalizedName); - validateSwitchOnExit(switchEntry); validateSwitchRoutes(switchEntry, normalizedName); validateSwitchElse(switchEntry, normalizedName); } diff --git a/dialect/agentfabric/src/lint/passes/rules/unused-node.ts b/dialect/agentfabric/src/lint/passes/rules/unused-node.ts new file mode 100644 index 00000000..551c3951 --- /dev/null +++ b/dialect/agentfabric/src/lint/passes/rules/unused-node.ts @@ -0,0 +1,86 @@ +import { + DiagnosticSeverity, + DiagnosticTag, + attachDiagnostic, + decomposeAtMemberExpression, + isAstNodeLike, + isNamedMap, + storeKey, +} from '@agentscript/language'; +import type { AstRoot, LintPass, PassStore } from '@agentscript/language'; +import { AGENTFABRIC_LINT_SOURCE } from './shared.js'; + +const NODE_NAMESPACES = new Set([ + 'orchestrator', + 'subagent', + 'generator', + 'executor', + 'router', + 'echo', + 'actions', + 'llm', +]); + +class UnusedNodePass implements LintPass { + readonly id = storeKey('unused-node'); + readonly description = + 'Flags graph nodes that are declared but never referenced'; + + private usedSymbols = new Set(); + + init(): void { + this.usedSymbols = new Set(); + } + + enterNode(_key: string, value: unknown, _parent: unknown): void { + const ref = decomposeAtMemberExpression(value); + if (!ref) return; + if (!NODE_NAMESPACES.has(ref.namespace)) return; + this.usedSymbols.add(`${ref.namespace}:${ref.property}`); + } + + run(_store: PassStore, root: AstRoot): void { + const groups: Array<{ namespace: string; label: string; group: unknown }> = + [ + { + namespace: 'orchestrator', + label: 'Orchestrator', + group: root.orchestrator, + }, + { namespace: 'subagent', label: 'Subagent', group: root.subagent }, + { namespace: 'generator', label: 'Generator', group: root.generator }, + { namespace: 'executor', label: 'Executor', group: root.executor }, + { namespace: 'router', label: 'Router', group: root.router }, + { namespace: 'echo', label: 'Echo', group: root.echo }, + { namespace: 'actions', label: 'Actions', group: root.actions }, + { namespace: 'llm', label: 'LLM', group: root.llm }, + ]; + + for (const { namespace, label, group } of groups) { + if (!isNamedMap(group)) continue; + + for (const [name, decl] of group) { + if (this.usedSymbols.has(`${namespace}:${name}`)) continue; + + const node = isAstNodeLike(decl) ? decl : null; + if (!node?.__cst) continue; + + const range = node.__cst.range; + + attachDiagnostic(node, { + range, + message: `${label} '${name}' is declared but never referenced`, + severity: DiagnosticSeverity.Information, + code: 'unused-node', + source: AGENTFABRIC_LINT_SOURCE, + tags: [DiagnosticTag.Unnecessary], + data: { removalRange: range }, + }); + } + } + } +} + +export function unusedNodePass(): LintPass { + return new UnusedNodePass(); +} diff --git a/dialect/agentfabric/src/lint/passes/strict-schema-validation.ts b/dialect/agentfabric/src/lint/passes/strict-schema-validation.ts new file mode 100644 index 00000000..17aba1ed --- /dev/null +++ b/dialect/agentfabric/src/lint/passes/strict-schema-validation.ts @@ -0,0 +1,35 @@ +import { + storeKey, + DiagnosticSeverity, + collectDiagnostics, +} from '@agentscript/language'; +import type { LintPass, PassStore } from '@agentscript/language'; + +const PROMOTE_CODES = new Set(['unknown-field', 'unknown-block']); + +/** + * Promotes unknown-field and unknown-block diagnostics from Warning to Error + * for the AgentFabric dialect. AgentFabric is a strict compilation target — + * unrecognized fields are silently dropped and indicate author mistakes. + */ +class StrictSchemaValidationPass implements LintPass { + readonly id = storeKey('agentfabric-strict-schema'); + readonly description = + 'Promotes unknown field/block warnings to errors for AgentFabric'; + + finalize(_store: PassStore, root: Record): void { + const diagnostics = collectDiagnostics(root); + for (const diag of diagnostics) { + if ( + diag.severity === DiagnosticSeverity.Warning && + PROMOTE_CODES.has(diag.code ?? '') + ) { + diag.severity = DiagnosticSeverity.Error; + } + } + } +} + +export function strictSchemaValidationPass(): LintPass { + return new StrictSchemaValidationPass(); +} diff --git a/dialect/agentfabric/src/lint/utils.ts b/dialect/agentfabric/src/lint/utils.ts index 5dc1c403..a1d36bf8 100644 --- a/dialect/agentfabric/src/lint/utils.ts +++ b/dialect/agentfabric/src/lint/utils.ts @@ -9,3 +9,42 @@ export function normalizeId(name: string): string { return name ? name.replace(/-/g, '_') : name; } + +/** + * Iterate key/value pairs from a dialect collection block. + * Parsed collections are `NamedMap` (iterable, not `instanceof Map`) or native `Map`. + */ +export function iterateCollection( + block: unknown +): [string, Record][] { + if (block == null) return []; + if (block instanceof Map) { + return [...block.entries()] as [string, Record][]; + } + if (typeof block === 'object' && Symbol.iterator in block) { + return [...(block as Iterable<[string, unknown]>)] as [ + string, + Record, + ][]; + } + return []; +} + +/** + * Input parameter names declared on an action_definition `inputs:` map (declaration order). + */ +export function listActionDefInputNames( + actionDef: Record +): string[] { + const names: string[] = []; + for (const [name] of iterateCollection(actionDef.inputs)) { + if (name) names.push(name); + } + return names; +} + +/** + * Implicit parameter names allowed in `with` clauses without being declared + * in the action's `inputs:` map. + */ +export const IMPLICIT_WITH_PARAMS = new Set(['http_headers']); diff --git a/dialect/agentfabric/src/schema.ts b/dialect/agentfabric/src/schema.ts index 8c22c1f0..be901d62 100644 --- a/dialect/agentfabric/src/schema.ts +++ b/dialect/agentfabric/src/schema.ts @@ -26,8 +26,31 @@ import type { Schema, SchemaInfo, SchemaContext, + NamedBlockFactory, + CollectionBlockFactory, + BlockFactory, } from '@agentscript/language'; +/** + * Lazy field type wrapper that defers resolution of a factory reference until + * first access. Used to declare self-referential block schemas without hitting + * the language factories' eager freeze + validate pass. + */ +function lazyField(resolve: () => F): F { + let cache: F | undefined; + const get = (): F => (cache ??= resolve()); + return new Proxy({} as F, { + get(_target, prop) { + return (get() as Record)[ + prop as string | symbol + ]; + }, + has(_target, prop) { + return prop in (get() as object); + }, + }); +} + import { SystemBlock, ActionBlock as AgentScriptActionBlock, @@ -43,7 +66,9 @@ export { SystemBlock, VariablesBlock } from '@agentscript/agentscript-dialect'; export const AFConfigBlock = Block('AFConfigBlock', { agent_name: StringValue.describe('Unique agent identifier.').required(), - label: StringValue.describe('Human-readable display name for the agent.'), + label: StringValue.describe( + 'Human-readable display name for the agent.' + ).displayLabelField(), description: StringValue.describe('Description of the agent.'), default_llm: ReferenceValue.describe( 'Default LLM (@llm.) used at compile time for orchestration, reasoning, and generate nodes that omit an explicit llm field. The linter reports an error if this is omitted while any such node also omits llm.' @@ -93,7 +118,7 @@ const llmBaseFields: Schema = { const openaiLlmVariantFields: Schema = { reasoning_effort: StringValue.describe( 'Constrains effort on reasoning for OpenAI reasoning models.' - ).enum(['NONE', 'MINIMAL', 'LOW', 'MEDIUM', 'HIGH']), + ).enum(['NONE', 'MINIMAL', 'LOW', 'MEDIUM', 'HIGH', 'XHIGH']), top_logprobs: NumberValue.describe( 'Number of most likely tokens to return at each position (OpenAI).' ), @@ -149,9 +174,6 @@ export const ActionDefBlock = AgentScriptActionBlock.pick([ kind: StringValue.describe( 'Action type discriminator: "a2a:send_message" or "mcp:tool".' ).required(), - http_headers: ExpressionValue.describe( - 'Optional per-action HTTP headers map applied to outgoing action calls.' - ), inputs: CollectionBlock(ActionDefInputBlock).describe( 'Bindable input arguments for the action.' ), @@ -191,7 +213,9 @@ export const TriggerBlock = NamedBlock('TriggerBlock', { .required(), on_message: ProcedureValue.describe( 'Procedure executed when a message is received. Must contain a transition to the initial node.' - ).required(), + ) + .required() + .transitionContainer(), }) .discriminant('kind') .variant('a2a', {}) @@ -245,31 +269,49 @@ function createOutputJsonSchemaFields(options?: { return fields; } -export const OutputPropertyBlock = NamedBlock('OutputPropertyBlock', { - ...createOutputJsonSchemaFields({ includeRequired: true }), - items: Block('OutputArrayItemsBlock', { +// Self-referential schema: a property's `items` (array) and `properties` +// entries (object) recursively accept the same shape. Forward declarations +// + lazy proxies break the cycle without depending on factory `extend`, +// which returns a new factory rather than mutating in place. +const lazyOutputProperty: NamedBlockFactory = lazyField( + () => OutputPropertyBlock as NamedBlockFactory +); +const lazyOutputArrayItems: BlockFactory = lazyField( + () => OutputArrayItemsBlock as BlockFactory +); +const lazyOutputPropertyCollection: CollectionBlockFactory = lazyField( + () => OutputPropertyCollection as CollectionBlockFactory +); + +export const OutputPropertyBlock: NamedBlockFactory = NamedBlock( + 'OutputPropertyBlock', + { + ...createOutputJsonSchemaFields({ + includeRequired: true, + includeDefault: true, + }), + items: lazyOutputArrayItems, + properties: lazyOutputPropertyCollection, + } +).describe('Output structure property definition.'); + +const OutputArrayItemsBlock: BlockFactory = Block( + 'OutputArrayItemsBlock', + { ...createOutputJsonSchemaFields({ typeDescription: 'Data type for array items: string, number, integer, boolean, array, object.', descriptionDescription: 'Description of this item schema.', includeRequired: true, + includeDefault: true, }), - properties: CollectionBlock( - NamedBlock( - 'OutputNestedPropertyBlock', - createOutputJsonSchemaFields({ includeDefault: true }) - ) - ).describe('Nested object properties map.'), - default: ExpressionValue.describe('Default value.'), - }).describe('Schema for each array item.'), - properties: CollectionBlock( - NamedBlock( - 'OutputObjectPropertyBlock', - createOutputJsonSchemaFields({ includeDefault: true }) - ) - ).describe('Nested object properties map.'), - default: ExpressionValue.describe('Default value.'), -}).describe('Output structure property definition.'); + items: lazyOutputArrayItems, + properties: lazyOutputPropertyCollection, + } +).describe('Schema for each array item.'); + +const OutputPropertyCollection: CollectionBlockFactory = + CollectionBlock(lazyOutputProperty).describe('Nested object properties map.'); export const OutputStructureBlock = Block('OutputStructureBlock', { properties: CollectionBlock(OutputPropertyBlock).describe( @@ -327,7 +369,9 @@ export const SubagentBlock = AgentScriptSubagentBlock.omit( ).required(), on_exit: ProcedureValue.describe( 'Procedure executed when node completes. Must contain a transition to statement.' - ).required(), + ) + .required() + .transitionContainer(), }, { capabilities: ['transitionTarget'], @@ -346,7 +390,9 @@ export const GeneratorBlock = NamedBlock( 'GeneratorBlock', { description: StringValue.describe('Description of what this node does.'), - label: StringValue.describe('Human-readable display name.'), + label: StringValue.describe( + 'Human-readable display name.' + ).displayLabelField(), llm: ReferenceValue.describe( 'Override the default LLM setting.' ).allowedNamespaces(['llm']), @@ -361,7 +407,9 @@ export const GeneratorBlock = NamedBlock( ), on_exit: ProcedureValue.describe( 'Procedure executed when node completes. Must contain a transition to statement.' - ).required(), + ) + .required() + .transitionContainer(), }, { capabilities: ['transitionTarget'], @@ -375,13 +423,15 @@ export const ExecutorBlock = NamedBlock( 'ExecutorBlock', { description: StringValue.describe('Description of what this node does.'), - label: StringValue.describe('Human-readable display name.'), + label: StringValue.describe( + 'Human-readable display name.' + ).displayLabelField(), do: ProcedureValue.describe( 'Deterministic steps: `set @variables. = ` and/or `run @actions.` with `with` inputs and optional `set` lines that read `@outputs.` from the action result. For prior graph node results use `@..output` (for example `@generate.summary.output`). Use @request.* for trigger payload and @variables.* for declared variables.' ).required(), on_exit: ProcedureValue.describe( 'Procedure executed when node completes. Optional for terminal execute nodes; when present, should contain a transition to statement.' - ), + ).transitionContainer(), }, { capabilities: ['transitionTarget'], @@ -393,6 +443,9 @@ export const ExecutorBlock = NamedBlock( // ── Switch ────────────────────────────────────────────────────────── +// TODO: derive this list from the schema itself (every top-level namespace +// whose entry block declares the `'transitionTarget'` capability) so adding +// a new node kind doesn't require editing this allowlist by hand. const ROUTER_TARGET_NAMESPACES = [ 'orchestrator', 'subagent', @@ -407,11 +460,16 @@ export const RouterRouteBlock = Block('RouterRouteBlock', { 'Transition target reference, e.g. @orchestrator.someNode.' ) .allowedNamespaces(ROUTER_TARGET_NAMESPACES) + .resolvedType('transitionTarget') .required(), when: ExpressionValue.describe( 'Condition expression that enables this route.' - ).required(), - label: StringValue.describe('Optional UI label for this route.'), + ) + .required() + .predicateField(), + label: StringValue.describe( + 'Optional UI label for this route.' + ).outputNameField(), }); export const RouterOtherwiseBlock = Block('RouterOtherwiseBlock', { @@ -419,6 +477,7 @@ export const RouterOtherwiseBlock = Block('RouterOtherwiseBlock', { 'Default transition target when no route condition matches.' ) .allowedNamespaces(ROUTER_TARGET_NAMESPACES) + .resolvedType('transitionTarget') .required(), }); @@ -426,7 +485,9 @@ export const RouterBlock = NamedBlock( 'RouterBlock', { description: StringValue.describe('Description of what this node does.'), - label: StringValue.describe('Human-readable display name.'), + label: StringValue.describe( + 'Human-readable display name.' + ).displayLabelField(), routes: Sequence(RouterRouteBlock) .describe( 'Ordered conditional routes. Each route has target + when, and optional label for UI.' @@ -451,7 +512,9 @@ export const EchoBlock = NamedBlock( 'EchoBlock', { description: StringValue.describe('Description of what this node does.'), - label: StringValue.describe('Human-readable display name.'), + label: StringValue.describe( + 'Human-readable display name.' + ).displayLabelField(), kind: StringValue.describe( 'Response type discriminator. Currently only "a2a:response".' ).required(), @@ -463,7 +526,9 @@ export const EchoBlock = NamedBlock( 'Artifacts expression for the response.' ), metadata: ExpressionValue.describe('Metadata expression for the response.'), - on_exit: ProcedureValue.describe('Procedure executed when node completes.'), + on_exit: ProcedureValue.describe( + 'Procedure executed when node completes.' + ).transitionContainer(), }, { capabilities: ['transitionTarget'], @@ -482,7 +547,7 @@ export const AgentFabricSchema = { variables: VariablesBlock, llm: LLMBlock, actions: ActionsBlock, - trigger: NamedCollectionBlock(TriggerBlock), + trigger: NamedCollectionBlock(TriggerBlock).required(), orchestrator: NamedCollectionBlock(OrchestratorBlock), subagent: NamedCollectionBlock(SubagentBlock), generator: NamedCollectionBlock(GeneratorBlock), diff --git a/dialect/agentfabric/src/tests/lint.test.ts b/dialect/agentfabric/src/tests/lint.test.ts index eb4a9e08..6f99ca42 100644 --- a/dialect/agentfabric/src/tests/lint.test.ts +++ b/dialect/agentfabric/src/tests/lint.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import type { Diagnostic } from '@agentscript/language'; import { parseAndLintSource } from './test-utils.js'; const __filename = fileURLToPath(import.meta.url); @@ -740,6 +741,12 @@ echo successResponse: it('allows namespaced A2A helper calls in expression fields (a2a.message, a2a.textPart, …)', () => { const source = ` +trigger t: + kind: "a2a" + target: "brokers://generator-procedure-prompt/a2a" + on_message: -> + transition to @echo.out + echo out: kind: "a2a:response" message: a2a.message(a2a.textPart("hello")) @@ -755,7 +762,10 @@ executor step: set @variables.t = a2a.task({ state: "completed" }) `; const result = parseAndLintSource(source); - expect(result.diagnostics.length).toBe(0); + const relevant = result.diagnostics.filter( + d => d.code !== 'unused-node' && d.code !== 'missing-required-field' + ); + expect(relevant.length).toBe(0); }); it('accepts generator prompt in procedure form', () => { @@ -801,4 +811,1054 @@ echo done: ); expect(lintErrors).toHaveLength(0); }); + + it('does not flag output-structure-items-required for top-level array with items', () => { + const source = ` +config: + agent_name: "outputs-top-level-array" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +trigger t: + kind: "a2a" + target: "brokers://outputs-top-level-array/a2a" + on_message: -> transition to @orchestrator.o + +orchestrator o: + llm: @llm.g + reasoning: + instructions: -> + | do something + outputs: + properties: + associated_symptoms: + type: "array" + description: "Additional symptoms" + items: + type: "string" + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const result = parseAndLintSource(source); + expect( + result.diagnostics.some(d => d.code === 'output-structure-items-required') + ).toBe(false); + }); + + it('does not flag output-structure-items-required for nested array with items under object properties', () => { + const source = ` +config: + agent_name: "outputs-nested-array" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +trigger t: + kind: "a2a" + target: "brokers://outputs-nested-array/a2a" + on_message: -> transition to @orchestrator.o + +orchestrator o: + llm: @llm.g + reasoning: + instructions: -> + | do something + outputs: + properties: + information_gathered: + type: "object" + properties: + associated_symptoms: + type: "array" + description: "Additional symptoms" + items: + type: "string" + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const result = parseAndLintSource(source); + const outputStructureErrors = result.diagnostics.filter( + d => typeof d.code === 'string' && d.code.startsWith('output-structure') + ); + expect(outputStructureErrors).toEqual([]); + }); + + it('does not flag output-structure for array-of-objects nested under object properties', () => { + const source = ` +config: + agent_name: "outputs-array-of-objects" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +trigger t: + kind: "a2a" + target: "brokers://outputs-array-of-objects/a2a" + on_message: -> transition to @orchestrator.o + +orchestrator o: + llm: @llm.g + reasoning: + instructions: -> + | do something + outputs: + properties: + information_gathered: + type: "object" + properties: + top_conditions: + type: "array" + description: "Top matching conditions" + items: + type: "object" + properties: + condition_name: + type: "string" + score: + type: "number" + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const result = parseAndLintSource(source); + const outputStructureErrors = result.diagnostics.filter( + d => typeof d.code === 'string' && d.code.startsWith('output-structure') + ); + expect(outputStructureErrors).toEqual([]); + }); + + it('does not flag output-structure for deeply nested mixed array/object schema', () => { + const source = ` +config: + agent_name: "outputs-deeply-nested" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +trigger t: + kind: "a2a" + target: "brokers://outputs-deeply-nested/a2a" + on_message: -> transition to @orchestrator.o + +orchestrator o: + llm: @llm.g + reasoning: + instructions: -> + | do something + outputs: + properties: + info: + type: "object" + properties: + top_conditions: + type: "array" + items: + type: "object" + properties: + evidence: + type: "array" + items: + type: "object" + properties: + details: + type: "string" + deep: + type: "object" + properties: + inner: + type: "object" + properties: + items_at_depth4: + type: "array" + items: + type: "string" + matrix: + type: "array" + items: + type: "array" + items: + type: "number" + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const result = parseAndLintSource(source); + const outputStructureErrors = result.diagnostics.filter( + d => typeof d.code === 'string' && d.code.startsWith('output-structure') + ); + expect(outputStructureErrors).toEqual([]); + }); + + it('still flags output-structure-items-required when items is missing at depth', () => { + const source = ` +config: + agent_name: "outputs-missing-items-at-depth" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +trigger t: + kind: "a2a" + target: "brokers://outputs-missing-items-at-depth/a2a" + on_message: -> transition to @orchestrator.o + +orchestrator o: + llm: @llm.g + reasoning: + instructions: -> + | do something + outputs: + properties: + information_gathered: + type: "object" + properties: + associated_symptoms: + type: "array" + description: "Additional symptoms (no items declared)" + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const result = parseAndLintSource(source); + const itemsErrors = result.diagnostics.filter( + d => d.code === 'output-structure-items-required' + ); + expect(itemsErrors).toHaveLength(1); + expect(itemsErrors[0].message).toContain( + 'information_gathered.properties.associated_symptoms' + ); + }); +}); + +describe('unused-node rule', () => { + function unusedNode(diagnostics: Diagnostic[]): Diagnostic[] { + return diagnostics.filter(d => d.code === 'unused-node'); + } + + it('does not warn when trigger -> A and A.on_exit -> B (terminal echo)', () => { + const source = ` +config: + agent_name: "unused-node-1" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +trigger t: + kind: "a2a" + target: "brokers://unused-node-1/a2a" + on_message: -> + transition to @subagent.A + +subagent A: + llm: @llm.g + description: "A subagent" + reasoning: + instructions: -> + | work + on_exit: -> + transition to @echo.B + +echo B: + kind: "a2a:response" + message: "ok" +`; + const { diagnostics } = parseAndLintSource(source); + expect(diagnostics).toHaveLength(0); + }); + + it('does not warn when a node is the trigger target only', () => { + const source = ` +config: + agent_name: "unused-node-3" + +trigger t: + kind: "a2a" + target: "brokers://unused-node-3/a2a" + on_message: -> + transition to @echo.X + +echo X: + kind: "a2a:response" + message: "ok" +`; + const { diagnostics } = parseAndLintSource(source); + expect(diagnostics).toHaveLength(0); + }); + + it('does not warn when a node is referenced only inside a reasoning.instructions if branch', () => { + const source = ` +config: + agent_name: "unused-node-4" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +trigger t: + kind: "a2a" + target: "brokers://unused-node-4/a2a" + on_message: -> + transition to @subagent.A + +subagent A: + llm: @llm.g + description: "A subagent" + reasoning: + instructions: -> + | analyze + if @variables.ready: + transition to @echo.X + on_exit: -> + transition to @echo.X + +echo X: + kind: "a2a:response" + message: "ok" +`; + const { diagnostics } = parseAndLintSource(source); + expect(diagnostics).toHaveLength(0); + }); + + it('does not warn when a node is referenced only by router otherwise.target', () => { + const source = ` +config: + agent_name: "unused-node-5" + +trigger t: + kind: "a2a" + target: "brokers://unused-node-5/a2a" + on_message: -> + transition to @router.r + +router r: + routes: + - target: @echo.done + when: True + otherwise: + target: @echo.X + +echo done: + kind: "a2a:response" + message: "ok" + +echo X: + kind: "a2a:response" + message: "fallback" +`; + const { diagnostics } = parseAndLintSource(source); + expect(diagnostics).toHaveLength(0); + }); + + it('does not warn when a node is referenced only by router routes[].target', () => { + const source = ` +config: + agent_name: "unused-node-6" + +trigger t: + kind: "a2a" + target: "brokers://unused-node-6/a2a" + on_message: -> + transition to @router.r + +router r: + routes: + - target: @echo.X + when: True + otherwise: + target: @echo.done + +echo done: + kind: "a2a:response" + message: "ok" + +echo X: + kind: "a2a:response" + message: "x" +`; + const { diagnostics } = parseAndLintSource(source); + expect(diagnostics).toHaveLength(0); + }); + + it('flags one diagnostic per unused declaration across all node namespaces (subagent, generator, echo, llm, actions)', () => { + const source = ` +config: + agent_name: "unused-node-positive" + +llm: + usedLlm: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + unusedLlm: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +actions: + usedAction: + target: "mcp://knowledge" + kind: "mcp:tool" + tool_name: "lookup" + unusedAction: + target: "mcp://knowledge" + kind: "mcp:tool" + tool_name: "lookup" + +trigger t: + kind: "a2a" + target: "brokers://unused-node-positive/a2a" + on_message: -> + transition to @subagent.usedSub + +subagent usedSub: + llm: @llm.usedLlm + description: "A subagent" + reasoning: + instructions: -> + | work + actions: + alias: @actions.usedAction + on_exit: -> + transition to @echo.done + +subagent unusedSub: + llm: @llm.usedLlm + description: "A subagent" + reasoning: + instructions: -> + | work + on_exit: -> + transition to @echo.done + +generator unusedGen: + llm: @llm.usedLlm + prompt: -> + | summarize + on_exit: -> + transition to @echo.done + +echo unusedEcho: + kind: "a2a:response" + message: "x" + +echo done: + kind: "a2a:response" + message: "ok" +`; + const { diagnostics } = parseAndLintSource(source); + expect(diagnostics).toHaveLength(5); + const found = unusedNode(diagnostics); + expect(found).toHaveLength(5); + const messages = found.map(d => d.message).sort(); + expect(messages).toEqual([ + "Actions 'unusedAction' is declared but never referenced", + "Echo 'unusedEcho' is declared but never referenced", + "Generator 'unusedGen' is declared but never referenced", + "LLM 'unusedLlm' is declared but never referenced", + "Subagent 'unusedSub' is declared but never referenced", + ]); + for (const d of found) { + expect(d.source).toBe('agentfabric-lint'); + expect(d.tags).toEqual([1]); + } + }); + + it('does not crash or double-report on a malformed transition target', () => { + const source = ` +config: + agent_name: "unused-node-10" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +trigger t: + kind: "a2a" + target: "brokers://unused-node-10/a2a" + on_message: -> + transition to @subagent.missing + +subagent A: + llm: @llm.g + description: "A subagent" + reasoning: + instructions: -> + | work + on_exit: -> + transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const { diagnostics } = parseAndLintSource(source); + // info for unused node and error for missing transition target + expect(diagnostics).toHaveLength(2); + const found = unusedNode(diagnostics); + expect(found).toHaveLength(1); + expect(found[0].message).toBe( + "Subagent 'A' is declared but never referenced" + ); + }); +}); + +describe('cycle-detected rule', () => { + function cycleDiagnostics(diagnostics: Diagnostic[]): Diagnostic[] { + return diagnostics.filter(d => d.code === 'cycle-detected'); + } + + it('reports no cycle for a DAG (A -> B, A -> C, B -> D, C -> D)', () => { + const source = ` +config: + agent_name: "cycle-dag" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +trigger t: + kind: "a2a" + target: "brokers://cycle-dag/a2a" + on_message: -> + transition to @orchestrator.A + +orchestrator A: + description: "node A" + llm: @llm.g + reasoning: + instructions: -> + | work + on_exit: -> + transition to @router.R + +router R: + routes: + - target: @orchestrator.B + when: True + otherwise: + target: @orchestrator.C + +orchestrator B: + description: "node B" + llm: @llm.g + reasoning: + instructions: -> + | work + on_exit: -> + transition to @orchestrator.D + +orchestrator C: + description: "node C" + llm: @llm.g + reasoning: + instructions: -> + | work + on_exit: -> + transition to @orchestrator.D + +orchestrator D: + description: "node D" + llm: @llm.g + reasoning: + instructions: -> + | work + on_exit: -> + transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const result = parseAndLintSource(source); + expect(cycleDiagnostics(result.diagnostics)).toHaveLength(0); + }); + + it('reports a three-node cycle (A -> B -> C -> A) with paths rotated per node', () => { + const source = ` +config: + agent_name: "cycle-three" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +trigger t: + kind: "a2a" + target: "brokers://cycle-three/a2a" + on_message: -> + transition to @orchestrator.A + +orchestrator A: + description: "node A" + llm: @llm.g + reasoning: + instructions: -> + | work + on_exit: -> + transition to @orchestrator.B + +orchestrator B: + description: "node B" + llm: @llm.g + reasoning: + instructions: -> + | work + on_exit: -> + transition to @orchestrator.C + +orchestrator C: + description: "node C" + llm: @llm.g + reasoning: + instructions: -> + | work + on_exit: -> + transition to @orchestrator.A +`; + const result = parseAndLintSource(source); + const cycles = cycleDiagnostics(result.diagnostics); + expect(cycles).toHaveLength(3); + const messages = cycles.map(d => d.message).sort(); + expect(messages).toEqual([ + 'Cycle detected in execution flow: @orchestrator.A → @orchestrator.B → @orchestrator.C → @orchestrator.A', + 'Cycle detected in execution flow: @orchestrator.B → @orchestrator.C → @orchestrator.A → @orchestrator.B', + 'Cycle detected in execution flow: @orchestrator.C → @orchestrator.A → @orchestrator.B → @orchestrator.C', + ]); + }); + + it('detects an orphan cycle that is unreachable from triggers', () => { + const source = ` +config: + agent_name: "cycle-orphan" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +trigger t: + kind: "a2a" + target: "brokers://cycle-orphan/a2a" + on_message: -> + transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" + +orchestrator A: + description: "orphan A" + llm: @llm.g + reasoning: + instructions: -> + | work + on_exit: -> + transition to @orchestrator.B + +orchestrator B: + description: "orphan B" + llm: @llm.g + reasoning: + instructions: -> + | work + on_exit: -> + transition to @orchestrator.A +`; + const result = parseAndLintSource(source); + const cycles = cycleDiagnostics(result.diagnostics); + expect(cycles).toHaveLength(2); + const messages = cycles.map(d => d.message).sort(); + expect(messages).toEqual([ + 'Cycle detected in execution flow: @orchestrator.A → @orchestrator.B → @orchestrator.A', + 'Cycle detected in execution flow: @orchestrator.B → @orchestrator.A → @orchestrator.B', + ]); + }); + + it('emits two distinct cycle diagnostics on a node shared by two cycles (router fork)', () => { + const source = ` +config: + agent_name: "cycle-shared" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +trigger t: + kind: "a2a" + target: "brokers://cycle-shared/a2a" + on_message: -> + transition to @orchestrator.A + +orchestrator A: + description: "shared node" + llm: @llm.g + reasoning: + instructions: -> + | work + on_exit: -> + transition to @router.R + +router R: + routes: + - target: @orchestrator.B + when: True + - target: @orchestrator.C + when: False + otherwise: + target: @echo.done + +orchestrator B: + description: "node B" + llm: @llm.g + reasoning: + instructions: -> + | work + on_exit: -> + transition to @orchestrator.A + +orchestrator C: + description: "node C" + llm: @llm.g + reasoning: + instructions: -> + | work + on_exit: -> + transition to @orchestrator.A + +echo done: + kind: "a2a:response" + message: "ok" +`; + const result = parseAndLintSource(source); + const cycles = cycleDiagnostics(result.diagnostics); + const onA = cycles.filter( + d => + typeof d.message === 'string' && + d.message.startsWith( + 'Cycle detected in execution flow: @orchestrator.A → ' + ) + ); + expect(onA).toHaveLength(2); + const aMessages = onA.map(d => d.message).sort(); + expect(aMessages).toEqual([ + 'Cycle detected in execution flow: @orchestrator.A → @router.R → @orchestrator.B → @orchestrator.A', + 'Cycle detected in execution flow: @orchestrator.A → @router.R → @orchestrator.C → @orchestrator.A', + ]); + }); +}); + +describe('execute rules', () => { + it('reports execute-undeclared-input for undeclared with param in executor run', () => { + const source = `# @dialect: AGENTFABRIC=1.0-BETA + +config: + agent_name: "exec-input" + +actions: + billing: + target: "a2a://billing" + kind: "a2a:send_message" + inputs: + message: {} + +trigger t: + kind: "a2a" + target: "brokers://exec-input/a2a" + on_message: -> transition to @executor.run_billing + +executor run_billing: + do: -> + run @actions.billing + with unknown_param = "x" + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const result = parseAndLintSource(source); + expect( + result.diagnostics.some(d => d.code === 'execute-undeclared-input') + ).toBe(true); + }); + + it('reports execute-action-def for missing action definition', () => { + const source = `# @dialect: AGENTFABRIC=1.0-BETA + +config: + agent_name: "exec-noaction" + +actions: + other: + target: "mcp://conn" + kind: "mcp:tool" + tool_name: "tool" + +trigger t: + kind: "a2a" + target: "brokers://exec-noaction/a2a" + on_message: -> transition to @executor.run_it + +executor run_it: + do: -> + run @actions.nonexistent + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const result = parseAndLintSource(source); + expect(result.diagnostics.some(d => d.code === 'execute-action-def')).toBe( + true + ); + }); + + it('reports execute-action-def when no actions block is defined', () => { + const source = `# @dialect: AGENTFABRIC=1.0-BETA + +config: + agent_name: "exec-no-actions-block" + +trigger t: + kind: "a2a" + target: "brokers://exec-no-actions-block/a2a" + on_message: -> transition to @executor.run_it + +executor run_it: + do: -> + run @actions.nonexistent + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const result = parseAndLintSource(source); + expect(result.diagnostics.some(d => d.code === 'execute-action-def')).toBe( + true + ); + }); +}); + +describe('action-binding rules', () => { + it('reports action-binding-undeclared-input for undeclared with param on subagent action', () => { + const source = `# @dialect: AGENTFABRIC=1.0-BETA + +config: + agent_name: "binding-warn" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +actions: + my_tool: + target: "mcp://conn" + kind: "mcp:tool" + tool_name: "tool" + inputs: + foo: {} + +trigger t: + kind: "a2a" + target: "brokers://binding-warn/a2a" + on_message: -> transition to @subagent.worker + +subagent worker: + description: "test" + llm: @llm.g + reasoning: + instructions: -> go + actions: + invoke: @actions.my_tool + with bar = "wrong" + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const result = parseAndLintSource(source); + expect( + result.diagnostics.some(d => d.code === 'action-binding-undeclared-input') + ).toBe(true); + }); +}); + +describe('unknown-block rules', () => { + it('reports error for unknown top-level block', () => { + const source = ` +config: + agent_name: "unknown-block-test" + +foobar: + something: "value" + +trigger t: + kind: "a2a" + target: "brokers://unknown-block-test/a2a" + on_message: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const result = parseAndLintSource(source); + const unknownErrors = result.diagnostics.filter( + d => d.code === 'unknown-block' + ); + expect(unknownErrors).toHaveLength(1); + expect(unknownErrors[0].severity).toBe(1); + expect(unknownErrors[0].message).toContain('foobar'); + }); + + it('reports errors for multiple unknown top-level blocks', () => { + const source = ` +config: + agent_name: "multi-unknown" + +foo: + x: "1" + +bar: + y: "2" + +trigger t: + kind: "a2a" + target: "brokers://multi-unknown/a2a" + on_message: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const result = parseAndLintSource(source); + const unknownErrors = result.diagnostics.filter( + d => d.code === 'unknown-block' + ); + expect(unknownErrors).toHaveLength(2); + const messages = unknownErrors.map(d => d.message); + expect(messages.some(m => m.includes('foo'))).toBe(true); + expect(messages.some(m => m.includes('bar'))).toBe(true); + }); + + it('does not report unknown-block for valid blocks', () => { + const source = ` +config: + agent_name: "all-valid" + +llm: + default_llm: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +actions: + lookup: + target: "mcp://knowledge" + kind: "mcp:tool" + tool_name: "lookup" + +trigger t: + kind: "a2a" + target: "brokers://all-valid/a2a" + on_message: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const result = parseAndLintSource(source); + const unknownErrors = result.diagnostics.filter( + d => d.code === 'unknown-block' + ); + expect(unknownErrors).toHaveLength(0); + }); + + it('reports error for unknown field within a block', () => { + const source = ` +config: + agent_name: "unknown-field-test" + +echo done: + kind: "a2a:response" + message: "ok" + bogus_field: "should error" +`; + const result = parseAndLintSource(source); + const unknownFieldErrors = result.diagnostics.filter( + d => d.code === 'unknown-field' && d.severity === 1 + ); + expect(unknownFieldErrors).toHaveLength(1); + expect(unknownFieldErrors[0].message).toContain('bogus_field'); + }); + + it('reports error for deprecated router.choices as unknown-field', () => { + const source = ` +config: + agent_name: "deprecated-choices" + +router r: + choices: + - target: @echo.done + when: true + +echo done: + kind: "a2a:response" + message: "ok" +`; + const result = parseAndLintSource(source); + const unknownFieldErrors = result.diagnostics.filter( + d => d.code === 'unknown-field' && d.severity === 1 + ); + expect(unknownFieldErrors.length).toBeGreaterThanOrEqual(1); + expect(unknownFieldErrors.some(d => d.message.includes('choices'))).toBe( + true + ); + }); }); diff --git a/dialect/agentfabric/src/tests/llm-completions.test.ts b/dialect/agentfabric/src/tests/llm-completions.test.ts new file mode 100644 index 00000000..dc122338 --- /dev/null +++ b/dialect/agentfabric/src/tests/llm-completions.test.ts @@ -0,0 +1,113 @@ +/** + * Regression tests: field completions inside an LLM entry should include the + * variant-specific fields once `kind` is set. + * + * Bug: when the cursor is on a *blank* line inside an `llm` entry whose + * `kind` is already set (e.g. "OpenAI" / "Gemini"), the kind-specific fields + * (`reasoning_effort`, `thinking_level`) are missing from the completion list. + * When the user types even a single prefix character (e.g. `r`), they DO + * appear. + */ + +import { describe, it, expect } from 'vitest'; +import { getFieldCompletions } from '@agentscript/language'; +import { parseAndLintSource, testSchemaCtx } from './test-utils.js'; + +const INDENT8 = ' '.repeat(8); + +function completionLabelsAt( + source: string, + line: number, + character: number +): string[] { + const { ast } = parseAndLintSource(source); + const candidates = getFieldCompletions( + ast, + line, + character, + testSchemaCtx, + source + ); + return candidates.map(c => c.name); +} + +function build(...lines: string[]): string { + return ['# @dialect: AGENTFABRIC=1.0-BETA', ...lines].join('\n'); +} + +describe('LLM entry variant completions', () => { + it('blank line in OpenAI llm entry includes reasoning_effort and not thinking_level', () => { + const source = build( + 'llm:', + ' myLLM:', + ' target: "llm://connection_name"', + ' kind: "OpenAI"', + ' model: "The model name to use"', + INDENT8 + ); + + const lines = source.split('\n'); + const labels = completionLabelsAt(source, lines.length - 1, INDENT8.length); + + expect(labels).toContain('reasoning_effort'); + expect(labels).toContain('top_logprobs'); + expect(labels).not.toContain('thinking_level'); + }); + + it('blank line in Gemini llm entry includes thinking_level and not reasoning_effort', () => { + const source = build( + 'llm:', + ' myLLM4:', + ' target: "llm://connection_name"', + ' kind: "Gemini"', + ' model: "The model name to use"', + INDENT8 + ); + + const lines = source.split('\n'); + const labels = completionLabelsAt(source, lines.length - 1, INDENT8.length); + + expect(labels).toContain('thinking_level'); + expect(labels).toContain('thinking_budget'); + expect(labels).not.toContain('reasoning_effort'); + }); + + it('two sibling entries — variant fields are scoped to the matching kind', () => { + const source = build( + 'llm:', + ' myOpenAI:', + ' target: "llm://a"', + ' kind: "OpenAI"', + ' model: "m"', + ' myGemini:', + ' target: "llm://b"', + ' kind: "Gemini"', + ' model: "m"', + INDENT8 + ); + + const lines = source.split('\n'); + const labels = completionLabelsAt(source, lines.length - 1, INDENT8.length); + + // Cursor is inside myGemini (last entry) — only Gemini-specific fields. + expect(labels).toContain('thinking_level'); + expect(labels).not.toContain('reasoning_effort'); + }); + + it('blank line with no `kind` set falls back to base schema (no variant fields)', () => { + const source = build( + 'llm:', + ' myLLM:', + ' target: "llm://connection_name"', + ' model: "The model name to use"', + INDENT8 + ); + + const lines = source.split('\n'); + const labels = completionLabelsAt(source, lines.length - 1, INDENT8.length); + + expect(labels).toContain('kind'); + expect(labels).not.toContain('reasoning_effort'); + expect(labels).not.toContain('thinking_level'); + }); +}); diff --git a/dialect/agentfabric/src/tests/llm-value-completions.test.ts b/dialect/agentfabric/src/tests/llm-value-completions.test.ts new file mode 100644 index 00000000..f9ee629d --- /dev/null +++ b/dialect/agentfabric/src/tests/llm-value-completions.test.ts @@ -0,0 +1,193 @@ +/** + * Regression tests: value-position completions for enum-typed fields inside + * an LLM entry should include the enum members. + * + * Bug: when the cursor is at value position (after `key: `) for + * an enum-typed field, the LSP returns no completions for: + * - `kind:` → expected to suggest the discriminator enum members + * (OpenAI, Gemini) + * - `reasoning_effort:` (when `kind: "OpenAI"`) → expected to suggest its + * enum members (NONE, MINIMAL, LOW, MEDIUM, HIGH, XHIGH) + * - `thinking_level:` (when `kind: "Gemini"`) → expected to suggest its + * enum members (LOW, HIGH) + * + * These tests pin the expected behaviour for the upcoming fix in + * `dialect/agentfabric`. Today they FAIL because `getValueCompletions` only + * returns primitive type keywords for TypedMap-typed fields and never + * surfaces enum constraints attached to `StringValue.enum([...])` fields. + */ + +import { describe, it, expect } from 'vitest'; +import { getValueCompletions } from '@agentscript/language'; +import { parseDocument, testSchemaCtx } from './test-utils.js'; + +const INDENT8 = ' '.repeat(8); + +function valueCompletionLabelsAt( + source: string, + line: number, + character: number +): string[] { + const ast = parseDocument(source); + const candidates = getValueCompletions( + ast, + line, + character, + testSchemaCtx, + source + ); + return candidates.map(c => c.name); +} + +function valueCompletionInsertTextsAt( + source: string, + line: number, + character: number +): Array { + const ast = parseDocument(source); + const candidates = getValueCompletions( + ast, + line, + character, + testSchemaCtx, + source + ); + return candidates.map(c => c.insertText); +} + +function build(...lines: string[]): string { + return ['# @dialect: AGENTFABRIC=1.0-BETA', ...lines].join('\n'); +} + +describe('LLM entry value-position completions', () => { + it('after `kind: ` suggests discriminator enum members (OpenAI, Gemini)', () => { + const kindLine = `${INDENT8}kind: `; + const source = build( + 'llm:', + ' myLLM:', + ' target: "llm://connection_name"', + kindLine, + ' model: "The model name to use"' + ); + + const lines = source.split('\n'); + const kindLineIdx = lines.findIndex(l => l === kindLine); + expect(kindLineIdx).toBeGreaterThan(-1); + + const labels = valueCompletionLabelsAt( + source, + kindLineIdx, + kindLine.length + ); + + expect(labels).toContain('OpenAI'); + expect(labels).toContain('Gemini'); + // No leakage of TypedMap primitive keywords or unrelated values. + expect(labels).not.toContain('string'); + expect(labels).not.toContain('NONE'); + }); + + it('after `reasoning_effort: ` (kind=OpenAI) suggests OpenAI reasoning enum members', () => { + const reLine = `${INDENT8}reasoning_effort: `; + const source = build( + 'llm:', + ' myLLM:', + ' target: "llm://connection_name"', + ' kind: "OpenAI"', + ' model: "The model name to use"', + reLine + ); + + const lines = source.split('\n'); + const reLineIdx = lines.findIndex(l => l === reLine); + expect(reLineIdx).toBeGreaterThan(-1); + + const labels = valueCompletionLabelsAt(source, reLineIdx, reLine.length); + + expect(labels).toContain('NONE'); + expect(labels).toContain('MINIMAL'); + expect(labels).toContain('LOW'); + expect(labels).toContain('MEDIUM'); + expect(labels).toContain('HIGH'); + expect(labels).toContain('XHIGH'); + expect(labels).not.toContain('OpenAI'); + expect(labels).not.toContain('string'); + }); + + it('after `thinking_level: ` (kind=Gemini) suggests Gemini thinking enum members', () => { + const tlLine = `${INDENT8}thinking_level: `; + const source = build( + 'llm:', + ' myLLM:', + ' target: "llm://connection_name"', + ' kind: "Gemini"', + ' model: "The model name to use"', + tlLine + ); + + const lines = source.split('\n'); + const tlLineIdx = lines.findIndex(l => l === tlLine); + expect(tlLineIdx).toBeGreaterThan(-1); + + const labels = valueCompletionLabelsAt(source, tlLineIdx, tlLine.length); + + expect(labels).toContain('LOW'); + expect(labels).toContain('HIGH'); + expect(labels).not.toContain('Gemini'); + expect(labels).not.toContain('NONE'); + }); + + it('after `target: ` (plain StringValue, no enum) suggests nothing', () => { + const targetLine = `${INDENT8}target: `; + const source = build('llm:', ' myLLM:', targetLine); + + const lines = source.split('\n'); + const targetLineIdx = lines.findIndex(l => l === targetLine); + expect(targetLineIdx).toBeGreaterThan(-1); + + const labels = valueCompletionLabelsAt( + source, + targetLineIdx, + targetLine.length + ); + + expect(labels).toEqual([]); + }); + + it('enum members are inserted with surrounding double quotes', () => { + const kindLine = `${INDENT8}kind: `; + const source = build('llm:', ' myLLM:', kindLine); + + const lines = source.split('\n'); + const kindLineIdx = lines.findIndex(l => l === kindLine); + expect(kindLineIdx).toBeGreaterThan(-1); + + const inserts = valueCompletionInsertTextsAt( + source, + kindLineIdx, + kindLine.length + ); + + expect(inserts).toContain('"OpenAI"'); + expect(inserts).toContain('"Gemini"'); + }); + + it('after `reasoning_effort: ` with kind=Gemini (variant mismatch) suggests nothing', () => { + const reLine = `${INDENT8}reasoning_effort: `; + const source = build( + 'llm:', + ' myLLM:', + ' target: "llm://connection_name"', + ' kind: "Gemini"', + reLine + ); + + const lines = source.split('\n'); + const reLineIdx = lines.findIndex(l => l === reLine); + expect(reLineIdx).toBeGreaterThan(-1); + + const labels = valueCompletionLabelsAt(source, reLineIdx, reLine.length); + + expect(labels).toEqual([]); + }); +}); diff --git a/dialect/agentfabric/src/tests/resources/agentfabric-customer-support-netwrok.agent b/dialect/agentfabric/src/tests/resources/agentfabric-customer-support-netwrok.agent index b54d7ecb..d3163927 100644 --- a/dialect/agentfabric/src/tests/resources/agentfabric-customer-support-netwrok.agent +++ b/dialect/agentfabric/src/tests/resources/agentfabric-customer-support-netwrok.agent @@ -96,6 +96,9 @@ router route_request: - target: @subagent.technical_handler when: @generator.analyze_request.output.category == "technical" label: "Technical" + - target: @generator.analyze_request + when: @request.headers["Slack-UUID"] != "" + label: "Has Slack UUID" otherwise: target: @orchestrator.general_response @@ -119,6 +122,7 @@ subagent technical_handler: actions: kb_search: @actions.search_articles kb_get_article: @actions.get_article + with http_headers = {"Authorization": @request.headers.authorization, "X-CorrelationId": @variables.requestTimestamp} max_consecutive_errors: 20 task_timeout_secs: 10 max_number_of_loops: 5 @@ -148,7 +152,7 @@ echo send_response: state: "completed", message: a2a.message({ parts: [ - a2a.textPart(@variables.output_response) + a2a.textPart(@echo.send_response.input) ] }) } diff --git a/dialect/agentfabric/src/tests/resources/agentfabric-customer-support-netwrok.yaml b/dialect/agentfabric/src/tests/resources/agentfabric-customer-support-netwrok.yaml index af1422c8..a9b0d128 100644 --- a/dialect/agentfabric/src/tests/resources/agentfabric-customer-support-netwrok.yaml +++ b/dialect/agentfabric/src/tests/resources/agentfabric-customer-support-netwrok.yaml @@ -118,6 +118,16 @@ unifiedAgentSpec: data-type: string description: "" default: "" + - name: _handoff_source + data-type: string + default: null + label: "" + description: "" + - name: _node_input + data-type: string + default: null + label: "" + description: "" initial-node: set_context nodes: - name: general_response @@ -211,6 +221,8 @@ unifiedAgentSpec: after-reasoning: - type: handoff target: send_response + state-updates: + - _handoff_source: "'general_response'" - name: technical_handler label: null description: Handles technical support issues using knowledge-base tools. @@ -241,9 +253,14 @@ unifiedAgentSpec: - type: mcp_tool ref: get_article-client enabled: true + bound-inputs: + http_headers: "{\"authorization\": state.request.headers['authorization'], + \"x-correlationid\": state.requestTimestamp}" after-reasoning: - type: handoff target: send_response + state-updates: + - _handoff_source: "'technical_handler'" system-limits: max-reasoning-iterations: 5 max-consecutive-errors: 20 @@ -272,6 +289,8 @@ unifiedAgentSpec: after-reasoning: - type: handoff target: route_request + state-updates: + - _handoff_source: "'analyze_request'" - name: set_context type: action label: null @@ -282,7 +301,8 @@ unifiedAgentSpec: - requestTimestamp: now() - customerMessage: state.request.payload.message on-init: - - ref: IdentityAction + - type: action + ref: IdentityAction state-updates: - request: normalize_headers(variables['request']) on-exit: @@ -304,6 +324,8 @@ unifiedAgentSpec: on-exit: - type: handoff target: send_response + state-updates: + - _handoff_source: "'billing_handler'" add-tool-result-to-chat-history: false output-template: null - name: cleanup @@ -327,10 +349,15 @@ unifiedAgentSpec: on-exit: - type: handoff target: billing_handler - enabled: system.node_outputs['analyze_request'].category == "billing" + enabled: parse_json(system.node_outputs['analyze_request']).category == + "billing" - type: handoff target: technical_handler - enabled: system.node_outputs['analyze_request'].category == "technical" + enabled: parse_json(system.node_outputs['analyze_request']).category == + "technical" + - type: handoff + target: analyze_request + enabled: state.request.headers[lower("Slack-UUID")] != "" - type: handoff target: general_response add-tool-result-to-chat-history: false @@ -342,9 +369,13 @@ unifiedAgentSpec: - ref: IdentityAction state-updates: - __send_response_value: a2a_task(state="completed", - message=a2a_message(parts=[a2a_textPart(state.output_response)])) + message=a2a_message(parts=[a2a_textPart(state._node_input)])) - outputs: add(state.outputs, "send_response", state.__send_response_value) - on-init: null + on-init: + - type: action + ref: IdentityAction + state-updates: + - _node_input: get(system.node_outputs, state._handoff_source, '') on-exit: - type: handoff target: cleanup diff --git a/dialect/agentfabric/src/tests/resources/it-help-investigation.graph.json b/dialect/agentfabric/src/tests/resources/it-help-investigation.graph.json new file mode 100644 index 00000000..8a00c346 --- /dev/null +++ b/dialect/agentfabric/src/tests/resources/it-help-investigation.graph.json @@ -0,0 +1,218 @@ +{ + "nodes": [ + { + "id": "trigger.ticketTrigger", + "kind": "trigger", + "additionalProperties": { + "lexical-start-position": "57,2", + "lexical-end-position": "60,45" + } + }, + { + "id": "orchestrator.crossPlatformTriage", + "kind": "orchestrator", + "additionalProperties": { + "label": "Cross-Platform Triage", + "lexical-start-position": "147,2", + "lexical-end-position": "181,42" + } + }, + { + "id": "generator.classifySeverity", + "kind": "generator", + "additionalProperties": { + "label": "Classify Severity", + "lexical-start-position": "66,2", + "lexical-end-position": "102,40" + } + }, + { + "id": "generator.helpSummary", + "kind": "generator", + "additionalProperties": { + "lexical-start-position": "202,2", + "lexical-end-position": "208,36" + } + }, + { + "id": "generator.licenseSummary", + "kind": "generator", + "additionalProperties": { + "lexical-start-position": "227,2", + "lexical-end-position": "233,39" + } + }, + { + "id": "executor.escalateTicket", + "kind": "executor", + "additionalProperties": { + "lexical-start-position": "120,2", + "lexical-end-position": "128,42" + } + }, + { + "id": "executor.escalateUnresolved", + "kind": "executor", + "additionalProperties": { + "lexical-start-position": "252,2", + "lexical-end-position": "260,42" + } + }, + { + "id": "router.severityRouter", + "kind": "router", + "additionalProperties": { + "outputs": "High, otherwise", + "lexical-start-position": "108,2", + "lexical-end-position": "114,45" + } + }, + { + "id": "router.resolutionRouter", + "kind": "router", + "additionalProperties": { + "outputs": "License Given, Unresolved, otherwise", + "lexical-start-position": "187,2", + "lexical-end-position": "196,34" + } + }, + { + "id": "echo.escalationResponse", + "kind": "echo", + "additionalProperties": { + "lexical-start-position": "131,2", + "lexical-end-position": "141,4" + } + }, + { + "id": "echo.helpResponse", + "kind": "echo", + "additionalProperties": { + "lexical-start-position": "211,2", + "lexical-end-position": "221,4" + } + }, + { + "id": "echo.licenseResponse", + "kind": "echo", + "additionalProperties": { + "lexical-start-position": "236,2", + "lexical-end-position": "246,4" + } + }, + { + "id": "echo.unresolvedResponse", + "kind": "echo", + "additionalProperties": { + "lexical-start-position": "263,2", + "lexical-end-position": "273,4" + } + } + ], + "edges": [ + { + "from": "trigger.ticketTrigger", + "to": "generator.classifySeverity", + "additionalProperties": { + "lexical-start-position": "60,15", + "lexical-end-position": "60,45" + } + }, + { + "from": "orchestrator.crossPlatformTriage", + "to": "router.resolutionRouter", + "additionalProperties": { + "lexical-start-position": "181,15", + "lexical-end-position": "181,42" + } + }, + { + "from": "generator.classifySeverity", + "to": "router.severityRouter", + "additionalProperties": { + "lexical-start-position": "102,15", + "lexical-end-position": "102,40" + } + }, + { + "from": "generator.helpSummary", + "to": "echo.helpResponse", + "additionalProperties": { + "lexical-start-position": "208,15", + "lexical-end-position": "208,36" + } + }, + { + "from": "generator.licenseSummary", + "to": "echo.licenseResponse", + "additionalProperties": { + "lexical-start-position": "233,15", + "lexical-end-position": "233,39" + } + }, + { + "from": "executor.escalateTicket", + "to": "echo.escalationResponse", + "additionalProperties": { + "lexical-start-position": "128,15", + "lexical-end-position": "128,42" + } + }, + { + "from": "executor.escalateUnresolved", + "to": "echo.unresolvedResponse", + "additionalProperties": { + "lexical-start-position": "260,15", + "lexical-end-position": "260,42" + } + }, + { + "from": "router.severityRouter", + "to": "executor.escalateTicket", + "additionalProperties": { + "output": "High", + "predicate": "@generator.classifySeverity.output.severity == \"high\"", + "lexical-start-position": "110,14", + "lexical-end-position": "110,38" + } + }, + { + "from": "router.severityRouter", + "to": "orchestrator.crossPlatformTriage", + "additionalProperties": { + "output": "otherwise", + "lexical-start-position": "114,12", + "lexical-end-position": "114,45" + } + }, + { + "from": "router.resolutionRouter", + "to": "generator.licenseSummary", + "additionalProperties": { + "output": "License Given", + "predicate": "@orchestrator.crossPlatformTriage.output.resolution == \"license_given\"", + "lexical-start-position": "189,14", + "lexical-end-position": "189,39" + } + }, + { + "from": "router.resolutionRouter", + "to": "executor.escalateUnresolved", + "additionalProperties": { + "output": "Unresolved", + "predicate": "@orchestrator.crossPlatformTriage.output.resolution == \"unresolved\"", + "lexical-start-position": "192,14", + "lexical-end-position": "192,42" + } + }, + { + "from": "router.resolutionRouter", + "to": "generator.helpSummary", + "additionalProperties": { + "output": "otherwise", + "lexical-start-position": "196,12", + "lexical-end-position": "196,34" + } + } + ] +} diff --git a/dialect/agentfabric/src/tests/resources/it-help-investigation.yaml b/dialect/agentfabric/src/tests/resources/it-help-investigation.yaml index af13225a..c13d1ce7 100644 --- a/dialect/agentfabric/src/tests/resources/it-help-investigation.yaml +++ b/dialect/agentfabric/src/tests/resources/it-help-investigation.yaml @@ -144,6 +144,16 @@ unifiedAgentSpec: description: Map of action/execute node outputs in state namespace (agent outputs are in system.node_outputs) default: {} + - name: _handoff_source + data-type: string + default: null + label: "" + description: "" + - name: _node_input + data-type: string + default: null + label: "" + description: "" initial-node: classifySeverity nodes: - name: crossPlatformTriage @@ -247,13 +257,15 @@ unifiedAgentSpec: ref: updateIssue-client enabled: true bound-inputs: - ticket_id: system.node_outputs['classifySeverity'].ticket_id + ticket_id: parse_json(system.node_outputs['classifySeverity']).ticket_id llm-inputs: - status - comment after-reasoning: - type: handoff target: resolutionRouter + state-updates: + - _handoff_source: "'crossPlatformTriage'" - name: classifySeverity label: Classify Severity description: Classifies the severity of the support ticket. @@ -264,7 +276,8 @@ unifiedAgentSpec: model: gemini-2.5-pro output-structure-ref: os_classifySeverity on-init: - - ref: IdentityAction + - type: action + ref: IdentityAction state-updates: - request: normalize_headers(variables['request']) system-prompt: "| {{state.request.payload.message.parts[0].text}}" @@ -304,6 +317,8 @@ unifiedAgentSpec: after-reasoning: - type: handoff target: severityRouter + state-updates: + - _handoff_source: "'classifySeverity'" - name: helpSummary label: null description: Generates a summary of the help resolution. @@ -315,12 +330,15 @@ unifiedAgentSpec: on-init: null system-prompt: "| Generate a resolution summary for the user. Original request: {{state.request.payload.message.parts[0].text}}. Resolution and - actions taken: {{system.node_outputs['crossPlatformTriage'].summary}}" + actions taken: + {{parse_json(system.node_outputs['crossPlatformTriage']).summary}}" focus-prompt: You generate clear, friendly summaries of IT help desk resolutions. tools: null after-reasoning: - type: handoff target: helpResponse + state-updates: + - _handoff_source: "'helpSummary'" - name: licenseSummary label: null description: Generates a summary of the license provisioning. @@ -333,12 +351,14 @@ unifiedAgentSpec: system-prompt: "| Generate a license provisioning summary for the user. Original request: {{state.request.payload.message.parts[0].text}}. Resolution and actions taken: - {{system.node_outputs['crossPlatformTriage'].summary}}" + {{parse_json(system.node_outputs['crossPlatformTriage']).summary}}" focus-prompt: You generate clear, friendly summaries of license provisioning actions. tools: null after-reasoning: - type: handoff target: licenseResponse + state-updates: + - _handoff_source: "'licenseSummary'" - name: escalateTicket type: action label: null @@ -346,16 +366,18 @@ unifiedAgentSpec: - type: action ref: escalate-action bound-inputs: - ticket_id: system.node_outputs['classifySeverity'].ticket_id + ticket_id: parse_json(system.node_outputs['classifySeverity']).ticket_id severity: '"high"' - reason: system.node_outputs['classifySeverity'].reason + reason: parse_json(system.node_outputs['classifySeverity']).reason description: state.request.payload.message.parts[0].text state-updates: - - outputs: add(state.outputs, "escalateTicket", result["result"]) + - outputs: add(state.outputs, "escalateTicket", result["content"]) on-init: null on-exit: - type: handoff target: escalationResponse + state-updates: + - _handoff_source: "'escalateTicket'" add-tool-result-to-chat-history: false output-template: null - name: escalateUnresolved @@ -365,16 +387,18 @@ unifiedAgentSpec: - type: action ref: escalate-action bound-inputs: - ticket_id: system.node_outputs['classifySeverity'].ticket_id + ticket_id: parse_json(system.node_outputs['classifySeverity']).ticket_id severity: '"low"' - reason: system.node_outputs['crossPlatformTriage'].summary + reason: parse_json(system.node_outputs['crossPlatformTriage']).summary description: state.request.payload.message.parts[0].text state-updates: - - outputs: add(state.outputs, "escalateUnresolved", result["result"]) + - outputs: add(state.outputs, "escalateUnresolved", result["content"]) on-init: null on-exit: - type: handoff target: unresolvedResponse + state-updates: + - _handoff_source: "'escalateUnresolved'" add-tool-result-to-chat-history: false output-template: null - name: severityRouter @@ -385,7 +409,7 @@ unifiedAgentSpec: on-exit: - type: handoff target: escalateTicket - enabled: system.node_outputs['classifySeverity'].severity == "high" + enabled: parse_json(system.node_outputs['classifySeverity']).severity == "high" - type: handoff target: crossPlatformTriage add-tool-result-to-chat-history: false @@ -398,11 +422,12 @@ unifiedAgentSpec: on-exit: - type: handoff target: licenseSummary - enabled: system.node_outputs['crossPlatformTriage'].resolution == + enabled: parse_json(system.node_outputs['crossPlatformTriage']).resolution == "license_given" - type: handoff target: escalateUnresolved - enabled: system.node_outputs['crossPlatformTriage'].resolution == "unresolved" + enabled: parse_json(system.node_outputs['crossPlatformTriage']).resolution == + "unresolved" - type: handoff target: helpSummary add-tool-result-to-chat-history: false @@ -416,9 +441,10 @@ unifiedAgentSpec: - __escalationResponse_value: "a2a_task(state=\"completed\", message=a2a_message(messageId=uuid(), parts=[a2a_textPart(\"Ticket \" + - system.node_outputs['classifySeverity'].ticket_id + \" has - been escalated to the on-call team due to high severity: \" + - system.node_outputs['classifySeverity'].reason)]), + parse_json(system.node_outputs['classifySeverity']).ticket_id + + \" has been escalated to the on-call team due to high + severity: \" + + parse_json(system.node_outputs['classifySeverity']).reason)]), metadata=None)" - outputs: add(state.outputs, "escalationResponse", state.__escalationResponse_value) @@ -465,11 +491,11 @@ unifiedAgentSpec: - __unresolvedResponse_value: "a2a_task(state=\"completed\", message=a2a_message(messageId=uuid(), parts=[a2a_textPart(\"Ticket \" + - system.node_outputs['classifySeverity'].ticket_id + \" could - not be resolved automatically and has been escalated to a - human agent. Summary: \" + - system.node_outputs['crossPlatformTriage'].summary)]), - metadata=None)" + parse_json(system.node_outputs['classifySeverity']).ticket_id + + \" could not be resolved automatically and has been + escalated to a human agent. Summary: \" + + parse_json(system.node_outputs['crossPlatformTriage']).summar\ + y)]), metadata=None)" - outputs: add(state.outputs, "unresolvedResponse", state.__unresolvedResponse_value) on-init: null diff --git a/dialect/agentfabric/src/tests/snippet-indentation.test.ts b/dialect/agentfabric/src/tests/snippet-indentation.test.ts new file mode 100644 index 00000000..3510509d --- /dev/null +++ b/dialect/agentfabric/src/tests/snippet-indentation.test.ts @@ -0,0 +1,580 @@ +/** + * Indentation guardrails for completion snippets in AgentFabric `.agent` files. + * + * Bug: when a multi-line completion snippet is inserted at a + * cursor that is already indented, nested entries inside the snippet are + * indented too deeply relative to the indentation step the user is already + * using in the document. + * + * The contract we pin: + * "Indent step is consistent — same number of spaces at every nesting + * level, relative to the cursor, and that step matches the document's + * existing step." + * + * Concretely: + * - If the surrounding document uses 2-space indents, the snippet body's + * first nested line should be cursor + 2, second nested at cursor + 4, + * etc. + * - If the surrounding document uses 4-space indents, step is 4. + * + * The snippet generator currently hardcodes `tabSize = 4` regardless of the + * document's actual step. The repro in the bug uses a 2-space-step document + * with the cursor at column 4, and `inputs:` produces a body whose first + * nested entry sits at indent 8 (cursor 4 + step 4 from generator) instead of + * the expected indent 6 (cursor 4 + step 2 from the document). The second + * nested line sits at indent 12 instead of the expected 8. + * + * Snippet inflow: `getFieldCompletions` returns the raw snippet (column 0 + * baseline). The LSP layer forwards it verbatim; the host editor (VS Code's + * snippet engine, mirrored by Monaco) prepends the cursor's leading + * whitespace to lines 2+ during insertion per LSP semantics. We replicate + * that host-editor step here so assertions reason about the indentation + * the user actually sees. + */ + +import { describe, it, expect } from 'vitest'; +import { + getFieldCompletions, + type CompletionCandidate, +} from '@agentscript/language'; +import { parseAndLintSource, testSchemaCtx } from './test-utils.js'; + +/** Mirrors VS Code/Monaco's snippet engine cursor-indent prepend on insert. */ +function applyCursorIndent(snippet: string, baseIndent: number): string { + const lines = snippet.split('\n'); + if (lines.length <= 1) return snippet; + const indentStr = ' '.repeat(baseIndent); + return lines.map((ln, i) => (i === 0 ? ln : indentStr + ln)).join('\n'); +} + +/** Strip LSP snippet markers (`${1:foo}`, `${1|a,b|}`, `$0`) but keep raw text. */ +function stripSnippetMarkers(s: string): string { + return s + .replace(/\$\{\d+:([^}]*)\}/g, '$1') + .replace(/\$\{\d+\|([^}]*)\|\}/g, '$1') + .replace(/\$\{\d+\}/g, '') + .replace(/\$0/g, ''); +} + +function leadingSpaces(line: string): number { + const m = line.match(/^ */); + return m ? m[0].length : 0; +} + +function getCandidate( + source: string, + line: number, + character: number, + name: string +): CompletionCandidate { + const { ast } = parseAndLintSource(source); + const candidates = getFieldCompletions( + ast, + line, + character, + testSchemaCtx, + source + ); + const cand = candidates.find(c => c.name === name); + if (!cand) { + throw new Error( + `No candidate named "${name}" — got: ${candidates + .map(c => c.name) + .join(', ')}` + ); + } + return cand; +} + +/** + * Return the leading-space counts of every body line of the rendered + * snippet (excluding the header line 0 which inherits the cursor indent). + * We deliberately KEEP lines that become whitespace-only after stripping + * snippet markers (e.g. a `${cursor}` placeholder on its own line) — those + * are real lines whose indent we still want to assert on. + */ +function bodyIndents(rendered: string): number[] { + const lines = rendered.split('\n'); + return lines.slice(1).map(leadingSpaces); +} + +function build(...lines: string[]): string { + return ['# @dialect: AGENTFABRIC=1.0-BETA', ...lines].join('\n'); +} + +// --------------------------------------------------------------------------- +// Scope 1: Action-level fields — primary bug repro +// --------------------------------------------------------------------------- + +describe('snippet indentation — action-level fields (primary bug repro)', () => { + /** + * Document uses 2-space indent step. Cursor at column 4 (one body step + * inside an action entry). Completing `inputs:` should produce: + * + * inputs: <- at cursor (col 4) + * ${1:Name}: <- cursor + 1*step_doc = 6 + * ${2} <- cursor + 2*step_doc = 8 + * + * Today the body lines land at 8 and 12 because the generator uses + * step=4 unconditionally. + */ + it('inputs: nested entry at cursor + 1*doc-step (2-space doc, cursor 4)', () => { + const docStep = 2; + const cursorIndent = 4; + const source = build( + 'actions:', + ' escalate_ticket:', + ' target: "mcp://x"', + ' kind: "mcp:tool"', + ' tool_name: "escalate"', + ' ' + ); + const lines = source.split('\n'); + const lastLine = lines.length - 1; + + const cand = getCandidate(source, lastLine, cursorIndent, 'inputs'); + expect( + cand.snippet, + 'inputs candidate should expose a snippet' + ).toBeDefined(); + + const rendered = stripSnippetMarkers( + applyCursorIndent(cand.snippet!, cursorIndent) + ); + const indents = bodyIndents(rendered); + + expect( + indents.length, + `expected at least 2 non-blank body lines\nRendered:\n${rendered}` + ).toBeGreaterThanOrEqual(2); + + expect( + indents[0], + `entry-name line should sit at cursor + 1*docStep (${cursorIndent + docStep})\nRendered:\n${rendered}` + ).toBe(cursorIndent + docStep); + expect( + indents[1], + `entry-body line should sit at cursor + 2*docStep (${cursorIndent + 2 * docStep})\nRendered:\n${rendered}` + ).toBe(cursorIndent + 2 * docStep); + }); + + /** + * Same field in a 4-space-step document: today this works because the + * generator's hardcoded step (4) happens to match. Pin it so any fix + * doesn't regress this case. + */ + it('inputs: nested entry at cursor + 1*doc-step (4-space doc, cursor 8)', () => { + const docStep = 4; + const cursorIndent = 8; + const source = build( + 'actions:', + ' escalate_ticket:', + ' target: "mcp://x"', + ' kind: "mcp:tool"', + ' tool_name: "escalate"', + ' ' + ); + const lines = source.split('\n'); + const lastLine = lines.length - 1; + + const cand = getCandidate(source, lastLine, cursorIndent, 'inputs'); + const rendered = stripSnippetMarkers( + applyCursorIndent(cand.snippet!, cursorIndent) + ); + const indents = bodyIndents(rendered); + expect(indents[0]).toBe(cursorIndent + docStep); + expect(indents[1]).toBe(cursorIndent + 2 * docStep); + }); +}); + +// --------------------------------------------------------------------------- +// Scope 2: Top-level fields — orchestrator is the user-confirmed reference +// --------------------------------------------------------------------------- + +describe('snippet indentation — top-level fields', () => { + /** + * Reference test: at the root (cursor indent 0), the document hasn't + * declared its step yet. The generator's default (4) is the de-facto + * convention and the user reports `orchestrator` works correctly here. + * Pin: every body line is at a positive multiple of 4. + */ + it('orchestrator: top-level snippet body at multiples of 4 from cursor 0', () => { + const source = build(''); + const cand = getCandidate(source, 1, 0, 'orchestrator'); + const rendered = stripSnippetMarkers(applyCursorIndent(cand.snippet!, 0)); + const indents = bodyIndents(rendered); + expect(indents.length).toBeGreaterThan(0); + for (const indent of indents) { + expect(indent).toBeGreaterThan(0); + expect(indent % 4).toBe(0); + } + }); + + it('subagent: top-level snippet body at multiples of 4 from cursor 0', () => { + const source = build(''); + const cand = getCandidate(source, 1, 0, 'subagent'); + const rendered = stripSnippetMarkers(applyCursorIndent(cand.snippet!, 0)); + const indents = bodyIndents(rendered); + expect(indents.length).toBeGreaterThan(0); + for (const indent of indents) { + expect(indent).toBeGreaterThan(0); + expect(indent % 4).toBe(0); + } + }); + + it('actions: top-level CollectionBlock snippet body at multiples of 4 from cursor 0', () => { + const source = build(''); + const cand = getCandidate(source, 1, 0, 'actions'); + const rendered = stripSnippetMarkers(applyCursorIndent(cand.snippet!, 0)); + const indents = bodyIndents(rendered); + expect(indents.length).toBeGreaterThan(0); + for (const indent of indents) { + expect(indent).toBeGreaterThan(0); + expect(indent % 4).toBe(0); + } + }); + + it('trigger: top-level snippet body at multiples of 4 from cursor 0', () => { + const source = build(''); + const cand = getCandidate(source, 1, 0, 'trigger'); + const rendered = stripSnippetMarkers(applyCursorIndent(cand.snippet!, 0)); + const indents = bodyIndents(rendered); + expect(indents.length).toBeGreaterThan(0); + for (const indent of indents) { + expect(indent).toBeGreaterThan(0); + expect(indent % 4).toBe(0); + } + }); + + /** + * Same orchestrator snippet, but in a 2-space-step document. The user's + * rule says the snippet's step should match the document's step. Today + * orchestrator's body lines are at 4, 4, 8, 4, 8 — multiples of 4, so + * inserting at cursor 0 in a 2-space doc gives "4 spaces" which is two + * doc-steps: too deep. + */ + it('orchestrator: nested body matches doc step (2-space doc, cursor 0)', () => { + const docStep = 2; + const source = build( + 'system:', + ' instructions: "x"', + '' // blank trailing + ); + const lines = source.split('\n'); + const lastLine = lines.length - 1; + const cand = getCandidate(source, lastLine, 0, 'orchestrator'); + const rendered = stripSnippetMarkers(applyCursorIndent(cand.snippet!, 0)); + const indents = bodyIndents(rendered); + + // Every body indent should be a multiple of docStep (2). The current + // generator emits multiples of 4, which are also multiples of 2 — so + // this assertion alone passes today. We additionally pin that no body + // line skips more than one doc-step from the previous one. + let prev = 0; + for (const indent of indents) { + expect(indent % docStep).toBe(0); + // Indent jumps relative to previous body line should be at most + // ±docStep (i.e. siblings at same depth, or one step in/out). + const delta = Math.abs(indent - prev); + expect( + delta <= docStep, + `body line indent ${indent} jumps from ${prev} (delta ${delta}) — should change by at most one doc-step (${docStep})\nRendered:\n${rendered}` + ).toBe(true); + prev = indent; + } + }); +}); + +// --------------------------------------------------------------------------- +// Scope 3: Nested-and-not-at-root — the critical bug surface +// --------------------------------------------------------------------------- + +describe('snippet indentation — nested compound children (not at root)', () => { + /** + * Cursor inside a subagent's `reasoning:` section, in a 2-space-step + * document. Completing a CollectionBlock-typed field (`actions`) should + * produce body lines at cursor + N*docStep for N = 1, 2, … + */ + it('reasoning.actions in 2-space doc: body at cursor + N*doc-step', () => { + const docStep = 2; + const cursorIndent = 4; + const source = build( + 'subagent triage_agent:', + ' reasoning:', + ' instructions: "do work"', + ' ' + ); + const lines = source.split('\n'); + const lastLine = lines.length - 1; + + const cand = getCandidate(source, lastLine, cursorIndent, 'actions'); + const rendered = stripSnippetMarkers( + applyCursorIndent(cand.snippet!, cursorIndent) + ); + const indents = bodyIndents(rendered); + + expect( + indents.length, + `expected non-empty body for nested actions\nRendered:\n${rendered}` + ).toBeGreaterThan(0); + + // Each body line should be at cursorIndent + N*docStep for some N >= 1. + for (const indent of indents) { + const offset = indent - cursorIndent; + expect( + offset > 0 && offset % docStep === 0, + `nested body line at indent ${indent} has offset ${offset} from cursor — should be a positive multiple of doc step ${docStep}\nRendered:\n${rendered}` + ).toBe(true); + } + }); + + /** + * Same nested context, but using a string-valued field whose snippet is + * still multi-line because it's a compound (outputs is a Block with + * `properties: CollectionBlock(...)`). Today `outputs` produces just a + * one-line body (`${cursor}`) because no children pass the depth-1 filter, + * so this test pins the consistent behaviour. + */ + it('reasoning.outputs in 2-space doc: every body line offset is multiple of doc-step', () => { + const docStep = 2; + const cursorIndent = 4; + const source = build( + 'subagent triage_agent:', + ' reasoning:', + ' instructions: "do work"', + ' ' + ); + const lines = source.split('\n'); + const lastLine = lines.length - 1; + + const cand = getCandidate(source, lastLine, cursorIndent, 'outputs'); + const rendered = stripSnippetMarkers( + applyCursorIndent(cand.snippet!, cursorIndent) + ); + const indents = bodyIndents(rendered); + + expect( + indents.length, + `outputs snippet should expose a body — assertion is vacuous otherwise\nRendered:\n${rendered}` + ).toBeGreaterThan(0); + // Require at least one body line at a positive offset from the cursor + // — otherwise the per-line "positive multiple of docStep" assertion + // could pass vacuously on a snippet body of just `${cursor}` placeholders. + expect( + indents.some(i => i > cursorIndent), + `outputs snippet should expose at least one indented (positive-offset) body line\nRendered:\n${rendered}` + ).toBe(true); + for (const indent of indents) { + const offset = indent - cursorIndent; + // Allow zero-offset placeholder lines; require positive-offset lines + // to land on a docStep boundary. + if (offset === 0) continue; + expect( + offset > 0 && offset % docStep === 0, + `nested outputs body line at indent ${indent} offset ${offset} — should be positive multiple of ${docStep}\nRendered:\n${rendered}` + ).toBe(true); + } + }); +}); + +// --------------------------------------------------------------------------- +// Scope 5: Hardening — clamp boundaries, mixed-step docs, block-scalar +// immunity. These pin the heuristic's edge-case behavior so future tweaks +// don't silently regress. +// --------------------------------------------------------------------------- + +describe('snippet indentation — heuristic hardening', () => { + /** + * 1-space-step documents are below MIN_INDENT_STEP=2 and should fall + * through to DEFAULT_INDENT_STEP=4. The fix should not honour a doc step + * we've explicitly excluded. + */ + it('1-space doc falls back to default step (4)', () => { + const source = build('actions:', ' escalate_ticket:', ' target: "x"', ''); + const lines = source.split('\n'); + const cand = getCandidate(source, lines.length - 1, 0, 'subagent'); + const rendered = stripSnippetMarkers(applyCursorIndent(cand.snippet!, 0)); + const indents = bodyIndents(rendered); + expect(indents.length).toBeGreaterThan(0); + // Default step = 4. Every body line indent should be a multiple of 4. + for (const indent of indents) { + expect(indent % 4).toBe(0); + } + }); + + /** + * 10-space-step docs exceed MAX_INDENT_STEP=8 and should fall back to + * the default. (Outlandish, but pins the upper clamp.) + */ + it('10-space doc falls back to default step (4)', () => { + const source = build( + 'actions:', + ' escalate_ticket:', + ' target: "x"', + '' + ); + const lines = source.split('\n'); + const cand = getCandidate(source, lines.length - 1, 0, 'subagent'); + const rendered = stripSnippetMarkers(applyCursorIndent(cand.snippet!, 0)); + const indents = bodyIndents(rendered); + expect(indents.length).toBeGreaterThan(0); + for (const indent of indents) { + expect(indent % 4).toBe(0); + } + }); + + /** + * Mixed-step doc — first structural pair the AST exposes wins. With a + * 2-space top-level pair followed by a 4-space sub-block, the heuristic + * picks 2 (most authoritative top-level signal). Pin this so refactors + * to "mode of all observations" or "deepest pair" don't sneak through + * silently. + */ + it('mixed-step doc: first structural pair wins (2 over 4)', () => { + const source = build( + 'actions:', + ' outer:', + ' target: "x"', + ' kind: "mcp:tool"', + ' tool_name: "x"', + '' + ); + const lines = source.split('\n'); + const cand = getCandidate(source, lines.length - 1, 0, 'subagent'); + const rendered = stripSnippetMarkers(applyCursorIndent(cand.snippet!, 0)); + const indents = bodyIndents(rendered); + expect(indents.length).toBeGreaterThan(0); + // First-pair-wins: should be 2-step, so first body line at 2, not 4. + const positive = indents.filter(i => i > 0); + expect(positive.length).toBeGreaterThan(0); + expect(Math.min(...positive)).toBe(2); + for (const indent of indents) { + expect(indent % 2).toBe(0); + } + }); + + /** + * Block-scalar pollution immunity. A doc whose ONLY non-trivial nested + * content lives in a multi-line string body should still resolve to the + * default step — string content is StringLiteral leaves, not nested + * AstNodeLike children, so an AST-aware walk skips it entirely. + * + * Concretely: `system.instructions: |` followed by deeply-indented prose + * must not push the heuristic to whatever step the prose happens to + * use. We use 3-space prose indent (which would be NEW data if it + * leaked) inside an otherwise-unstructured doc to stress the immunity. + */ + /** + * `getFieldCompletions` must accept calls without `source` (e.g. the + * `service.ts` API) and fall back to the default step. Pin the default + * so future refactors don't quietly change it. + */ + it('caller without source falls back to default step (4)', () => { + const source = build(''); + const { ast } = parseAndLintSource(source); + const candidates = getFieldCompletions( + ast, + 1, + 0, + testSchemaCtx + // intentionally omit `source` to exercise the fallback branch + ); + const cand = candidates.find(c => c.name === 'subagent'); + expect(cand?.snippet).toBeDefined(); + const rendered = stripSnippetMarkers(applyCursorIndent(cand!.snippet!, 0)); + const indents = bodyIndents(rendered); + expect(indents.length).toBeGreaterThan(0); + for (const indent of indents) { + expect(indent % 4).toBe(0); + } + }); + + it('indented multi-line string content does not pollute the step', () => { + const source = build( + 'system:', + ' instructions: |', + ' line one', + ' line two with deeper prose indent', + '' + ); + const lines = source.split('\n'); + const cand = getCandidate(source, lines.length - 1, 0, 'subagent'); + const rendered = stripSnippetMarkers(applyCursorIndent(cand.snippet!, 0)); + const indents = bodyIndents(rendered); + expect(indents.length).toBeGreaterThan(0); + // The structural step is 4 (system → instructions). Prose at +3 / +6 + // inside the block-scalar must NOT be picked up — that would yield 3. + // Every body line should be a multiple of 4. + for (const indent of indents) { + expect(indent % 4).toBe(0); + } + }); +}); + +// --------------------------------------------------------------------------- +// Scope 4: Internal consistency — the snippet's body uses ONE step throughout +// --------------------------------------------------------------------------- + +describe('snippet indentation — internal step consistency', () => { + /** + * Verify that within a single snippet, all body indent offsets (from the + * minimum body indent) are integer multiples of a single step value. + * This tests the user's rule "same number of spaces at every nesting + * level" without committing to what the step is. + */ + function assertSingleStep( + snippet: string | undefined, + label: string, + fallbackStep = 4 + ) { + expect(snippet, `${label}: missing snippet`).toBeDefined(); + const stripped = stripSnippetMarkers(snippet!); + const indents = bodyIndents(stripped); + if (indents.length === 0) return; // single-line — vacuous + const min = Math.min(...indents); + // Step is the GCD-like consistent unit: all offsets must be multiples + // of one shared positive integer. Check via successive offsets. + const offsets = indents.map(i => i - min); + // Find the minimum non-zero offset — that's the step. + const nonZero = offsets.filter(o => o > 0); + const step = nonZero.length > 0 ? Math.min(...nonZero) : fallbackStep; + for (const o of offsets) { + expect( + o % step, + `${label}: body indent offset ${o} is not a multiple of step ${step}\nIndents: ${JSON.stringify(indents)}\nSnippet:\n${stripped}` + ).toBe(0); + } + } + + it('inputs (action) — single step throughout', () => { + const source = build( + 'actions:', + ' escalate_ticket:', + ' target: "mcp://x"', + ' kind: "mcp:tool"', + ' tool_name: "escalate"', + ' ' + ); + const lines = source.split('\n'); + const cand = getCandidate(source, lines.length - 1, 4, 'inputs'); + assertSingleStep(cand.snippet, 'inputs'); + }); + + it('subagent (top-level) — single step throughout', () => { + const source = build(''); + const cand = getCandidate(source, 1, 0, 'subagent'); + assertSingleStep(cand.snippet, 'subagent'); + }); + + it('actions (top-level) — single step throughout', () => { + const source = build(''); + const cand = getCandidate(source, 1, 0, 'actions'); + assertSingleStep(cand.snippet, 'actions'); + }); + + it('orchestrator (top-level) — single step throughout', () => { + const source = build(''); + const cand = getCandidate(source, 1, 0, 'orchestrator'); + assertSingleStep(cand.snippet, 'orchestrator'); + }); +}); diff --git a/dialect/agentscript/src/schema.ts b/dialect/agentscript/src/schema.ts index ef12f723..10c539bd 100644 --- a/dialect/agentscript/src/schema.ts +++ b/dialect/agentscript/src/schema.ts @@ -196,9 +196,9 @@ export const ReasoningBlock = Block( ); export const baseSubagentFields = { - label: StringValue.describe('Display label shown in the UI.').accepts([ - 'StringLiteral', - ]), + label: StringValue.describe('Display label shown in the UI.') + .accepts(['StringLiteral']) + .displayLabelField(), description: StringValue.describe( 'Block description. Influences transitions to this block.' ).required(), diff --git a/packages/language/src/core/analysis/completions.ts b/packages/language/src/core/analysis/completions.ts index d2a01887..9fb47b09 100644 --- a/packages/language/src/core/analysis/completions.ts +++ b/packages/language/src/core/analysis/completions.ts @@ -49,6 +49,12 @@ export interface CompletionCandidate { documentation?: string; /** Auto-generated LSP snippet text with tab stops, for compound fields. */ snippet?: string; + /** + * Text actually inserted at the cursor. Defaults to `name` when absent. + * Used by enum-value candidates that need quoting (e.g. label `OpenAI`, + * insertText `"OpenAI"`). + */ + insertText?: string; } /** @@ -501,6 +507,7 @@ export function getFieldCompletions( ): CompletionCandidate[] { const rootSchema = ctx.info.schema; const aliases = ctx.info.aliases; + const tabSize = source ? inferIndentStep(ast, source) : DEFAULT_INDENT_STEP; let result = findEnclosingBlockWithSchema(ast, line, character, rootSchema); @@ -546,7 +553,7 @@ export function getFieldCompletions( name: key, kind: fieldCompletionKind(ft), documentation: ft.__metadata?.description, - snippet: generateFieldSnippet(key, ft), + snippet: generateFieldSnippet(key, ft, { tabSize }), }; }); } @@ -567,68 +574,104 @@ export function getFieldCompletions( name, kind: fieldCompletionKind(fieldType), documentation: fieldType.__metadata?.description, - snippet: generateFieldSnippet(name, fieldType), + snippet: generateFieldSnippet(name, fieldType, { tabSize }), }; }); } /** - * Indentation-based inference for schema context. + * Resolved schema context derived from the cursor's indentation and the AST. + * + * Shared between `getFieldCompletions` (via `inferBlockFromIndentation`) and + * `getValueCompletions`. Walking parents twice with two slightly divergent + * implementations was the bug source; both callers now use + * this single resolver. + */ +interface IndentSchemaContext { + /** Parent keys at strictly decreasing indents (root → cursor's parent). */ + parents: Array<{ + key: string; + indent: number; + line: number; + entryName?: string; + }>; + /** Schema at the cursor's nesting level after variant/TypedMap resolution. */ + schema: Schema | Record; + /** + * Whether the cursor sits at a level where users type entry names (rather + * than field keys / values). + * 'none' → inside a regular block; field/enum completions apply. + * 'named' → entry-name level of a NamedMap or CollectionBlock. + * 'typed' → entry-name level of a TypedMap; propertiesSchema fields apply. + */ + mapLevel: 'none' | 'named' | 'typed'; + /** + * The TypedMap field whose primitive-type keywords (`string`, `number`, …) + * should be offered at value position. Set only when the cursor's parent is + * a TypedMap entry. Independent of `mapLevel`; primitive keywords appear + * after the colon on a TypedMap entry's value side. + */ + typedMapField: FieldType | null; + /** Cached source split into lines (avoid re-splitting in the caller). */ + lines: string[]; + /** Indent of the cursor line, in columns. */ + cursorIndent: number; + /** Cursor line content (trimmed and raw both useful). */ + cursorLineRaw: string; +} + +/** + * Walk parents from the cursor up to the root and resolve the matching + * schema, mirroring discriminant-based variant resolution from the AST. * * AgentScript uses indentation to define structure, so the indent hierarchy - * directly maps to the schema hierarchy. This function: + * maps directly to the schema hierarchy. This function: * * 1. Collects parent keys at strictly decreasing indent levels going upward * 2. Walks the schema top-down following those keys - * 3. Tracks whether the cursor is at a "map level" (where users type entry - * names, not field keywords) or inside a block (where we offer completions) - * 4. Scans siblings at cursor indent for already-present field exclusion + * 3. Tracks the matching AST node in lockstep so it can resolve + * discriminant-based variant schemas (e.g. `kind: "OpenAI"` exposes + * OpenAI-specific fields on top of the LLM base schema). Mirrors the + * CST-path resolution in `findEnclosingBlockWithSchema`. * - * Works uniformly for intact and broken ASTs since it only reads source text - * and schema — no CST/AST traversal needed. + * Returns `null` only when there is nothing useful for any caller (no + * parents at all, or top-level cursor). */ -function inferBlockFromIndentation( - _ast: AstRoot, +function walkParentsToSchemaContext( + ast: AstRoot, line: number, - _character: number, rootSchema: Schema | Record, source: string -): { block: AstNodeLike; schema: Schema } | null { +): IndentSchemaContext | null { const lines = source.split('\n'); - const currentLine = lines[line] ?? ''; - const cursorIndent = getIndent(currentLine); + const cursorLineRaw = lines[line] ?? ''; + const cursorIndent = getIndent(cursorLineRaw); - if (cursorIndent === 0) return null; // top-level, let normal path handle it + if (cursorIndent === 0) return null; - // Step 1: collect parent keys at strictly decreasing indent levels. - // For "start_agent greeting:" we capture key="start_agent" and note - // that an entry name is present on the same line (hasEntryName=true). - const parents: Array<{ - key: string; - indent: number; - line: number; - hasEntryName: boolean; - }> = []; + const parents: IndentSchemaContext['parents'] = []; for (const { line: l, indent, trimmed } of walkParentsByIndent(lines, line)) { const m = trimmed.match(/^([\w-]+)(?:\s+([\w-]+))?\s*:/); if (!m) continue; - parents.unshift({ key: m[1], indent, line: l, hasEntryName: !!m[2] }); + parents.unshift({ key: m[1], indent, line: l, entryName: m[2] }); } if (parents.length === 0) return null; - // Step 2: walk the schema tree following the parent keys. - // Track whether the cursor is at a map's entry level — where users type - // entry names rather than field keywords. - // // Key distinction: // NamedMap / CollectionBlock at entry level → no completions (user types names) // TypedMap at entry level → show propertiesSchema (entries are typed // declarations like "name: string", properties are useful here) let schema: Schema | Record = rootSchema; - let mapLevel: 'none' | 'named' | 'typed' = 'none'; + let mapLevel: IndentSchemaContext['mapLevel'] = 'none'; + let typedMapField: FieldType | null = null; + let astCursor: unknown = ast; + // Set only when the parent step entered a CollectionBlock; consumed on + // the next step to resolve the variant/named schema for the entry once + // its name is known. + let pendingEntryBlock: NamedBlockEntryType | undefined; - for (const { key, hasEntryName } of parents) { + for (const { key, entryName } of parents) { const fieldDef = schema[key]; if (fieldDef) { const ft = Array.isArray(fieldDef) ? fieldDef[0] : fieldDef; @@ -637,12 +680,23 @@ function inferBlockFromIndentation( if (mapLike) { const entrySchema = ft.schema ?? ft.propertiesSchema; + typedMapField = isTypedMap ? ft : null; if (entrySchema) { schema = entrySchema; - if (hasEntryName) { - // Entry name on same line (e.g. "start_agent greeting:") - // → inside the entry, not at map level + astCursor = isAstNodeLike(astCursor) + ? astField(astCursor, key) + : undefined; + pendingEntryBlock = isCollectionFieldType(ft) + ? ft.entryBlock + : undefined; + if (entryName) { mapLevel = 'none'; + astCursor = isNamedMap(astCursor) + ? astCursor.get(entryName) + : undefined; + schema = resolveEntrySchema(astCursor, pendingEntryBlock, schema); + pendingEntryBlock = undefined; + typedMapField = null; } else { mapLevel = isTypedMap ? 'typed' : 'named'; } @@ -650,25 +704,71 @@ function inferBlockFromIndentation( } else if (ft.schema) { schema = ft.schema; mapLevel = 'none'; + typedMapField = null; + astCursor = isAstNodeLike(astCursor) + ? astField(astCursor, key) + : undefined; + pendingEntryBlock = undefined; } else { // Leaf field (no sub-schema, e.g. ProcedureValue) — cursor is - // inside a value body where schema-based field completions - // don't apply. Return an empty schema to suppress completions - // and override any CST-based result. + // inside a value body where schema-based completions don't apply. return { - block: { __kind: 'LeafField' } as unknown as AstNodeLike, + parents, schema: {} as Schema, + mapLevel: 'none', + typedMapField: null, + lines, + cursorIndent, + cursorLineRaw, }; } } else { - // Key not in schema = named entry key (e.g. "collect_info" in actions). - // The schema was already set to the entry schema by the parent map-like - // step, so we're now inside this entry. + // Key not in schema = named entry key (e.g. "myLLM" inside `llm:`). + // The parent map-like step already advanced the schema to the entry + // schema; descend into the entry and resolve any variant schema. mapLevel = 'none'; + typedMapField = null; + astCursor = isNamedMap(astCursor) ? astCursor.get(key) : undefined; + schema = resolveEntrySchema(astCursor, pendingEntryBlock, schema); + pendingEntryBlock = undefined; } } - if (schema === rootSchema) return null; + return { + parents, + schema, + mapLevel, + typedMapField, + lines, + cursorIndent, + cursorLineRaw, + }; +} + +/** + * Indentation-based inference for schema context. + * + * Used by `getFieldCompletions` to surface field-key completions when the + * CST walk returns nothing (blank line, error-recovered partial AST). Wraps + * `walkParentsToSchemaContext` with the field-completion-specific + * post-processing: synthetic block with sibling keys for already-present + * field exclusion, leaf/named-gap returns. + */ +function inferBlockFromIndentation( + ast: AstRoot, + line: number, + _character: number, + rootSchema: Schema | Record, + source: string +): { block: AstNodeLike; schema: Schema } | null { + const ctx = walkParentsToSchemaContext(ast, line, rootSchema, source); + if (!ctx) return null; + + const { parents, schema, mapLevel, lines, cursorIndent } = ctx; + + // Leaf field — suppress completions and override any CST-based result. + if (!parents.length) return null; + if (mapLevel === 'none' && schema === rootSchema) return null; // NamedMap/CollectionBlock at entry level → user types entry names, no completions if (mapLevel === 'named') { @@ -680,9 +780,9 @@ function inferBlockFromIndentation( // TypedMap at entry level → show propertiesSchema fields (mapLevel === 'typed') // Block level → show block schema fields (mapLevel === 'none') - // Step 3: build a synthetic block with already-present sibling keys so - // the caller can filter them out of completion suggestions. - // Scan lines at cursor indent within the parent block boundaries. + // Build a synthetic block with already-present sibling keys so the caller + // can filter them out of completion suggestions. Scan lines at cursor + // indent within the parent block boundaries. const lastParent = parents[parents.length - 1]; const presentKeys: Record = { __kind: 'Synthetic' }; for (let l = lastParent.line + 1; l < lines.length; l++) { @@ -704,6 +804,33 @@ function inferBlockFromIndentation( }; } +/** + * Resolve the schema variant for a parsed NamedMap entry, mirroring what + * the dialect would compute at parse time. Tries discriminant-based + * resolution first, then name-based, and falls back to `entrySchema` when + * neither applies (or the entry is half-parsed / missing). The fallback + * yields safe completions when the AST is partial: callers still get the + * base schema rather than no completions at all. + */ +function resolveEntrySchema( + entry: unknown, + entryBlock: NamedBlockEntryType | undefined, + entrySchema: Schema | Record +): Schema | Record { + if (!entryBlock || !isAstNodeLike(entry)) return entrySchema; + if (hasDiscriminant(entryBlock)) { + const discValue = extractDiscriminantValue( + entry, + entryBlock.discriminantField + ); + if (discValue) return entryBlock.resolveSchemaForDiscriminant(discValue); + return entrySchema; + } + const name = typeof entry.__name === 'string' ? entry.__name : undefined; + if (name) return entryBlock.resolveSchemaForName(name); + return entrySchema; +} + function fieldCompletionKind(ft: FieldType | FieldType[]): SymbolKind { const resolved = Array.isArray(ft) ? ft[0] : ft; if (resolved.isNamed) return SymbolKind.Namespace; @@ -732,23 +859,7 @@ function findEnclosingBlockWithSchema( const cst = entry.__cst; if (!cst || !isPositionInRange(line, character, cst.range)) continue; - let entrySchema = schema; - // Check for discriminant-based variant resolution first - if (namedEntryType && hasDiscriminant(namedEntryType)) { - const discValue = extractDiscriminantValue( - entry, - namedEntryType.discriminantField - ); - if (discValue) { - entrySchema = namedEntryType.resolveSchemaForDiscriminant(discValue); - } - } else if (namedEntryType) { - const name = - typeof entry.__name === 'string' ? entry.__name : undefined; - if (name) { - entrySchema = namedEntryType.resolveSchemaForName(name); - } - } + const entrySchema = resolveEntrySchema(entry, namedEntryType, schema); return ( findDeeperBlock(entry, line, character, entrySchema) ?? { block: entry, @@ -847,89 +958,74 @@ function findDeeperBlock( } /** - * Get value completions for a TypedMap entry's value position. + * Get value-position completions for the cursor on `: `. + * + * Two sources contribute: * - * When the cursor is after `key: ` inside a TypedMap (e.g., `inputs:`, - * `outputs:`, `variables:`), returns the primitive types and modifiers - * defined by the TypedMap's schema. + * 1. **Enum members** — when the cursor's key resolves to a `StringValue` + * (or other primitive) field whose schema declares + * `__metadata.constraints.enum`, each enum value is offered. Discriminant + * variants are honoured: if the enclosing entry has a discriminant set + * (e.g. `kind: "OpenAI"`), enum candidates come from the resolved variant + * schema, not the base. + * + * 2. **TypedMap primitive keywords** — when the cursor sits at the value + * side of a TypedMap entry (e.g. `name: ` under `variables:`), + * the TypedMap's primitive type keywords (`string`, `number`, …) are + * offered. + * + * Both sources can fire independently; for fields like `visibility:` under + * a TypedMap variable entry only the enum branch fires (the cursor is at + * the property value, not the entry value). */ export function getValueCompletions( + ast: AstRoot, line: number, _character: number, ctx: SchemaContext, source: string ): CompletionCandidate[] { - const lines = source.split('\n'); - const currentLine = lines[line] ?? ''; - const cursorIndent = getIndent(currentLine); - - if (cursorIndent === 0) return []; - const rootSchema = ctx.info.schema; + const resolved = walkParentsToSchemaContext(ast, line, rootSchema, source); + if (!resolved) return []; - // Walk up to find parent keys at strictly decreasing indent levels - const parents: Array<{ - key: string; - indent: number; - hasEntryName: boolean; - }> = []; - for (const { trimmed, indent } of walkParentsByIndent(lines, line)) { - const m = trimmed.match(/^([\w-]+)(?:\s+([\w-]+))?\s*:/); - if (!m) continue; - parents.unshift({ key: m[1], indent, hasEntryName: !!m[2] }); - } - - if (parents.length === 0) return []; - - // Walk schema following parent keys to find the enclosing TypedMap - let schema: Schema | Record = rootSchema; - let typedMapField: FieldType | null = null; + const { schema, typedMapField, cursorLineRaw } = resolved; + const candidates: CompletionCandidate[] = []; - for (const { key, hasEntryName } of parents) { - const fieldDef = schema[key]; + // 1. Enum members for the cursor-line key, if any. + const keyMatch = cursorLineRaw.trimStart().match(/^([\w-]+)\s*:/); + if (keyMatch) { + const cursorKey = keyMatch[1]; + const fieldDef = (schema as Record)[ + cursorKey + ]; if (fieldDef) { const ft = Array.isArray(fieldDef) ? fieldDef[0] : fieldDef; - const isTypedMap = ft.__isTypedMap === true; - const mapLike = ft.isNamed || ft.__isCollection || isTypedMap; - - if (mapLike) { - if (isTypedMap) { - typedMapField = ft; - } else { - typedMapField = null; + const enumValues = ft.__metadata?.constraints?.enum; + if (Array.isArray(enumValues)) { + const needsQuotes = ft.__accepts?.includes('StringLiteral'); + for (const value of enumValues) { + const literal = String(value); + candidates.push({ + name: literal, + kind: SymbolKind.EnumMember, + insertText: needsQuotes ? `"${literal}"` : literal, + }); } - const entrySchema = ft.schema ?? ft.propertiesSchema; - if (entrySchema) { - schema = entrySchema; - if (hasEntryName) { - // Inside the entry, not at map level - typedMapField = null; - } - } - } else if (ft.schema) { - schema = ft.schema; - typedMapField = null; - } else { - typedMapField = null; } - } else { - // Key not in schema = named entry key - typedMapField = null; } } - if (!typedMapField) return []; - - const candidates: CompletionCandidate[] = []; - - // Add primitive type completions (e.g., string, number, boolean) - const primitiveTypes = typedMapField.__primitiveTypes ?? []; - for (const pt of primitiveTypes) { - candidates.push({ - name: pt.keyword, - kind: SymbolKind.TypeParameter, - documentation: pt.description, - }); + // 2. TypedMap primitive type keywords (e.g. string, number, boolean). + if (typedMapField) { + const primitiveTypes = typedMapField.__primitiveTypes ?? []; + for (const pt of primitiveTypes) { + candidates.push({ + name: pt.keyword, + kind: SymbolKind.TypeParameter, + documentation: pt.description, + }); + } } return candidates; @@ -1126,6 +1222,91 @@ function getIndent(line: string): number { return line.length - line.trimStart().length; } +/** Min/max indent step we'll honour. Outside this range we fall back. */ +const MIN_INDENT_STEP = 2; +const MAX_INDENT_STEP = 8; +const DEFAULT_INDENT_STEP = 4; + +/** + * Infer the document's indent step for snippet generation by walking the + * AST. Used so completion-snippet bodies match the user's actual + * indentation convention rather than a hardcoded 4 spaces. + * + * The dialect's grammar is whitespace-significant with a consistent step + * across the whole document. We just need ONE structural parent→child + * pair on different lines to recover the step. + * + * For each `AstNodeLike` we have a CST range, and the "structural indent" + * of that node is the leading-whitespace count of the line where its CST + * starts. Walking the AST depth-first and comparing line-indents between a + * node and the nearest descendant that starts on a different line gives + * the document's step. + * + * This is naturally immune to indented prose inside multi-line scalars + * (`description: |` content is a StringLiteral leaf, not nested + * `AstNodeLike` children with their own line starts). + * + * Result is clamped to [MIN_INDENT_STEP, MAX_INDENT_STEP]. When nothing + * usable is found (empty file, single-line doc, totally broken parse), we + * fall back to DEFAULT_INDENT_STEP. + */ +function inferIndentStep(ast: AstRoot, source: string): number { + const lines = source.split('\n'); + const lineIndent = (line: number): number => { + const ln = lines[line]; + if (ln === undefined) return -1; + return ln.length - ln.trimStart().length; + }; + + let result: number | undefined; + + function visit(node: unknown, parentIndent: number): void { + if (result !== undefined) return; + if (!node || typeof node !== 'object') return; + + if (Array.isArray(node)) { + for (const item of node) { + visit(item, parentIndent); + if (result !== undefined) return; + } + return; + } + + if (!isAstNodeLike(node) && !isNamedMap(node)) return; + + let nextParentIndent = parentIndent; + const cst = (node as AstNodeLike).__cst; + if (cst) { + const indent = lineIndent(cst.range.start.line); + if (indent >= 0 && parentIndent >= 0 && indent > parentIndent) { + const delta = indent - parentIndent; + if (delta >= MIN_INDENT_STEP && delta <= MAX_INDENT_STEP) { + result = delta; + return; + } + } + if (indent >= 0) nextParentIndent = indent; + } + + if (isNamedMap(node)) { + for (const [, entry] of node) { + visit(entry, nextParentIndent); + if (result !== undefined) return; + } + return; + } + + for (const [k, v] of Object.entries(node as AstNodeLike)) { + if (k.startsWith('__')) continue; + visit(v, nextParentIndent); + if (result !== undefined) return; + } + } + + visit(ast, -1); + return result ?? DEFAULT_INDENT_STEP; +} + /** * Yield non-blank lines above `cursorLine` at strictly decreasing * indentation — the structural parent chain in an indentation-based diff --git a/packages/language/src/core/analysis/scope.ts b/packages/language/src/core/analysis/scope.ts index a1a9889c..2099c53f 100644 --- a/packages/language/src/core/analysis/scope.ts +++ b/packages/language/src/core/analysis/scope.ts @@ -239,6 +239,18 @@ function resolveFieldType(ft: FieldType | FieldType[]): FieldType { return Array.isArray(ft) ? ft[0] : ft; } +/** + * Returns true if `schema` was already visited; otherwise records it and + * returns false. Used by recursive walkers to short-circuit on self- + * referential schemas (e.g., agentfabric's output property block, which + * references itself via `items` and `properties`). + */ +function alreadyVisited(visited: WeakSet, schema: Schema): boolean { + if (visited.has(schema)) return true; + visited.add(schema); + return false; +} + function buildScopedNamespaces( schemaInfo: SchemaInfo ): Map> { @@ -253,6 +265,8 @@ function buildScopedNamespaces( scopeAlias && schema ) { + // Fresh visited set per top-level entry: cycles are intra-subtree, + // so peer entries that share a sub-schema must each walk it. collectScopedFields(schema, scopeAlias, result); } } @@ -281,26 +295,38 @@ function addScopedField( function collectScopedFields( schema: Schema, parentScope: string, - result: Map> + result: Map>, + visited: WeakSet = new WeakSet() ): void { + if (alreadyVisited(visited, schema)) return; for (const [fieldName, rawFt] of Object.entries(schema)) { const fieldType = resolveFieldType(rawFt); if (fieldType.isNamed) { addScopedField(result, fieldName, parentScope); if (fieldType.scopeAlias && fieldType.schema) { - collectScopedFields(fieldType.schema, fieldType.scopeAlias, result); + collectScopedFields( + fieldType.schema, + fieldType.scopeAlias, + result, + visited + ); } } else if (isCollectionField(fieldType)) { // CollectionBlock — treat like a NamedBlock for scope purposes addScopedField(result, fieldName, parentScope); if (fieldType.scopeAlias && fieldType.schema) { - collectScopedFields(fieldType.schema, fieldType.scopeAlias, result); + collectScopedFields( + fieldType.schema, + fieldType.scopeAlias, + result, + visited + ); } } else if (isTypedMapField(fieldType)) { addScopedField(result, fieldName, parentScope); } else if (fieldType.schema && !fieldType.isNamed) { // Non-scoped Block (e.g., ReasoningBlock) -- recurse through it - collectScopedFields(fieldType.schema, parentScope, result); + collectScopedFields(fieldType.schema, parentScope, result, visited); } } } @@ -316,8 +342,10 @@ function collectReferenceableFields( function walkForReferenceable( schema: Record, - result: Set + result: Set, + visited: WeakSet = new WeakSet() ): void { + if (alreadyVisited(visited, schema)) return; for (const [fieldName, fieldType] of Object.entries(schema)) { if (fieldType.__metadata?.crossBlockReferenceable) { result.add(fieldName); @@ -325,7 +353,8 @@ function walkForReferenceable( if (fieldType.schema) { walkForReferenceable( fieldType.schema as Record, - result + result, + visited ); } } @@ -348,6 +377,7 @@ function buildCapabilityNamespaces( const fieldType = resolveFieldType(rawFt); collectCapabilities(key, fieldType, result); if (fieldType.schema) { + // Fresh visited set per top-level entry: cycles are intra-subtree. walkForCapabilities(fieldType.schema, result); } } @@ -369,13 +399,15 @@ function collectCapabilities( function walkForCapabilities( schema: Schema, - result: CapabilityNamespaces + result: CapabilityNamespaces, + visited: WeakSet = new WeakSet() ): void { + if (alreadyVisited(visited, schema)) return; for (const [fieldName, rawFt] of Object.entries(schema)) { const fieldType = resolveFieldType(rawFt); collectCapabilities(fieldName, fieldType, result); if (fieldType.schema) { - walkForCapabilities(fieldType.schema, result); + walkForCapabilities(fieldType.schema, result, visited); } } } @@ -401,6 +433,7 @@ function buildScopeNavigation( } if (fieldType.schema) { + // Fresh visited set per top-level entry: cycles are intra-subtree. walkSchemaForNavigation(fieldType.schema, fieldType.scopeAlias, registry); } } @@ -412,8 +445,10 @@ function buildScopeNavigation( function walkSchemaForNavigation( schema: Schema, parentScope: string, - registry: Map + registry: Map, + visited: WeakSet = new WeakSet() ): void { + if (alreadyVisited(visited, schema)) return; for (const [, rawFt] of Object.entries(schema)) { const fieldType = resolveFieldType(rawFt); if ( @@ -430,11 +465,12 @@ function walkSchemaForNavigation( walkSchemaForNavigation( fieldType.schema, fieldType.scopeAlias, - registry + registry, + visited ); } } else if (fieldType.schema && !fieldType.isNamed) { - walkSchemaForNavigation(fieldType.schema, parentScope, registry); + walkSchemaForNavigation(fieldType.schema, parentScope, registry, visited); } } } diff --git a/packages/language/src/core/field-builder.ts b/packages/language/src/core/field-builder.ts index ce08f060..094569ef 100644 --- a/packages/language/src/core/field-builder.ts +++ b/packages/language/src/core/field-builder.ts @@ -84,6 +84,35 @@ export interface BuilderMethods< allowedNamespaces(namespaces: string[]): ConstrainedBuilder; resolvedType(type: BlockCapability): ConstrainedBuilder; crossBlockReferenceable(): ConstrainedBuilder; + /** + * Mark a `ProcedureValue` field as a transition container — its body is + * expected to hold `TransitionStatement`s that produce graph edges. + * Consumed by schema-driven graph extractors so they can discover + * transition sites without hardcoding field names like `on_exit`. + */ + transitionContainer(): ConstrainedBuilder; + /** + * Mark a primitive field as carrying a predicate / condition for its + * sibling transition target (e.g. a router route's `when` expression). + * Schema-driven graph extractors surface this as the edge's predicate + * without needing to know the field's name. + */ + predicateField(): ConstrainedBuilder; + /** + * Mark a string-literal field as the human-readable name of the output + * a sibling transition target represents (e.g. a router route's + * `label` field). Schema-driven graph extractors copy its value as the + * edge's `output` so the canvas can render route names without + * hardcoding the field name. + */ + outputNameField(): ConstrainedBuilder; + /** + * Mark a string-literal field as the human-readable display label for + * the surrounding block (e.g. a node's `label` field). Schema-driven + * graph extractors surface its value as the protocol-level node + * label without hardcoding the field name. + */ + displayLabelField(): ConstrainedBuilder; hidden(): ConstrainedBuilder; // Structural methods — delegate to base type's extend/omit/etc. when present. // Throws at runtime for types that don't support them (e.g., primitives). @@ -421,6 +450,11 @@ export function addBuilderMethods< if (!skipFactoryOverridden) { target.crossBlockReferenceable = () => withMeta({ crossBlockReferenceable: true }); + target.transitionContainer = () => + withMeta({ transitionContainer: true }); + target.predicateField = () => withMeta({ predicateField: true }); + target.outputNameField = () => withMeta({ outputNameField: true }); + target.displayLabelField = () => withMeta({ displayLabelField: true }); target.pick = (keys: string[]) => { if ('pick' in base && typeof base.pick === 'function') { return enhance(meta, base.pick(keys)); diff --git a/packages/language/src/core/index.ts b/packages/language/src/core/index.ts index 71097e59..cbd2ea8f 100644 --- a/packages/language/src/core/index.ts +++ b/packages/language/src/core/index.ts @@ -35,6 +35,7 @@ export { emitIndent, isKeyNode, isNamedMap, + isAstNodeLike, isCollectionFieldType, isNamedCollectionFieldType, parseCommentNode, diff --git a/packages/language/src/core/types.ts b/packages/language/src/core/types.ts index 3b8e1f9d..57c851f8 100644 --- a/packages/language/src/core/types.ts +++ b/packages/language/src/core/types.ts @@ -516,6 +516,36 @@ export interface FieldMetadata extends DocumentationMetadata { * colinear value (e.g., `@actions.fetch_data`). */ crossBlockReferenceable?: boolean; + /** + * When true, this ProcedureValue field is a transition container — the + * procedure body is expected to contain TransitionStatement(s) that + * define edges in a graph extractor. Schema-driven graph extractors use + * this to discover transition sites without hardcoding field names. + */ + transitionContainer?: boolean; + /** + * When true, this primitive field carries a predicate / condition that + * gates the sibling transition target (e.g. a router route's `when` + * expression). Schema-driven graph extractors surface its source text on + * the resulting edge so consumers can render it without knowing the + * field's name. + */ + predicateField?: boolean; + /** + * When true, this primitive field provides the human-readable name of + * the *output* a sibling transition target represents (e.g. a router + * route's `label` field). Schema-driven graph extractors copy its + * StringLiteral value onto the resulting edge so consumers can render + * the route name without knowing the field's name. + */ + outputNameField?: boolean; + /** + * When true, this string-literal primitive field provides a node's + * human-readable display label. Schema-driven graph extractors copy + * its value onto the node's `label` so consumers can render it + * without knowing the field's name. + */ + displayLabelField?: boolean; /** When true, the field is valid in the schema but not shown in code completions. */ hidden?: boolean; } diff --git a/packages/language/src/index.ts b/packages/language/src/index.ts index cea40f9c..f9130d71 100644 --- a/packages/language/src/index.ts +++ b/packages/language/src/index.ts @@ -35,6 +35,7 @@ export { emitIndent, isKeyNode, isNamedMap, + isAstNodeLike, isCollectionFieldType, isNamedCollectionFieldType, parseCommentNode, @@ -211,6 +212,7 @@ export type { } from './core/field-builder.js'; export type { DocumentationMetadata, KeywordInfo } from './core/types.js'; export { keywordNames } from './core/types.js'; +export type { Range } from './core/types.js'; export { VariablePropertiesBlock, diff --git a/packages/lsp/src/providers/completion.ts b/packages/lsp/src/providers/completion.ts index b4b1d6b2..3f3f972a 100644 --- a/packages/lsp/src/providers/completion.ts +++ b/packages/lsp/src/providers/completion.ts @@ -144,6 +144,7 @@ export function provideCompletion( } const valueCandidates = getValueCompletions( + ast, line, character, schemaContext, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2271c918..d82f8fcc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,7 +25,7 @@ importers: version: 8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 3.2.4(vitest@3.2.6(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) eslint: specifier: ^9.39.4 version: 9.39.4(jiti@2.6.1) @@ -363,8 +363,8 @@ importers: specifier: ^5.8.3 version: 5.9.3 vitest: - specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + specifier: ^3.2.6 + version: 3.2.6(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) dialect/agentforce: dependencies: @@ -4381,6 +4381,9 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@3.2.6': + resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==} + '@vitest/mocker@3.2.4': resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: @@ -4392,21 +4395,47 @@ packages: vite: optional: true + '@vitest/mocker@3.2.6': + resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==} + peerDependencies: + msw: ^2.4.9 + vite: ^7.3.2 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@3.2.6': + resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==} + '@vitest/runner@3.2.4': resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@3.2.6': + resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==} + '@vitest/snapshot@3.2.4': resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@3.2.6': + resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==} + '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@3.2.6': + resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==} + '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@3.2.6': + resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} + '@vscode/iconv-lite-umd@0.7.1': resolution: {integrity: sha512-tK6k0DXFHW7q5+GGuGZO+phpAqpxO4WXl+BLc/8/uOk3RsM2ssAL3CQUQDb1TGfwltjsauhN6S4ghYZzs4sPFw==} @@ -9563,6 +9592,34 @@ packages: jsdom: optional: true + vitest@3.2.6: + resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.6 + '@vitest/ui': 3.2.6 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-jsonrpc@8.2.0: resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} engines: {node: '>=14.0.0'} @@ -14538,7 +14595,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.6(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -14553,7 +14610,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + vitest: 3.2.6(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -14565,17 +14622,33 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/expect@3.2.6': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + + '@vitest/mocker@3.2.6(vite@7.3.2(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 3.2.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 optionalDependencies: vite: 7.3.2(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@3.2.6(vite@7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: - '@vitest/spy': 3.2.4 + '@vitest/spy': 3.2.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: @@ -14585,28 +14658,54 @@ snapshots: dependencies: tinyrainbow: 2.0.0 + '@vitest/pretty-format@3.2.6': + dependencies: + tinyrainbow: 2.0.0 + '@vitest/runner@3.2.4': dependencies: '@vitest/utils': 3.2.4 pathe: 2.0.3 strip-literal: 3.1.0 + '@vitest/runner@3.2.6': + dependencies: + '@vitest/utils': 3.2.6 + pathe: 2.0.3 + strip-literal: 3.1.0 + '@vitest/snapshot@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/snapshot@3.2.6': + dependencies: + '@vitest/pretty-format': 3.2.6 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.4 + '@vitest/spy@3.2.6': + dependencies: + tinyspy: 4.0.4 + '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 loupe: 3.2.1 tinyrainbow: 2.0.0 + '@vitest/utils@3.2.6': + dependencies: + '@vitest/pretty-format': 3.2.6 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@vscode/iconv-lite-umd@0.7.1': {} '@vscode/vsce-sign-alpine-arm64@2.0.6': @@ -20627,11 +20726,11 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -20649,6 +20748,48 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 + vite: 7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + vite-node: 3.2.4(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 24.10.9 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vitest@3.2.6(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.6 + '@vitest/mocker': 3.2.6(vite@7.3.2(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 3.2.6 + '@vitest/runner': 3.2.6 + '@vitest/snapshot': 3.2.6 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 vite: 7.3.2(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) vite-node: 3.2.4(@types/node@20.19.37)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 @@ -20669,22 +20810,22 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): + vitest@3.2.6(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: '@types/chai': 5.2.3 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 + '@vitest/expect': 3.2.6 + '@vitest/mocker': 3.2.6(vite@7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 3.2.6 + '@vitest/runner': 3.2.6 + '@vitest/snapshot': 3.2.6 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 chai: 5.3.3 debug: 4.4.3 expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2