From e6380d3ef0545f2165e7dca2fd34c8e4d4bd0a4f Mon Sep 17 00:00:00 2001 From: "ashwin.venkataraman" Date: Fri, 5 Jun 2026 20:12:17 +0530 Subject: [PATCH 1/5] sync(agentfabric): port dialect from module-agentscript Sync the agentfabric dialect and the @agentscript/language APIs it depends on from the internal module-agentscript repo, so the build and test suite pass in op-agentscript. - Add agentfabric compiler and graph-extraction modules, lint rules, and tests carried over from module-agentscript. - Extend @agentscript/language with the graph field markers the dialect needs: transitionContainer, predicateField, outputNameField, and displayLabelField (core/types.ts FieldMetadata flags + field-builder methods). Export isAstNodeLike and the Range type. - Update getValueCompletions to the AST-aware signature and add walkParentsToSchemaContext plus CompletionCandidate.insertText; thread cycle detection through scope.ts walkers; update the lsp caller. - Mark agentscript-dialect baseSubagentFields.label as displayLabelField so orchestrator nodes (cloned from SubagentBlock) surface their label in the extracted graph. --- dialect/agentfabric/CHANGELOG.md | 16 +- dialect/agentfabric/README.md | 48 - dialect/agentfabric/package.json | 9 +- .../agentfabric/src/compiler/agent-graph.ts | 28 + .../src/compiler/build-definitions.ts | 167 ++ .../agentfabric/src/compiler/build-nodes.ts | 1389 +++++++++++++ .../src/compiler/build-providers.ts | 253 +++ .../src/compiler/compile-execute-do.ts | 460 +++++ dialect/agentfabric/src/compiler/compile.ts | 218 ++ .../src/compiler/compiler-context.ts | 27 + dialect/agentfabric/src/compiler/index.ts | 22 + .../agentfabric/src/compiler/service-types.ts | 25 + .../compiler/unified-agent-specification.ts | 366 ++++ dialect/agentfabric/src/compiler/utils.ts | 369 ++++ .../agentfabric/src/graph/extractor.test.ts | 366 ++++ dialect/agentfabric/src/graph/extractor.ts | 582 ++++++ .../agentfabric/src/graph/get-graph.test.ts | 375 ++++ dialect/agentfabric/src/graph/get-graph.ts | 205 ++ dialect/agentfabric/src/graph/index.ts | 10 + dialect/agentfabric/src/index.ts | 41 +- dialect/agentfabric/src/lint/index.ts | 10 +- .../src/lint/passes/agentfabric-semantic.ts | 13 +- dialect/agentfabric/src/lint/passes/index.ts | 13 +- .../lint/passes/rules/action-binding-rules.ts | 120 ++ .../lint/passes/rules/agentic-llm-rules.ts | 7 - .../src/lint/passes/rules/connection-rules.ts | 7 - .../src/lint/passes/rules/cycle-rules.ts | 172 ++ .../src/lint/passes/rules/echo-rules.ts | 9 +- .../src/lint/passes/rules/execute-rules.ts | 299 +++ .../src/lint/passes/rules/on-exit-rules.ts | 7 - .../passes/rules/output-structure-rules.ts | 7 - .../rules/reasoning-instructions-rules.ts | 7 - .../src/lint/passes/rules/shared.ts | 35 +- .../src/lint/passes/rules/switch-rules.ts | 36 +- .../src/lint/passes/rules/trigger-rules.ts | 7 - .../src/lint/passes/rules/unused-node.ts | 86 + .../lint/passes/strict-schema-validation.ts | 35 + ...ess-tools-namespace-undefined-reference.ts | 7 - dialect/agentfabric/src/lint/utils.ts | 11 - dialect/agentfabric/src/schema.ts | 144 +- .../agentfabric/src/tests/compiler.test.ts | 1755 +++++++++++++++++ dialect/agentfabric/src/tests/dialect.test.ts | 7 - dialect/agentfabric/src/tests/lint.test.ts | 1069 +++++++++- .../src/tests/llm-completions.test.ts | 113 ++ .../src/tests/llm-value-completions.test.ts | 193 ++ ...agentfabric-customer-support-netwrok.agent | 6 +- .../agentfabric-customer-support-netwrok.yaml | 41 +- .../it-help-investigation.graph.json | 218 ++ .../resources/it-help-investigation.yaml | 68 +- .../src/tests/schema-validation.test.ts | 150 ++ .../src/tests/snippet-indentation.test.ts | 580 ++++++ dialect/agentfabric/src/tests/test-utils.ts | 7 - dialect/agentfabric/src/tests/utils.test.ts | 152 ++ dialect/agentscript/src/schema.ts | 6 +- .../language/src/core/analysis/completions.ts | 437 ++-- packages/language/src/core/analysis/scope.ts | 58 +- packages/language/src/core/field-builder.ts | 34 + packages/language/src/core/index.ts | 1 + packages/language/src/core/types.ts | 30 + packages/language/src/index.ts | 2 + packages/lsp/src/providers/completion.ts | 1 + 61 files changed, 10489 insertions(+), 447 deletions(-) delete mode 100644 dialect/agentfabric/README.md create mode 100644 dialect/agentfabric/src/compiler/agent-graph.ts create mode 100644 dialect/agentfabric/src/compiler/build-definitions.ts create mode 100644 dialect/agentfabric/src/compiler/build-nodes.ts create mode 100644 dialect/agentfabric/src/compiler/build-providers.ts create mode 100644 dialect/agentfabric/src/compiler/compile-execute-do.ts create mode 100644 dialect/agentfabric/src/compiler/compile.ts create mode 100644 dialect/agentfabric/src/compiler/compiler-context.ts create mode 100644 dialect/agentfabric/src/compiler/index.ts create mode 100644 dialect/agentfabric/src/compiler/service-types.ts create mode 100644 dialect/agentfabric/src/compiler/unified-agent-specification.ts create mode 100644 dialect/agentfabric/src/compiler/utils.ts create mode 100644 dialect/agentfabric/src/graph/extractor.test.ts create mode 100644 dialect/agentfabric/src/graph/extractor.ts create mode 100644 dialect/agentfabric/src/graph/get-graph.test.ts create mode 100644 dialect/agentfabric/src/graph/get-graph.ts create mode 100644 dialect/agentfabric/src/graph/index.ts create mode 100644 dialect/agentfabric/src/lint/passes/rules/action-binding-rules.ts create mode 100644 dialect/agentfabric/src/lint/passes/rules/cycle-rules.ts create mode 100644 dialect/agentfabric/src/lint/passes/rules/execute-rules.ts create mode 100644 dialect/agentfabric/src/lint/passes/rules/unused-node.ts create mode 100644 dialect/agentfabric/src/lint/passes/strict-schema-validation.ts delete mode 100644 dialect/agentfabric/src/lint/utils.ts create mode 100644 dialect/agentfabric/src/tests/compiler.test.ts create mode 100644 dialect/agentfabric/src/tests/llm-completions.test.ts create mode 100644 dialect/agentfabric/src/tests/llm-value-completions.test.ts create mode 100644 dialect/agentfabric/src/tests/resources/it-help-investigation.graph.json create mode 100644 dialect/agentfabric/src/tests/schema-validation.test.ts create mode 100644 dialect/agentfabric/src/tests/snippet-indentation.test.ts create mode 100644 dialect/agentfabric/src/tests/utils.test.ts diff --git a/dialect/agentfabric/CHANGELOG.md b/dialect/agentfabric/CHANGELOG.md index c358a8bd..98a2db65 100644 --- a/dialect/agentfabric/CHANGELOG.md +++ b/dialect/agentfabric/CHANGELOG.md @@ -1,19 +1,5 @@ # @agentscript/agentfabric-dialect -## 0.1.24 - -### Patch Changes - -- d01c76b: Fix publish: rewrite `@agentscript/*` → `@sf-agentscript/*` in `dist/` and `src/`, not just `package.json`. - - Previously, `scripts/publish.mjs` only rewrote `package.json` files at publish time. The compiled JavaScript in `dist/` and the shipped TypeScript in `src/` still contained `import ... from '@agentscript/*'`, so consumers installing `@sf-agentscript/*` packages from npm hit `ERR_MODULE_NOT_FOUND: Cannot find package '@agentscript/...'` at runtime. - - `scripts/publish.mjs` now also rewrites `.js`, `.mjs`, `.cjs`, `.ts`, `.tsx`, `.cts`, `.mts`, and `.map` files inside each package's `dist/` and `src/` directories, so published artifacts resolve cleanly under the `@sf-agentscript` scope. - -- Updated dependencies [d01c76b] - - @agentscript/agentscript-dialect@2.5.20 - - @agentscript/language@2.5.4 - ## 0.1.8 ### Breaking Changes @@ -68,7 +54,7 @@ ### Patch Changes -- Revert rename (`tool_definitions` back to `actions`, `tools` back to `actions` in reasoning blocks). Add support for discriminant-based polymorphic variants via `.discriminant()` on block factories. Refactor `block.ts` into focused modules (block-factory, named-block-factory, typed-map-factory, collection-block-factory, factory-utils). Fix variant type propagation through `InferFieldType` and collection factories. Improve comment attachment parity with tree-sitter parser. +- Revert TDX rename (`tool_definitions` back to `actions`, `tools` back to `actions` in reasoning blocks). Add support for discriminant-based polymorphic variants via `.discriminant()` on block factories. Refactor `block.ts` into focused modules (block-factory, named-block-factory, typed-map-factory, collection-block-factory, factory-utils). Fix variant type propagation through `InferFieldType` and collection factories. Improve comment attachment parity with tree-sitter parser. - Updated dependencies - @agentscript/language@2.4.4 - @agentscript/agentscript-dialect@2.5.4 diff --git a/dialect/agentfabric/README.md b/dialect/agentfabric/README.md deleted file mode 100644 index a80f9d34..00000000 --- a/dialect/agentfabric/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# @agentscript/agentfabric-dialect - -AgentFabric dialect — defines the schema, lint rules, and compiler for the AgentFabric platform. - -## Overview - -This dialect extends the base AgentScript schema with AgentFabric-specific blocks, fields, and a full compiler. It is an alternative to the Agentforce dialect for targeting the AgentFabric runtime. - -## Installation - -```bash -pnpm add @agentscript/agentfabric-dialect -``` - -## Usage - -```typescript -import { agentfabricDialect } from '@agentscript/agentfabric-dialect'; - -// Use as a DialectConfig -console.log(agentfabricDialect.name); // 'agentfabric' -console.log(agentfabricDialect.schemaInfo); // AgentFabric-specific schema -``` - -## What It Provides - -- **Schema** — AgentFabric-specific block types and field definitions -- **Lint rules** — AgentFabric-specific validation passes -- **Compiler** — full compilation pipeline for the AgentFabric output format -- **Dialect config** — `DialectConfig` object for use with `@agentscript/language` and `@agentscript/lsp` - -## Dependencies - -- `@agentscript/agentscript-dialect` — inherits the base schema and rules -- `@agentscript/language` — language infrastructure - -## Scripts - -```bash -pnpm build # Compile TypeScript -pnpm test # Run tests -pnpm typecheck # Type-check -pnpm dev # Watch mode -``` - -## License - -MIT 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/compiler/agent-graph.ts b/dialect/agentfabric/src/compiler/agent-graph.ts new file mode 100644 index 00000000..389efa10 --- /dev/null +++ b/dialect/agentfabric/src/compiler/agent-graph.ts @@ -0,0 +1,28 @@ +/** + * AgentGraph — the top-level compiler output type. + * Mirrors the Python AgentGraph class from unified_agent_specification_adaptor.py. + */ + +import type { UnifiedAgentSpecification } from './unified-agent-specification.js'; +import type { LLMProvider, InvokableClient } from './service-types.js'; + +export interface AgentGraphTrigger { + id: string; + kind: 'a2a'; + namespace: string; + target_id: string; + on_message: { + transition_to: string; + }; +} + +export interface AgentGraph { + unifiedAgentSpec: UnifiedAgentSpecification; + llmProviders: LLMProvider[]; + invokableClients: InvokableClient[]; + responseNodeNames: string[]; + trigger: AgentGraphTrigger | null; + /** Mapping of output-structure ref ids to outputStructure schemas. + * Node-level linkage is carried by `llm.output-structure-ref`. */ + outputStructures: Record>; +} diff --git a/dialect/agentfabric/src/compiler/build-definitions.ts b/dialect/agentfabric/src/compiler/build-definitions.ts new file mode 100644 index 00000000..d7e6b893 --- /dev/null +++ b/dialect/agentfabric/src/compiler/build-definitions.ts @@ -0,0 +1,167 @@ +/** + * Build ActionDefinition entries from actions and the built-in IdentityAction. + * Mirrors _get_definitions() in the Python adaptor. + */ + +import type { + ActionDefinition, + Definition, +} from './unified-agent-specification.js'; +import { ObjectTypes } from './unified-agent-specification.js'; +import type { AgentFabricCompilerContext } from './compiler-context.js'; +import { extractString } from './utils.js'; + +/** + * JSON schema for A2A MessageSendParams input. + * Simplified static schema matching the Python adaptor's MessageSendParams.model_json_schema(). + */ +const MESSAGE_SEND_PARAMS_SCHEMA = { + type: 'object', + properties: { + message: { + type: 'object', + properties: { + role: { type: 'string' }, + parts: { + type: 'array', + items: { + type: 'object', + properties: { + kind: { type: 'string' }, + text: { type: 'string' }, + }, + }, + }, + }, + }, + }, +}; + +/** + * Permissive JSON schema for MCP tool input. + * Actual schemas are discovered at runtime via MCP protocol; this placeholder + * ensures the definition is valid and the runtime can resolve the ref. + */ +const MCP_TOOL_INPUT_SCHEMA = { + type: 'object', + properties: {}, + additionalProperties: true, +}; + +/** + * JSON schema for ToolCallResultEvent output. + */ +const TOOL_CALL_RESULT_SCHEMA = { + type: 'object', + properties: { + result: { type: 'object' }, + }, +}; + +function cloneSchema(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +export function buildDefinitions( + actionDefs: Map> | undefined, + _ctx: AgentFabricCompilerContext +): Definition[] { + const result: Definition[] = []; + + if (actionDefs) { + for (const [name, def] of actionDefs) { + const kind = extractString((def as Record).kind); + if (kind === 'a2a:send_message') { + const target = + extractString((def as Record).target) ?? ''; + const connectionUrl = target.replace( + /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, + '' + ); + const record = def as Record; + const defaultLabel = `${name}-action`; + const defaultDescription = `A2A tool: ${name}`; + const label = extractString(record.label) ?? defaultLabel; + const description = + extractString(record.description) ?? defaultDescription; + + const actionDef: ActionDefinition = { + name: `${name}-action`, + type: ObjectTypes.ACTION, + client: `${name}-client`, + label, + description, + 'invocation-target-type': 'agent', + 'invocation-target-name': name, + 'input-schema': cloneSchema(MESSAGE_SEND_PARAMS_SCHEMA), + 'output-schema': cloneSchema(TOOL_CALL_RESULT_SCHEMA), + behavior: { + 'require-user-confirmation': false, + 'include-in-progress-indicator': false, + }, + metadata: { + protocol: 'a2a', + url: connectionUrl, + platform: 'Mulesoft', + }, + }; + result.push(actionDef); + } else if (kind === 'mcp:tool') { + const record = def as Record; + const toolName = extractString(record.tool_name) ?? name; + const target = extractString(record.target) ?? ''; + const connection = target.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, ''); + const defaultLabel = `${name}-action`; + const defaultDescription = `MCP tool: ${name}`; + const label = extractString(record.label) ?? defaultLabel; + const description = + extractString(record.description) ?? defaultDescription; + + const actionDef: ActionDefinition = { + name: `${name}-action`, + type: ObjectTypes.ACTION, + client: `${name}-client`, + label, + description, + 'invocation-target-type': 'mcp', + 'invocation-target-name': toolName, + 'input-schema': cloneSchema(MCP_TOOL_INPUT_SCHEMA), + 'output-schema': cloneSchema(TOOL_CALL_RESULT_SCHEMA), + behavior: { + 'require-user-confirmation': false, + 'include-in-progress-indicator': false, + }, + metadata: { + protocol: 'mcp', + connection, + tool_name: toolName, + }, + }; + result.push(actionDef); + } + } + } + + // Always include IdentityAction + result.push({ + name: 'IdentityAction', + type: ObjectTypes.ACTION, + client: 'in-built', + label: 'State Update Action', + description: 'Generic action for updating state variables', + 'invocation-target-type': 'internal', + 'invocation-target-name': 'state-update-action', + 'input-schema': { + type: 'object', + properties: {}, + additionalProperties: true, + }, + 'output-schema': { + type: 'object', + properties: {}, + additionalProperties: true, + }, + }); + + return result; +} diff --git a/dialect/agentfabric/src/compiler/build-nodes.ts b/dialect/agentfabric/src/compiler/build-nodes.ts new file mode 100644 index 00000000..2afb7f13 --- /dev/null +++ b/dialect/agentfabric/src/compiler/build-nodes.ts @@ -0,0 +1,1389 @@ +/** + * Build UnifiedAgentSpecification nodes from parsed AgentFabric AST blocks. + * Mirrors the _build_*_node() methods from the Python adaptor. + */ + +import type { + Node, + AgentNode, + ActionNode, + HandoffAction, + HandoffActionUnion, + ActionCallableReference, + LLMRef, + ToolUnion, + MCPTool, + A2ATool, + StateVariable, + NodeSystemLimits, +} from './unified-agent-specification.js'; +import { ObjectTypes } from './unified-agent-specification.js'; +import { + isNamedMap, + decomposeAtMemberExpression, + Identifier, + WithClause, + Ellipsis, + SubscriptExpression, + StringLiteral, + NumberLiteral, + BooleanLiteral, + NoneLiteral, + VariableDeclarationNode, +} from '@agentscript/language'; +import type { Expression } from '@agentscript/language'; +import { + normalizeId, + resolveTarget, + normalizeTemplate, + extractString, + extractNumber, + extractLlmFieldReference, + iterateCollection, + combineGlobalSystemInstructions, + extractProcedureText, + extractTransitionReference, + toPlainData, +} from './utils.js'; +import { + compileExecuteDoProcedure, + compileExecuteExpression, + collectExecuteVariableEnv, + type ExecuteVariableEnv, +} from './compile-execute-do.js'; + +/** Extract top-level `system.instructions` as the global system prompt for all agent nodes. */ +function extractGlobalSystemInstructions(ast: Record): string { + const system = ast.system; + if (system == null || typeof system !== 'object') return ''; + return extractString((system as Record).instructions) ?? ''; +} + +// ── Shared helpers ────────────────────────────────────────────────── + +function buildOnInit(firstNode: boolean): ActionCallableReference[] | null { + if (firstNode) { + return [ + { + type: ObjectTypes.ACTION, + ref: 'IdentityAction', + 'state-updates': [ + { + request: "normalize_headers(variables['request'])", + }, + ], + }, + ]; + } + return null; +} + +/** Strip optional `@llm.` prefix from a config/node LLM reference. */ +function stripLlmRef(raw: string | undefined): string | undefined { + if (raw === undefined || raw === null) return undefined; + const t = String(raw).trim(); + if (!t) return undefined; + if (t.startsWith('@llm.')) return t.slice(5); + return t; +} + +function extractLlmRefFromText(text: string): string | undefined { + const m = text.match(/@llm\.([A-Za-z0-9_-]+)/); + return m?.[1]; +} + +function extractDefaultLlmRefFromSource(source: string): string | undefined { + const lines = source.split(/\r?\n/); + let inConfig = false; + for (const line of lines) { + const trimmed = line.trim(); + if (!inConfig) { + if (trimmed === 'config:') inConfig = true; + continue; + } + if (trimmed.length === 0) continue; + if (!line.startsWith(' ')) break; + const m = line.match(/^\s{2}default_llm:\s*(.+)\s*$/); + if (m) return extractLlmRefFromText(m[1]); + } + return undefined; +} + +function extractNodeLlmRefFromSource( + source: string, + nodeType: string, + nodeName: string +): string | undefined { + const lines = source.split(/\r?\n/); + const escapedType = nodeType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const escapedName = nodeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const headerRe = new RegExp(`^${escapedType}\\s+${escapedName}:\\s*$`); + + for (let i = 0; i < lines.length; i++) { + if (!headerRe.test(lines[i].trim())) continue; + for (let j = i + 1; j < lines.length; j++) { + const line = lines[j]; + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + if (!line.startsWith(' ')) return undefined; + if (line.startsWith(' ')) continue; + const m = line.match(/^\s{2}llm:\s*(.+)\s*$/); + if (m) return extractLlmRefFromText(m[1]); + } + } + return undefined; +} + +interface SourceNodeTool { + actionDefName: string; + llmInputs: string[]; + boundInputs: Record; +} + +interface CollectedToolInputs { + llmInputs: string[]; + boundInputs: Record; +} + +function parseNodeToolsFromSource( + source: string, + nodeType: string, + nodeName: string +): SourceNodeTool[] { + const lines = source.split(/\r?\n/); + const escapedType = nodeType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const escapedName = nodeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const headerRe = new RegExp(`^${escapedType}\\s+${escapedName}:\\s*$`); + + for (let i = 0; i < lines.length; i++) { + if (!headerRe.test(lines[i].trim())) continue; + + let actionsLine = -1; + let actionsIndent = 0; + let reasoningLine = -1; + let reasoningIndent = 0; + for (let j = i + 1; j < lines.length; j++) { + const line = lines[j]; + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + if (!line.startsWith(' ')) return []; + const actionsMatch = line.match(/^(\s*)actions:\s*$/); + if (actionsMatch) { + actionsLine = j; + actionsIndent = actionsMatch[1]?.length ?? 0; + break; + } + const reasoningMatch = line.match(/^(\s*)reasoning:\s*$/); + if (reasoningMatch) { + reasoningLine = j; + reasoningIndent = reasoningMatch[1]?.length ?? 0; + break; + } + } + if (actionsLine === -1 && reasoningLine !== -1) { + for (let j = reasoningLine + 1; j < lines.length; j++) { + const line = lines[j]; + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + const indent = line.match(/^(\s*)/)?.[1]?.length ?? 0; + if (indent <= reasoningIndent) break; + const actionsMatch = line.match(/^(\s*)actions:\s*$/); + if (actionsMatch) { + actionsLine = j; + actionsIndent = actionsMatch[1]?.length ?? 0; + break; + } + } + } + if (actionsLine === -1) return []; + + const result: SourceNodeTool[] = []; + let current: SourceNodeTool | undefined; + let currentEntryIndent = actionsIndent + 2; + for (let k = actionsLine + 1; k < lines.length; k++) { + const line = lines[k]; + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + const indent = line.match(/^(\s*)/)?.[1]?.length ?? 0; + if (indent <= actionsIndent) break; + + const entryMatch = trimmed.match(/^([\w-]+):\s*@actions\.([\w-]+)\s*$/); + if (entryMatch) { + current = { + actionDefName: entryMatch[2], + llmInputs: [], + boundInputs: {}, + }; + currentEntryIndent = indent; + result.push(current); + continue; + } + + const withMatch = trimmed.match(/^with\s+([\w-]+)\s*=\s*(.+)\s*$/); + if (withMatch && current && indent > currentEntryIndent) { + const key = withMatch[1]; + const raw = withMatch[2].trim(); + if (raw === '...') { + current.llmInputs.push(key); + } else { + const quoted = raw.match(/^(['"])(.*)\1$/); + current.boundInputs[key] = quoted ? quoted[2] : raw; + } + } + } + return result; + } + + return []; +} + +function extractConfigString(value: unknown): string | undefined { + const s = extractString(value); + if (s !== undefined && s !== '[object Object]') return s; + const plain = toPlainData(value); + if ( + typeof plain === 'string' || + typeof plain === 'number' || + typeof plain === 'boolean' + ) { + return String(plain); + } + return undefined; +} + +function parseEnumYamlListString(value: string): string[] | undefined { + const lines = value.split(/\r?\n/).filter(line => line.trim().length > 0); + if (lines.length === 0) return undefined; + + const parsed: string[] = []; + for (const line of lines) { + const match = line.match(/^\s*-\s+(.+)\s*$/); + if (!match) return undefined; + const raw = match[1].trim(); + const quoted = raw.match(/^(['"])(.*)\1$/); + parsed.push(quoted ? quoted[2] : raw); + } + return parsed.length > 0 ? parsed : undefined; +} + +function normalizeOutputStructureEnums(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(v => normalizeOutputStructureEnums(v)); + } + if (!value || typeof value !== 'object') return value; + + const rec = value as Record; + const out: Record = {}; + for (const [key, fieldValue] of Object.entries(rec)) { + if (key === 'enum' && typeof fieldValue === 'string') { + out[key] = parseEnumYamlListString(fieldValue) ?? fieldValue; + continue; + } + out[key] = normalizeOutputStructureEnums(fieldValue); + } + return out; +} + +/** + * Resolve the LLM ref for an agent node and optionally attach + * `output-structure-ref` when outputs are declared on the node. + * + * When the node omits `llm`, uses `defaultLlmRef` from `config.default_llm` if set, + * otherwise falls back to the connection name `"default"`. + */ +function resolveLLMRef( + nodeEntry: Record, + nodeType: string, + nodeId: string, + llmEntries: Map> | undefined, + outputStructures: Record>, + defaultLlmRef: string | undefined, + source: string | undefined, + llmNameAliases: Map | undefined +): LLMRef { + const explicitParsed = stripLlmRef(extractLlmFieldReference(nodeEntry.llm)); + const explicitSource = source + ? extractNodeLlmRefFromSource(source, nodeType, nodeId) + : undefined; + const explicit = explicitParsed ?? explicitSource; + const fromConfig = stripLlmRef(defaultLlmRef); + const providerName = + (explicit && (llmNameAliases?.get(explicit) ?? explicit)) ?? + (fromConfig && (llmNameAliases?.get(fromConfig) ?? fromConfig)) ?? + 'default'; + + const llmEntryKey = + (explicitParsed && + llmEntries && + llmEntries.has(explicitParsed) && + explicitParsed) || + (fromConfig && llmEntries && llmEntries.has(fromConfig) && fromConfig) || + [...(llmNameAliases?.entries() ?? [])].find( + ([parsed, canonical]) => + canonical === providerName && Boolean(llmEntries?.has(parsed)) + )?.[0] || + providerName; + + const configuration: Record = {}; + const llmConfig = llmEntries?.get(llmEntryKey); + if (llmConfig) { + const stringFields = [ + 'model', + 'reasoning_effort', + 'thinking_level', + 'response_logprobs', + ] as const; + const numberFields = [ + 'temperature', + 'top_p', + 'max_output_tokens', + 'thinking_budget', + 'top_logprobs', + ] as const; + + for (const field of stringFields) { + const value = extractConfigString(llmConfig[field]); + if (value !== undefined) { + configuration[field] = value; + } + } + for (const field of numberFields) { + const value = extractNumber(llmConfig[field]); + if (value !== undefined) { + configuration[field] = String(value); + } + } + } + + // If the node declares outputs, register it under a stable key and + // link it via llm.output-structure-ref (without mutating provider ref). + const outputStructure = + nodeType === 'generator' + ? ((nodeEntry.outputs as Record | undefined) ?? + undefined) + : ((nodeEntry.reasoning as Record | undefined) + ?.outputs as Record | undefined); + if (outputStructure) { + const normalizedNodeId = normalizeId(nodeId); + const outputStructureRef = `os_${normalizedNodeId}`; + // Extract the properties map as a plain dict for the runtime + const properties = outputStructure.properties as + | Record + | undefined; + if (properties) { + const plain = toPlainData(properties); + outputStructures[outputStructureRef] = + (normalizeOutputStructureEnums(plain) as Record) ?? + ({} as Record); + } else { + const plain = toPlainData(outputStructure); + outputStructures[outputStructureRef] = + (normalizeOutputStructureEnums(plain) as Record) ?? + ({} as Record); + } + return { + ref: providerName, + configuration, + 'output-structure-ref': outputStructureRef, + }; + } + + return { ref: providerName, configuration }; +} + +function buildAgentTools( + nodeTools: Map> | undefined, + actionDefs: Map> | undefined, + env: ExecuteVariableEnv, + source: string | undefined, + nodeType: string, + nodeName: string +): ToolUnion[] | null { + const sourceTools = + source !== undefined + ? parseNodeToolsFromSource(source, nodeType, nodeName) + : []; + if (!nodeTools && sourceTools.length === 0) return null; + + const tools: ToolUnion[] = []; + const parsedEntries: Array<{ + actionDefName: string; + bodyStatements: unknown[]; + fallbackLlmInputs: string[]; + fallbackBoundInputs: Record; + }> = []; + + for (const [, toolEntry] of nodeTools ?? []) { + const rawColinear = + toolEntry.value ?? + toolEntry.__colinear ?? + toolEntry.colinear ?? + toolEntry.__value; + const colinearRef = decomposeAtMemberExpression(rawColinear); + + let actionDefName: string | undefined; + if (colinearRef && colinearRef.namespace === 'actions') { + actionDefName = colinearRef.property; + } else { + const colinearValue = extractString(rawColinear); + if (colinearValue) { + actionDefName = colinearValue.startsWith('@actions.') + ? colinearValue.substring(9) + : colinearValue; + } + } + if (!actionDefName) continue; + + const body = (toolEntry.body as { statements?: unknown[] } | undefined) ?? { + statements: Array.isArray(toolEntry.statements) + ? toolEntry.statements + : [], + }; + parsedEntries.push({ + actionDefName, + bodyStatements: body?.statements ?? [], + fallbackLlmInputs: [], + fallbackBoundInputs: {}, + }); + } + + for (const st of sourceTools) { + if (parsedEntries.some(p => p.actionDefName === st.actionDefName)) continue; + parsedEntries.push({ + actionDefName: st.actionDefName, + bodyStatements: [], + fallbackLlmInputs: st.llmInputs, + fallbackBoundInputs: st.boundInputs, + }); + } + + for (const entry of parsedEntries) { + const actionDefName = entry.actionDefName; + if (actionDefs && actionDefs.has(actionDefName)) { + const actionDef = actionDefs.get(actionDefName)!; + const kind = extractString(actionDef.kind); + + if (kind === 'mcp:tool' || kind === 'a2a:send_message') { + const collected = collectToolInputs(entry, env, actionDef); + const tool = createCompiledAgentTool(kind, actionDefName, collected); + if (tool) tools.push(tool); + } + } + } + + return tools.length > 0 ? tools : null; +} + +/** + * 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']); + +/** + * Lowercase all JSON/dict-literal keys in an expression string so that + * HTTP header names comply with RFC 9110 case-insensitivity. + */ +export function lowercaseHttpHeaderKeys(expr: string): string { + return expr.replace( + /"([^"]+)"\s*:/g, + (_, key: string) => `"${key.toLowerCase()}":` + ); +} + +function collectToolInputs( + entry: { + bodyStatements: unknown[]; + fallbackLlmInputs: string[]; + fallbackBoundInputs: Record; + }, + env: ExecuteVariableEnv, + actionDef: Record +): CollectedToolInputs { + const llmInputs = [...entry.fallbackLlmInputs]; + const boundInputs = { ...entry.fallbackBoundInputs }; + + for (const stmt of entry.bodyStatements) { + if (!(stmt instanceof WithClause)) continue; + if (stmt.value instanceof Ellipsis) { + llmInputs.push(stmt.param); + continue; + } + + const compiled = compileExecuteExpression(stmt.value, env, 'run-body'); + boundInputs[stmt.param] = + stmt.param === 'http_headers' + ? lowercaseHttpHeaderKeys(compiled) + : compiled; + } + + const llmSeen = new Set(llmInputs); + for (const name of listActionDefInputNames(actionDef)) { + if (Object.prototype.hasOwnProperty.call(boundInputs, name)) continue; + if (llmSeen.has(name)) continue; + llmInputs.push(name); + llmSeen.add(name); + } + + return { llmInputs, boundInputs }; +} + +function createCompiledAgentTool( + kind: string, + actionDefName: string, + collected: CollectedToolInputs +): ToolUnion | null { + if (kind !== 'mcp:tool' && kind !== 'a2a:send_message') { + return null; + } + + const tool: MCPTool | A2ATool = { + type: kind === 'mcp:tool' ? 'mcp_tool' : 'a2a', + ref: `${actionDefName}-client`, + enabled: true, + }; + + if (Object.keys(collected.boundInputs).length > 0) { + tool['bound-inputs'] = collected.boundInputs; + } + if (collected.llmInputs.length > 0) { + tool['llm-inputs'] = collected.llmInputs; + } + + return tool; +} + +function buildHandoffTarget(target: string | null): HandoffAction[] { + if (!target) return []; + return [ + { + type: ObjectTypes.HANDOFF, + target: normalizeId(target), + }, + ]; +} + +/** + * Build the orchestration instructions wrapper. + * Mirrors _build_instructions() in the Python adaptor. + */ +function buildOrchestrationInstructions(instructions: string): string { + const parts: string[] = []; + + parts.push( + 'You are a task decomposition expert that analyzes user requests, ' + + 'identifies required sub-tasks, selects appropriate tools, and ' + + 'synthesizes final answers.\n' + ); + + parts.push( + '1. **Decompose** the query into atomic sub-tasks\n' + + '2. **Match** each sub-task to the appropriate tool below\n' + + '3. **Execute** tools in optimal sequence\n' + + '4. **Synthesize** results into final response\n' + ); + + parts.push( + 'Here is an Example of how to break down a user prompt\n' + + "**User Query:** 'Analyze Q2 earnings for Tesla and compare to Ford in EUR'\n" + + '**Sub-tasks:**\n' + + '1. Get Tesla financials (USD) → Financial Summary Tool\n' + + '2. Get Ford financials (USD) → Financial Summary Tool\n' + + '3. Convert USD figures to EUR → Currency Converter Tool\n' + + '4. Perform comparative analysis → Built-in Analysis Module\n' + ); + + parts.push( + "The User's instructions section contains directives " + + '*YOU MUST* follow when deciding which action to take next.\n\n' + + "### User's instructions\n\n" + ); + parts.push(normalizeTemplate(instructions)); + parts.push('\n'); + + parts.push( + '### Instructions for executing steps, selecting tools and generating output\n\n' + + '- Execute the list of steps in order. For each step, determine if ' + + 'invoking a tool is necessary\n' + + '- When you reach a step that requires a tool, look at the available ' + + 'tools and conversation history to determine the *single best tool* ' + + 'to call next.\n' + ); + + parts.push( + '### Constraints\n\n' + + '- Use the conversation history to avoid redundant tool calls and ' + + 'to track progress toward the goal.\n' + ); + + return parts.join('\n'); +} + +// ── Extract on_exit target ────────────────────────────────────────── + +function extractOnExitTarget(onExitProcedure: unknown): string | null { + if (!onExitProcedure) return null; + + // Prefer procedure-emitted text to avoid matching unrelated references that + // may exist in serialized AST internals. + const emitted = extractProcedureText(onExitProcedure); + const text = + emitted || (typeof onExitProcedure === 'string' ? onExitProcedure : ''); + + // Match only explicit transition targets. + const match = text.match(/transition\s+to\s+@(\w+)\.(\w[\w-]*)/i); + if (match) { + return match[2]; + } + return null; +} + +// ── System limits extraction ──────────────────────────────────────── + +function extractSystemLimits( + entry: Record +): NodeSystemLimits | undefined { + const reasoning = entry.reasoning as Record | undefined; + if (!reasoning) return undefined; + + const maxLoops = extractNumber(reasoning.max_number_of_loops); + const maxErrors = extractNumber(reasoning.max_consecutive_errors); + const timeout = extractNumber(reasoning.task_timeout_secs); + + if (maxLoops == null && maxErrors == null && timeout == null) { + return undefined; + } + + const limits: NodeSystemLimits = {}; + if (maxLoops != null) limits['max-reasoning-iterations'] = maxLoops; + if (maxErrors != null) limits['max-consecutive-errors'] = maxErrors; + if (timeout != null) limits['task-timeout-secs'] = timeout; + return limits; +} + +// ── Node builders ─────────────────────────────────────────────────── + +function buildOrchestrationNode( + name: string, + entry: Record, + isInitialNode: boolean, + llmEntries: Map> | undefined, + actionDefs: Map> | undefined, + outputStructures: Record>, + globalSystemInstructions: string, + defaultLlmRef: string | undefined, + source: string | undefined, + llmNameAliases: Map | undefined, + env: ExecuteVariableEnv +): Node[] { + const normalizedName = normalizeId(name); + const onExitTarget = resolveTarget( + extractOnExitTarget(entry.on_exit) ?? null + ); + + const systemInstructions = combineGlobalSystemInstructions( + globalSystemInstructions, + extractProcedureText( + (entry.system as Record | undefined)?.instructions + ) + ); + const prompt = extractProcedureText( + (entry.reasoning as Record | undefined)?.instructions + ); + + const agentNode: AgentNode = { + name: normalizedName, + label: extractString(entry.label) ?? null, + description: extractString(entry.description) ?? null, + type: ObjectTypes.AGENT, + llm: resolveLLMRef( + entry, + 'orchestrator', + name, + llmEntries, + outputStructures, + defaultLlmRef, + source, + llmNameAliases + ), + 'on-init': buildOnInit(isInitialNode), + 'system-prompt': normalizeTemplate(prompt), + 'focus-prompt': buildOrchestrationInstructions(systemInstructions), + tools: buildAgentTools( + (entry.reasoning as Record | undefined)?.actions as + | Map> + | undefined, + actionDefs, + env, + source, + 'orchestrator', + name + ), + 'after-reasoning': buildHandoffTarget(onExitTarget), + }; + const systemLimits = extractSystemLimits(entry); + if (systemLimits) agentNode['system-limits'] = systemLimits; + return [agentNode]; +} + +function buildReasoningNode( + name: string, + entry: Record, + isInitialNode: boolean, + llmEntries: Map> | undefined, + actionDefs: Map> | undefined, + outputStructures: Record>, + globalSystemInstructions: string, + defaultLlmRef: string | undefined, + source: string | undefined, + llmNameAliases: Map | undefined, + env: ExecuteVariableEnv +): Node[] { + const normalizedName = normalizeId(name); + const onExitTarget = resolveTarget( + extractOnExitTarget(entry.on_exit) ?? null + ); + + const systemInstructions = combineGlobalSystemInstructions( + globalSystemInstructions, + extractProcedureText( + (entry.system as Record | undefined)?.instructions + ) + ); + const prompt = extractProcedureText( + (entry.reasoning as Record | undefined)?.instructions + ); + + const agentNode: AgentNode = { + name: normalizedName, + label: extractString(entry.label) ?? null, + description: extractString(entry.description) ?? null, + type: ObjectTypes.AGENT, + llm: resolveLLMRef( + entry, + 'subagent', + name, + llmEntries, + outputStructures, + defaultLlmRef, + source, + llmNameAliases + ), + 'on-init': buildOnInit(isInitialNode), + 'system-prompt': normalizeTemplate(prompt), + 'focus-prompt': systemInstructions.trim() + ? normalizeTemplate(systemInstructions) + : null, + tools: buildAgentTools( + (entry.reasoning as Record | undefined)?.actions as + | Map> + | undefined, + actionDefs, + env, + source, + 'subagent', + name + ), + 'after-reasoning': buildHandoffTarget(onExitTarget), + }; + const systemLimits = extractSystemLimits(entry); + if (systemLimits) agentNode['system-limits'] = systemLimits; + return [agentNode]; +} + +function buildGenerateNode( + name: string, + entry: Record, + isInitialNode: boolean, + llmEntries: Map> | undefined, + outputStructures: Record>, + globalSystemInstructions: string, + defaultLlmRef: string | undefined, + source: string | undefined, + llmNameAliases: Map | undefined +): Node[] { + const normalizedName = normalizeId(name); + const onExitTarget = resolveTarget( + extractOnExitTarget(entry.on_exit) ?? null + ); + + const systemInstructions = combineGlobalSystemInstructions( + globalSystemInstructions, + extractProcedureText( + (entry.system as Record | undefined)?.instructions + ) + ); + const prompt = extractProcedureText(entry.prompt); + + const agentNode: AgentNode = { + name: normalizedName, + label: extractString(entry.label) ?? null, + description: extractString(entry.description) ?? null, + type: ObjectTypes.AGENT, + llm: resolveLLMRef( + entry, + 'generator', + name, + llmEntries, + outputStructures, + defaultLlmRef, + source, + llmNameAliases + ), + 'on-init': buildOnInit(isInitialNode), + 'system-prompt': normalizeTemplate(prompt), + 'focus-prompt': systemInstructions.trim() + ? normalizeTemplate(systemInstructions) + : null, + tools: null, + 'after-reasoning': buildHandoffTarget(onExitTarget), + }; + return [agentNode]; +} + +function buildExecuteNode( + name: string, + entry: Record, + isInitialNode: boolean, + actionDefs: Map> | undefined, + ast: Record +): Node[] { + const normalizedName = normalizeId(name); + const onExitTarget = resolveTarget( + extractOnExitTarget(entry.on_exit) ?? null + ); + + const compiledTools = compileExecuteDoProcedure( + entry.do, + actionDefs, + ast, + normalizedName + ); + const tools: ActionCallableReference[] = + compiledTools.length > 0 + ? compiledTools + : [{ ref: 'IdentityAction', 'state-updates': [] }]; + + const node: ActionNode = { + name: normalizedName, + type: ObjectTypes.ACTION, + label: extractString(entry.label) ?? null, + tools, + 'on-init': buildOnInit(isInitialNode), + 'on-exit': onExitTarget ? buildHandoffTarget(onExitTarget) : null, + 'add-tool-result-to-chat-history': false, + 'output-template': null, + }; + + return [node]; +} + +const SWITCH_TARGET_NAMESPACES = new Set([ + 'orchestrator', + 'subagent', + 'generator', + 'executor', + 'router', + 'echo', +]); + +function asSwitchTarget(value: unknown): string | undefined { + const candidates: unknown[] = [value]; + if (value && typeof value === 'object') { + const rec = value as Record; + if (rec.value !== undefined) candidates.push(rec.value); + } + + for (const candidate of candidates) { + const ref = decomposeAtMemberExpression(candidate); + if (ref && SWITCH_TARGET_NAMESPACES.has(ref.namespace)) { + return normalizeId(ref.property); + } + + const s = extractString(candidate); + if (s === undefined || s === '[object Object]') continue; + const m = s.match(/^@(\w+)\.([\w-]+)$/); + if (!m) continue; + if (!SWITCH_TARGET_NAMESPACES.has(m[1])) continue; + return normalizeId(m[2]); + } + return undefined; +} + +function asObjectList(value: unknown): Record[] { + if (Array.isArray(value)) { + return value.filter( + (v): v is Record => v != null && typeof v === 'object' + ); + } + if (value && typeof value === 'object' && Symbol.iterator in value) { + const out: Record[] = []; + for (const item of value as Iterable) { + const candidate = + Array.isArray(item) && item.length === 2 ? item[1] : item; + if (candidate && typeof candidate === 'object') { + out.push(candidate as Record); + } + } + return out; + } + if (value && typeof value === 'object') { + const rec = value as Record; + if (Array.isArray(rec.items)) { + return rec.items.filter( + (v): v is Record => v != null && typeof v === 'object' + ); + } + } + return []; +} + +function buildSwitchNode( + name: string, + entry: Record, + isInitialNode: boolean, + env: ExecuteVariableEnv +): Node[] { + const normalizedName = normalizeId(name); + + const onExit: HandoffAction[] = []; + + const routes = asObjectList(entry.routes); + for (const route of routes) { + if (!route || typeof route !== 'object') continue; + const r = route as Record; + const target = asSwitchTarget(r.target); + const whenRaw = extractString(r.when); + const when = + whenRaw && whenRaw !== '[object Object]' + ? whenRaw + : r.when && typeof r.when === 'object' + ? compileExecuteExpression(r.when as Expression, env, 'execute') + : undefined; + if (!target || !when || when === '[object Object]') { + continue; + } + onExit.push({ + type: ObjectTypes.HANDOFF, + target, + enabled: when.trim(), + }); + } + + const otherwiseBlock = entry.otherwise as Record | undefined; + if (otherwiseBlock && typeof otherwiseBlock === 'object') { + const otherwiseTarget = asSwitchTarget(otherwiseBlock.target); + if (otherwiseTarget) { + onExit.push({ + type: ObjectTypes.HANDOFF, + target: otherwiseTarget, + }); + } + } + + const node: ActionNode = { + name: normalizedName, + type: ObjectTypes.ACTION, + label: extractString(entry.label) ?? null, + tools: [], + 'on-init': buildOnInit(isInitialNode), + 'on-exit': onExit.length > 0 ? onExit : null, + 'add-tool-result-to-chat-history': false, + 'output-template': null, + }; + + return [node]; +} + +function buildEchoNode( + name: string, + entry: Record, + isInitialNode: boolean, + env: ExecuteVariableEnv +): Node[] { + const normalizedName = normalizeId(name); + const onExitTarget = resolveTarget( + extractOnExitTarget(entry.on_exit) ?? null + ); + + const tmpVar = `__${normalizedName}_value`; + let stateUpdateValue: string; + + if ( + entry.task != null && + typeof entry.task === 'object' && + '__kind' in entry.task + ) { + stateUpdateValue = compileExecuteExpression(entry.task as Expression, env); + } else { + const message = extractString(entry.message) ?? ''; + const outputJson = JSON.stringify({ + state: 'completed', + message: { + kind: 'text', + role: 'agent', + parts: [{ kind: 'text', text: message }], + }, + }); + stateUpdateValue = `template::${normalizeTemplate(outputJson)}`; + } + + const stateUpdates = [ + { [tmpVar]: stateUpdateValue }, + { + outputs: `add(state.outputs, "${normalizedName}", state.${tmpVar})`, + }, + ]; + + const node: ActionNode = { + name: normalizedName, + type: ObjectTypes.ACTION, + label: extractString(entry.label) ?? null, + tools: [ + { + ref: 'IdentityAction', + 'state-updates': stateUpdates, + }, + ], + 'on-init': buildOnInit(isInitialNode), + 'on-exit': onExitTarget ? buildHandoffTarget(onExitTarget) : null, + 'add-tool-result-to-chat-history': false, + 'output-template': null, + }; + + const echoDescription = extractString(entry.description); + if (echoDescription !== undefined && echoDescription !== '') { + node.description = echoDescription; + } + + return [node]; +} + +// ── _node_input tracking injection ────────────────────────────────── + +function isProducingNode(node: Node): boolean { + if (node.type === ObjectTypes.AGENT) return true; + if (node.type === ObjectTypes.ACTION) { + const action = node as ActionNode; + return action.tools.some(t => t.ref !== 'IdentityAction'); + } + return false; +} + +function nodeContainsNodeInputRef(node: Node): boolean { + return JSON.stringify(node).includes('state._node_input'); +} + +function appendHandoffBreadcrumb( + handoffs: HandoffActionUnion[], + sourceName: string +): void { + for (const h of handoffs) { + if ((h as HandoffAction).type !== ObjectTypes.HANDOFF) continue; + const handoff = h as HandoffAction; + const updates = handoff['state-updates'] ?? []; + updates.push({ _handoff_source: `'${sourceName}'` }); + handoff['state-updates'] = updates; + } +} + +function prependNodeInputLookup(node: Node): void { + const lookup: ActionCallableReference = { + type: ObjectTypes.ACTION, + ref: 'IdentityAction', + 'state-updates': [ + { _node_input: "get(system.node_outputs, state._handoff_source, '')" }, + ], + }; + const existing: HandoffActionUnion[] = + (node as AgentNode | ActionNode)['on-init'] ?? []; + (node as AgentNode | ActionNode)['on-init'] = [lookup, ...existing]; +} + +/** + * Post-process compiled nodes to inject _node_input tracking plumbing. + * + * 1. Producing nodes get a `_handoff_source` breadcrumb on every handoff. + * 2. Nodes whose compiled output references `state._node_input` get an + * on-init IdentityAction that resolves the deferred lookup. + * + * Returns true if any injection occurred (caller should add state variables). + */ +export function injectNodeInputTracking(nodes: Node[]): boolean { + let injected = false; + + for (const node of nodes) { + if (!isProducingNode(node)) continue; + + const agentNode = node as AgentNode; + if (agentNode['after-reasoning']) { + appendHandoffBreadcrumb(agentNode['after-reasoning'], node.name); + injected = true; + } + + const actionNode = node as ActionNode; + if (actionNode['on-exit']) { + appendHandoffBreadcrumb(actionNode['on-exit'], node.name); + injected = true; + } + } + + for (const node of nodes) { + if (nodeContainsNodeInputRef(node)) { + prependNodeInputLookup(node); + injected = true; + } + } + + return injected; +} + +// ── Public API ────────────────────────────────────────────────────── + +/** + * Build all nodes from parsed AST blocks. + * Iterates blocks in definition order and creates the appropriate node types. + */ +export function buildNodes( + ast: Record, + llmEntries: Map> | undefined, + actionDefs: Map> | undefined, + initialNode: string, + /** Original source — used by source-based fallbacks (llm/tool extraction). */ + source?: string, + llmNameAliases?: Map +): { + nodes: Node[]; + outputStructures: Record>; +} { + const outputStructures: Record> = {}; + const result: Node[] = []; + const env = collectExecuteVariableEnv(ast); + const globalSystemInstructions = extractGlobalSystemInstructions(ast); + const config = ast.config as Record | undefined; + const defaultLlmFromConfig = + extractLlmFieldReference(config?.default_llm) ?? + (source ? extractDefaultLlmRefFromSource(source) : undefined); + + const nodeBlocks: Array<{ + type: string; + entries: Map>; + }> = []; + + // Collect all node-type blocks (NamedMap or Map — not `instanceof Map`) + for (const nodeType of [ + 'orchestrator', + 'subagent', + 'generator', + 'executor', + 'router', + 'echo', + ]) { + const block = ast[nodeType]; + for (const [name, entry] of iterateCollection(block)) { + nodeBlocks.push({ + type: nodeType, + entries: new Map([[name, entry]]), + }); + } + } + + for (const { type, entries } of nodeBlocks) { + for (const [name, entry] of entries) { + let nodes: Node[]; + const isInitialNode = normalizeId(name) === normalizeId(initialNode); + + switch (type) { + case 'orchestrator': + nodes = buildOrchestrationNode( + name, + entry, + isInitialNode, + llmEntries, + actionDefs, + outputStructures, + globalSystemInstructions, + defaultLlmFromConfig, + source, + llmNameAliases, + env + ); + break; + case 'subagent': + nodes = buildReasoningNode( + name, + entry, + isInitialNode, + llmEntries, + actionDefs, + outputStructures, + globalSystemInstructions, + defaultLlmFromConfig, + source, + llmNameAliases, + env + ); + break; + case 'generator': + nodes = buildGenerateNode( + name, + entry, + isInitialNode, + llmEntries, + outputStructures, + globalSystemInstructions, + defaultLlmFromConfig, + source, + llmNameAliases + ); + break; + case 'executor': + nodes = buildExecuteNode(name, entry, isInitialNode, actionDefs, ast); + break; + case 'router': + nodes = buildSwitchNode(name, entry, isInitialNode, env); + break; + case 'echo': + nodes = buildEchoNode(name, entry, isInitialNode, env); + break; + default: + continue; + } + + result.push(...nodes); + } + } + + return { + nodes: result, + outputStructures, + }; +} + +function primitiveTypeString(type: Expression): string { + if (type instanceof Identifier) return type.name; + if ( + type instanceof SubscriptExpression && + type.object instanceof Identifier + ) { + return `${type.object.name}[]`; + } + return 'string'; +} + +function defaultFromVariableExpression(expr: Expression | undefined): unknown { + if (expr === undefined) return undefined; + if (expr instanceof StringLiteral) return expr.value; + if (expr instanceof NumberLiteral) return expr.value; + if (expr instanceof BooleanLiteral) return expr.value; + if (expr instanceof NoneLiteral) return null; + return undefined; +} + +function defaultForDataType(dataType: string): unknown { + if (dataType === 'object') return {}; + if (dataType.endsWith('[]')) return []; + if (dataType === 'number') return 0; + if (dataType === 'boolean') return false; + return ''; +} + +/** + * Build state variable entries from the `variables:` block (excluding reserved `outputs`). + */ +function buildUserDeclaredStateVariables( + ast: Record +): StateVariable[] { + const result: StateVariable[] = []; + const vars = ast.variables; + if (!isNamedMap(vars)) return result; + + for (const [name, entry] of vars) { + if (!(entry instanceof VariableDeclarationNode)) continue; + const n = normalizeId(name); + if (n === 'outputs') continue; + + const dataType = primitiveTypeString(entry.type); + const explicit = defaultFromVariableExpression(entry.defaultValue); + const defaultVal = + explicit !== undefined ? explicit : defaultForDataType(dataType); + + result.push({ + name: n, + label: name, + 'data-type': dataType, + description: '', + default: defaultVal, + }); + } + + return result; +} + +/** + * Build graph state variables: built-in `outputs` map plus declarations from `variables:`. + */ +export function buildStateVariables( + ast: Record +): StateVariable[] { + const outputs: StateVariable = { + name: 'outputs', + label: 'Node outputs', + 'data-type': 'object', + description: + 'Map of action/execute node outputs in state namespace (agent outputs are in system.node_outputs)', + default: {}, + }; + + const user = buildUserDeclaredStateVariables(ast); + return [outputs, ...user.filter(v => v.name !== 'outputs')]; +} + +/** + * Resolve the initial node from the trigger's on_message transition target. + */ +export function resolveInitialNode( + triggers: Map> +): string { + const [, triggerEntry] = triggers.entries().next().value as [ + string, + Record, + ]; + const transitionRef = extractTransitionReference(triggerEntry.on_message); + const targetMatch = transitionRef.match( + /^@([A-Za-z_][\w]*)\.([A-Za-z0-9_-]+)$/ + )!; + return normalizeId(targetMatch[2]); +} + +/** + * Collect echo node names that are a2a:response type. + */ +export function collectResponseNodeNames( + echoEntries: Map> | undefined +): string[] { + const names = new Set(); + if (!echoEntries) return []; + + for (const [name, entry] of echoEntries) { + const kind = extractString((entry as Record).kind); + if (kind === 'a2a:response') { + names.add(normalizeId(name)); + } + } + + return [...names]; +} diff --git a/dialect/agentfabric/src/compiler/build-providers.ts b/dialect/agentfabric/src/compiler/build-providers.ts new file mode 100644 index 00000000..318ff77e --- /dev/null +++ b/dialect/agentfabric/src/compiler/build-providers.ts @@ -0,0 +1,253 @@ +/** + * Build LLMProvider[] and InvokableClient[] from parsed AST. + * Mirrors _get_llm_providers() and _get_invokable_clients() in the Python adaptor. + */ + +import type { LLMProvider, InvokableClient } from './service-types.js'; +import type { AgentFabricCompilerContext } from './compiler-context.js'; +import { + extractNumber, + extractString, + iterateCollection, + toPlainData, +} from './utils.js'; + +function stripConnectionPrefix(target: string | undefined): string | undefined { + if (!target) return undefined; + return target.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, ''); +} + +function safeExtractString(value: unknown): string | undefined { + const s = extractString(value); + if (s === undefined || s === '[object Object]') return undefined; + return normalizeQuoted(s); +} + +function normalizeQuoted(s: string): string { + let out = s.trim(); + out = out.replace(/^\\?['"]/, ''); + out = out.replace(/\\?['"]$/, ''); + return out; +} + +function extractHeadersMap( + headersValue: unknown +): Record | undefined { + const byCollection = iterateCollection(headersValue); + if (byCollection.length > 0) { + const out: Record = {}; + for (const [headerName, headerEntry] of byCollection) { + let headerRaw = safeExtractString( + (headerEntry as Record).__colinear ?? + (headerEntry as Record).colinear + ); + if (headerRaw === undefined) { + const plain = toPlainData(headerEntry); + if ( + typeof plain === 'string' || + typeof plain === 'number' || + typeof plain === 'boolean' + ) { + headerRaw = String(plain); + } + } + out[normalizeQuoted(headerName)] = headerRaw ?? null; + } + return out; + } + + if (headersValue && typeof headersValue === 'object') { + const hv = headersValue as Record; + const rawEntries = hv.entries; + if (Array.isArray(rawEntries)) { + const out: Record = {}; + for (const item of rawEntries) { + if (!item || typeof item !== 'object') continue; + const kv = item as Record; + const plainKey = toPlainData(kv.key); + const plainValue = toPlainData(kv.value); + const key = + safeExtractString(kv.key) ?? + (typeof plainKey === 'string' || + typeof plainKey === 'number' || + typeof plainKey === 'boolean' + ? String(plainKey) + : undefined) ?? + safeExtractString(kv.name) ?? + safeExtractString(kv.__key); + const value = + safeExtractString(kv.value) ?? + (typeof plainValue === 'string' || + typeof plainValue === 'number' || + typeof plainValue === 'boolean' + ? String(plainValue) + : undefined) ?? + safeExtractString(kv.__value); + if (key !== undefined) out[normalizeQuoted(key)] = value ?? null; + } + if (Object.keys(out).length > 0) return out; + } + } + + const plainHeaders = toPlainData(headersValue); + if ( + plainHeaders && + typeof plainHeaders === 'object' && + !Array.isArray(plainHeaders) + ) { + const out: Record = {}; + for (const [headerName, headerValue] of Object.entries(plainHeaders)) { + if (headerName === 'entries' && Array.isArray(headerValue)) continue; + out[normalizeQuoted(headerName)] = + headerValue === null || headerValue === undefined + ? null + : normalizeQuoted(String(headerValue)); + } + if (Object.keys(out).length > 0) return out; + } + + return undefined; +} + +function lowercaseHeaderKeys( + headers: Record | undefined +): Record | undefined { + if (!headers) return undefined; + const out: Record = {}; + for (const [key, value] of Object.entries(headers)) { + out[key.toLowerCase()] = value; + } + return out; +} + +export function buildLLMProviders( + llmEntries: Map> | undefined, + llmNameAliases: Map | undefined, + _ctx: AgentFabricCompilerContext +): LLMProvider[] { + const providers: LLMProvider[] = []; + + if (!llmEntries) return providers; + + for (const [name, entry] of llmEntries) { + const providerName = llmNameAliases?.get(name) ?? name; + const kind = extractString((entry as Record).kind) ?? ''; + const normalizedKind = kind.toLowerCase(); + + let platform: string; + if (normalizedKind.startsWith('openai')) { + platform = 'openai'; + } else if (normalizedKind.startsWith('gemini')) { + platform = 'gemini'; + } else { + platform = normalizedKind; + } + + const target = extractString((entry as Record).target); + const connection = stripConnectionPrefix(target); + + const headersValue = (entry as Record).headers; + const headers = lowercaseHeaderKeys(extractHeadersMap(headersValue)); + + const timeout = extractNumber((entry as Record).timeout); + const apiKey = extractString((entry as Record).api_key); + + const metadata: LLMProvider['metadata'] = { + platform, + connection, + }; + if (headers !== undefined) metadata.headers = headers; + if (timeout !== undefined) metadata.timeout = timeout; + if (apiKey !== undefined) metadata.api_key = apiKey; + + providers.push({ + name: providerName, + description: `LLM provider: ${providerName}`, + metadata, + }); + } + + return providers; +} + +export function buildInvokableClients( + toolDefs: Map> | undefined, + _ctx: AgentFabricCompilerContext +): InvokableClient[] { + const clients: InvokableClient[] = []; + + function buildToolClientBase( + name: string, + type: InvokableClient['type'], + metadata: Record, + label?: string + ): InvokableClient { + return { + name: `${name}-client`, + type, + label: label ?? name, + metadata, + }; + } + + function buildMcpToolClient( + name: string, + connection: string, + display: { label?: string; description?: string }, + toolName?: string + ): InvokableClient { + const metadata: Record = { + description: display.description ?? `MCP tool: ${name}`, + connection, + transport: 'streamable-http', + }; + if (toolName) metadata.tool_name = toolName; + return buildToolClientBase(name, 'mcp_tool', metadata, display.label); + } + + function buildA2AClient( + name: string, + connection: string, + display: { label?: string; description?: string } + ): InvokableClient { + const metadata: Record = { connection }; + if (display.description !== undefined) { + metadata.description = display.description; + } + return buildToolClientBase(name, 'a2a', metadata, display.label); + } + + if (toolDefs) { + for (const [name, def] of toolDefs) { + const rec = def as Record; + const kind = extractString(rec.kind); + const target = extractString(rec.target) ?? ''; + const connection = stripConnectionPrefix(target) ?? ''; + const userLabel = extractString(rec.label); + const userDescription = extractString(rec.description); + const display = { + label: userLabel, + description: userDescription, + }; + + if (kind === 'mcp:tool') { + const toolName = extractString(rec.tool_name); + clients.push(buildMcpToolClient(name, connection, display, toolName)); + } else if (kind === 'a2a:send_message') { + clients.push(buildA2AClient(name, connection, display)); + } + } + } + + // Always include built-in internal client + clients.push({ + name: 'in-built', + type: 'internal-action', + label: 'Internal Actions', + metadata: { + description: 'Built-in actions for state management', + }, + }); + + return clients; +} diff --git a/dialect/agentfabric/src/compiler/compile-execute-do.ts b/dialect/agentfabric/src/compiler/compile-execute-do.ts new file mode 100644 index 00000000..c8b2b426 --- /dev/null +++ b/dialect/agentfabric/src/compiler/compile-execute-do.ts @@ -0,0 +1,460 @@ +/** + * Compile `execute.do` procedures into ActionCallableReference[] for the graph. + * Supports `set` (IdentityAction) and `run @actions.*` (action definitions) with the same + * expression shapes used in AgentScript: @variables.*, @request.*, and + * @..output (for graph node outputs). + */ + +import type { + Expression, + Statement, + TemplatePart, +} from '@agentscript/language'; +import { + AtIdentifier, + BinaryExpression, + BooleanLiteral, + CallExpression, + ComparisonExpression, + DictLiteral, + Ellipsis, + Identifier, + ListLiteral, + MemberExpression, + NoneLiteral, + NumberLiteral, + RunStatement, + SetClause, + SpreadExpression, + StringLiteral, + SubscriptExpression, + TemplateExpression, + TemplateInterpolation, + TemplateText, + TernaryExpression, + UnaryExpression, + WithClause, + decomposeAtMemberExpression, + decomposeMemberExpression, + isNamedMap, +} from '@agentscript/language'; +import type { ActionCallableReference } from './unified-agent-specification.js'; +import { ObjectTypes } from './unified-agent-specification.js'; +import { normalizeId, extractString } from './utils.js'; +import { AgentFabricSchemaInfo } from '../schema.js'; +import { lowercaseHttpHeaderKeys } from './build-nodes.js'; + +export interface ExecuteVariableEnv { + /** Mutable variable names (normalized snake_case). */ + mutable: ReadonlySet; + /** Linked variable names (read from external context). */ + linked: ReadonlySet; +} + +/** + * - `execute`: top-level `executor.do` (previous-node data lives in `state.outputs`). + * - `run-body`: inside `run @actions.*` — `@outputs.*` refers to the action invocation result. + */ +export type ExecuteExpressionMode = 'execute' | 'run-body'; + +const NAMESPACED_FUNCTION_NAMES: ReadonlySet = new Set( + Object.keys(AgentFabricSchemaInfo.namespacedFunctions!) +); + +function isA2aNamespaceCall(expr: CallExpression): boolean { + if (!(expr.func instanceof MemberExpression)) return false; + const ref = + decomposeAtMemberExpression(expr.func) ?? + decomposeMemberExpression(expr.func, NAMESPACED_FUNCTION_NAMES); + return ref?.namespace === 'a2a'; +} + +const SYSTEM_NODE_OUTPUT_NAMESPACES = new Set([ + 'orchestrator', + 'subagent', + 'generator', +]); + +const NODE_OUTPUTS_RE = /^system\.node_outputs\['[^']+'\]$/; + +/** + * Wrap a bare `system.node_outputs['X']` reference with `parse_json()` so that + * subsequent attribute / subscript access works at runtime (node_outputs values + * are JSON strings, not dicts). Bare references without further access are + * left unchanged — they're used as whole string values. + */ +function wrapNodeOutputParseJson(compiled: string): string { + return NODE_OUTPUTS_RE.test(compiled) ? `parse_json(${compiled})` : compiled; +} + +const ALL_NODE_NAMESPACES = new Set([ + 'orchestrator', + 'subagent', + 'generator', + 'executor', + 'router', + 'echo', +]); + +/** + * Collect variable namespaces from the parsed `variables:` block. + */ +export function collectExecuteVariableEnv( + ast: Record +): ExecuteVariableEnv { + const mutable = new Set(); + const linked = new Set(); + + const vars = ast.variables; + if (!isNamedMap(vars)) { + return { mutable, linked }; + } + + for (const [name, entry] of vars) { + if (entry == null || typeof entry !== 'object') continue; + const e = entry as Record; + const mod = e.modifier as { name?: string } | undefined; + const key = normalizeId(name); + if (mod?.name === 'linked') { + linked.add(key); + } else { + // mutable or unmarked — treat as assignable state for execute + mutable.add(key); + } + } + + return { mutable, linked }; +} + +function getProcedureStatements(doValue: unknown): Statement[] { + if (doValue == null || typeof doValue !== 'object') return []; + const rec = doValue as Record; + if (!Array.isArray(rec.statements)) return []; + return rec.statements as Statement[]; +} + +/** + * Compile an expression for IdentityAction / tool state-updates (Python-style runtime strings). + */ +export function compileExecuteExpression( + expr: Expression, + env: ExecuteVariableEnv, + mode: ExecuteExpressionMode = 'execute' +): string { + return compileExpr(expr, env, mode); +} + +function compileExpr( + expr: Expression, + env: ExecuteVariableEnv, + mode: ExecuteExpressionMode +): string { + if (expr instanceof MemberExpression) { + const objectRef = decomposeAtMemberExpression(expr.object as Expression); + if ( + objectRef?.namespace === 'request' && + objectRef.property === 'headers' + ) { + return `state.request.headers['${expr.property.toLowerCase()}']`; + } + } + + if ( + expr instanceof MemberExpression && + expr.property === 'output' && + expr.object instanceof MemberExpression + ) { + const nodeRef = decomposeAtMemberExpression(expr.object); + if (nodeRef && nodeRef.namespace === 'executor') { + return `state.outputs['${normalizeId(nodeRef.property)}']`; + } + if (nodeRef && SYSTEM_NODE_OUTPUT_NAMESPACES.has(nodeRef.namespace)) { + return `system.node_outputs['${normalizeId(nodeRef.property)}']`; + } + } + + if ( + expr instanceof MemberExpression && + expr.property === 'input' && + expr.object instanceof MemberExpression + ) { + const nodeRef = decomposeAtMemberExpression(expr.object); + if (nodeRef && ALL_NODE_NAMESPACES.has(nodeRef.namespace)) { + return 'state._node_input'; + } + } + + if (expr instanceof MemberExpression) { + // Unify @namespace.property and bare namespace.property into the same path. + const decomposed = + decomposeAtMemberExpression(expr) ?? + decomposeMemberExpression(expr, NAMESPACED_FUNCTION_NAMES); + if (decomposed) { + const { namespace, property } = decomposed; + const prop = normalizeId(property); + + switch (namespace) { + case 'variables': { + if (env.linked.has(prop)) { + return `variables['${prop}']`; + } + return `state.${prop}`; + } + case 'outputs': + return `result.${prop}`; + case 'request': + return `state.request.${prop}`; + case 'a2a': + return `a2a_${prop}`; + default: { + const obj = compileExpr(expr.object as Expression, env, mode); + return `${wrapNodeOutputParseJson(obj)}.${expr.property}`; + } + } + } + + const obj = compileExpr(expr.object as Expression, env, mode); + if (expr.property === 'length') { + return `len(${wrapNodeOutputParseJson(obj)})`; + } + return `${wrapNodeOutputParseJson(obj)}.${expr.property}`; + } + + if (expr instanceof SubscriptExpression) { + const objectRef = decomposeAtMemberExpression(expr.object as Expression); + if ( + objectRef?.namespace === 'request' && + objectRef.property === 'headers' + ) { + const index = compileExpr(expr.index as Expression, env, mode); + return `state.request.headers[lower(${index})]`; + } + if (expr.object instanceof AtIdentifier && expr.object.name === 'outputs') { + const index = compileExpr(expr.index as Expression, env, mode); + if (mode === 'run-body') { + return `result[${index}]`; + } + return `state.outputs[${index}]`; + } + const obj = compileExpr(expr.object as Expression, env, mode); + const index = compileExpr(expr.index as Expression, env, mode); + return `${wrapNodeOutputParseJson(obj)}[${index}]`; + } + + if (expr instanceof Identifier) { + return expr.name; + } + + if (expr instanceof StringLiteral) { + return JSON.stringify(expr.value); + } + + if (expr instanceof NumberLiteral) { + return String(expr.value); + } + + if (expr instanceof BooleanLiteral) { + return expr.value ? 'True' : 'False'; + } + + if (expr instanceof NoneLiteral) { + return 'None'; + } + + if (expr instanceof UnaryExpression) { + const operand = compileExpr(expr.operand, env, mode); + if (expr.operator === 'not') { + return `not ${operand}`; + } + return `${expr.operator}${operand}`; + } + + if (expr instanceof BinaryExpression) { + const left = compileExpr(expr.left, env, mode); + const right = compileExpr(expr.right, env, mode); + return `${left} ${expr.operator} ${right}`; + } + + if (expr instanceof ComparisonExpression) { + const left = compileExpr(expr.left, env, mode); + const right = compileExpr(expr.right, env, mode); + return `${left} ${expr.operator} ${right}`; + } + + if (expr instanceof TernaryExpression) { + const consequence = compileExpr(expr.consequence, env, mode); + const condition = compileExpr(expr.condition, env, mode); + const alternative = compileExpr(expr.alternative, env, mode); + return `${consequence} if ${condition} else ${alternative}`; + } + + if (expr instanceof CallExpression) { + const func = compileExpr(expr.func as Expression, env, mode); + if ( + isA2aNamespaceCall(expr) && + expr.args.length === 1 && + expr.args[0] instanceof DictLiteral + ) { + const dict = expr.args[0]; + const kwargs = dict.entries + .map( + e => + `${compileExpr(e.key, env, mode)}=${compileExpr(e.value, env, mode)}` + ) + .join(', '); + return `${func}(${kwargs})`; + } + const args = expr.args + .map((a: Expression) => compileExpr(a, env, mode)) + .join(', '); + return `${func}(${args})`; + } + + if (expr instanceof ListLiteral) { + const elements = expr.elements + .map((e: Expression) => compileExpr(e, env, mode)) + .join(', '); + return `[${elements}]`; + } + + if (expr instanceof DictLiteral) { + const pairs = expr.entries + .map( + e => + `${compileExpr(e.key, env, mode)}: ${compileExpr(e.value, env, mode)}` + ) + .join(', '); + return `{${pairs}}`; + } + + if (expr instanceof TemplateExpression) { + if (expr.parts.length === 0) { + // Parser can represent "" as an empty template expression; runtime expects + // a valid Python string literal expression, not an empty expression. + return '""'; + } + return expr.parts + .map((part: TemplatePart) => compileTemplatePart(part, env, mode)) + .join(''); + } + + if (expr instanceof Ellipsis) { + return '...'; + } + + if (expr instanceof SpreadExpression) { + return `*${compileExpr(expr.expression, env, mode)}`; + } + + return ''; +} + +function compileTemplatePart( + part: TemplatePart, + env: ExecuteVariableEnv, + mode: ExecuteExpressionMode +): string { + if (part instanceof TemplateText) { + return part.value; + } + if (part instanceof TemplateInterpolation) { + const compiled = compileExpr(part.expression, env, mode); + const normalized = compiled.replace(/\brequest\./g, 'state.request.'); + return `{{${normalized}}}`; + } + return ''; +} + +function actionDefRef(actionDefName: string): string { + return `${actionDefName}-action`; +} + +/** + * Compile `execute` node's `do` procedure into ordered tool references. + */ +export function compileExecuteDoProcedure( + doValue: unknown, + actionDefs: Map> | undefined, + ast: Record, + executeNodeName: string +): ActionCallableReference[] { + const env = collectExecuteVariableEnv(ast); + const statements = getProcedureStatements(doValue); + const tools: ActionCallableReference[] = []; + const pendingStateUpdates: Array> = []; + + const flushPendingIdentityAction = (): void => { + if (pendingStateUpdates.length === 0) return; + tools.push({ + type: ObjectTypes.ACTION, + ref: 'IdentityAction', + 'state-updates': [...pendingStateUpdates], + }); + pendingStateUpdates.length = 0; + }; + + for (const stmt of statements) { + if (stmt instanceof SetClause) { + const varName = normalizeId( + decomposeAtMemberExpression(stmt.target)!.property + ); + const valueExpr = compileExecuteExpression(stmt.value, env, 'execute'); + pendingStateUpdates.push({ [varName]: valueExpr }); + continue; + } + + if (stmt instanceof RunStatement) { + flushPendingIdentityAction(); + const actionDefName = normalizeId( + decomposeAtMemberExpression(stmt.target)!.property + ); + + const boundInputs: Record = {}; + const stateUpdates: Array> = []; + + for (const child of stmt.body) { + if (child instanceof WithClause) { + const compiled = compileExecuteExpression( + child.value, + env, + 'execute' + ); + boundInputs[child.param] = + child.param === 'http_headers' + ? lowercaseHttpHeaderKeys(compiled) + : compiled; + } else if (child instanceof SetClause) { + const key = normalizeId( + decomposeAtMemberExpression(child.target)!.property + ); + stateUpdates.push({ + [key]: compileExecuteExpression(child.value, env, 'run-body'), + }); + } + } + + const actionDef = actionDefs!.get(actionDefName)!; + const resultField = + extractString(actionDef.kind) === 'mcp:tool' ? 'content' : 'result'; + stateUpdates.push({ + outputs: `add(state.outputs, "${executeNodeName}", result["${resultField}"])`, + }); + + const action: ActionCallableReference = { + type: ObjectTypes.ACTION, + ref: actionDefRef(actionDefName), + }; + if (Object.keys(boundInputs).length > 0) { + action['bound-inputs'] = boundInputs; + } + if (stateUpdates.length > 0) { + action['state-updates'] = stateUpdates; + } + tools.push(action); + continue; + } + } + + flushPendingIdentityAction(); + return tools; +} diff --git a/dialect/agentfabric/src/compiler/compile.ts b/dialect/agentfabric/src/compiler/compile.ts new file mode 100644 index 00000000..20ab5125 --- /dev/null +++ b/dialect/agentfabric/src/compiler/compile.ts @@ -0,0 +1,218 @@ +/** + * Main compile() function — transforms parsed AgentFabric AST into AgentGraph. + * Mirrors the UnifiedAgentSpecificationAdaptor._adapt() flow from the Python adaptor. + */ + +import type { AgentGraph, AgentGraphTrigger } from './agent-graph.js'; +import type { UnifiedAgentSpecification } from './unified-agent-specification.js'; +import type { CompilerDiagnostic } from './compiler-context.js'; +import { AgentFabricCompilerContext } from './compiler-context.js'; +import { buildDefinitions } from './build-definitions.js'; +import { buildLLMProviders, buildInvokableClients } from './build-providers.js'; +import { + buildNodes, + buildStateVariables, + resolveInitialNode, + collectResponseNodeNames, + injectNodeInputTracking, +} from './build-nodes.js'; +import { extractString, extractTransitionReference } from './utils.js'; + +export interface CompileResult { + output: AgentGraph; + diagnostics: CompilerDiagnostic[]; +} + +/** Optional original source text (used by source-based fallbacks, e.g. llm/tool extraction). */ +export interface CompileOptions { + source?: string; +} + +const SCHEMA_VERSION = '2.0.0'; + +function parseTriggerTarget(target: string | undefined): { + namespace: string; + target_id: string; +} { + if (!target) return { namespace: '', target_id: '' }; + const trimmed = target.trim(); + const match = trimmed.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/?#]+)/); + if (!match) return { namespace: '', target_id: '' }; + const namespace = match[1]; + const authority = match[2]; + return { + namespace, + target_id: authority.split(':')[0] ?? '', + }; +} + +function buildCompiledTrigger( + triggers: Map> | undefined +): AgentGraphTrigger | null { + if (!triggers || triggers.size === 0) return null; + const [triggerId, triggerEntry] = triggers.entries().next().value as [ + string, + Record, + ]; + const kind = extractString(triggerEntry.kind) ?? 'a2a'; + if (kind !== 'a2a') return null; + const parsedTarget = parseTriggerTarget(extractString(triggerEntry.target)); + + return { + id: triggerId, + kind: 'a2a', + namespace: parsedTarget.namespace, + target_id: parsedTarget.target_id, + on_message: { + transition_to: extractTransitionReference(triggerEntry.on_message), + }, + }; +} + +function extractLlmNamesFromSource(source: string): string[] { + const lines = source.split(/\r?\n/); + const names: string[] = []; + let inLlmBlock = false; + for (const line of lines) { + const trimmed = line.trim(); + if (!inLlmBlock) { + if (trimmed === 'llm:') inLlmBlock = true; + continue; + } + if (trimmed.length === 0) continue; + if (!line.startsWith(' ')) break; + if (line.startsWith(' ')) continue; + const m = line.match(/^\s{2}([^:]+):\s*$/); + if (m) names.push(m[1].trim()); + } + return names; +} + +function createLlmNameAliases( + llmEntries: Map> | undefined, + source: string | undefined +): Map { + const aliases = new Map(); + if (!llmEntries) return aliases; + if (!source) { + for (const [name] of llmEntries) aliases.set(name, name); + return aliases; + } + const sourceNames = extractLlmNamesFromSource(source); + let i = 0; + for (const [name] of llmEntries) { + aliases.set(name, sourceNames[i] ?? name); + i += 1; + } + return aliases; +} + +export function compile( + ast: Record, + options?: CompileOptions +): CompileResult { + const ctx = new AgentFabricCompilerContext(); + + // Extract top-level blocks from the parsed AST + const config = ast.config as Record | undefined; + const llmEntries = ast.llm as + | Map> + | undefined; + const actionDefs = ast.actions as + | Map> + | undefined; + const triggers = ast.trigger as + | Map> + | undefined; + const echoEntries = ast.echo as + | Map> + | undefined; + + const llmNameAliases = createLlmNameAliases(llmEntries, options?.source); + + // 1. Build LLM providers + const llmProviders = buildLLMProviders(llmEntries, llmNameAliases, ctx); + + // 2. Build invokable clients + const invokableClients = buildInvokableClients(actionDefs, ctx); + + // 3. Build definitions (ActionDefinitions + IdentityAction) + const definitions = buildDefinitions(actionDefs, ctx); + + // 4. Resolve initial node from trigger (linter guarantees trigger exists) + const initialNode = resolveInitialNode(triggers!); + + // 5. Build graph nodes and collect outputStructures discovered during + // node-level LLM/output-structure resolution. + const builtNodes = buildNodes( + ast, + llmEntries, + actionDefs, + initialNode, + options?.source, + llmNameAliases + ); + const { nodes, outputStructures } = builtNodes; + + // 6. Inject _node_input tracking (handoff breadcrumbs + on-init lookups) + const trackingInjected = injectNodeInputTracking(nodes); + + // 7. Build state variables (built-in outputs + `variables:` declarations) + const stateVariables = buildStateVariables(ast); + if (trackingInjected) { + const trackingVarNames = new Set(stateVariables.map(v => v.name)); + if (!trackingVarNames.has('_handoff_source')) { + stateVariables.push({ + name: '_handoff_source', + 'data-type': 'string', + default: null, + label: '', + description: '', + }); + } + if (!trackingVarNames.has('_node_input')) { + stateVariables.push({ + name: '_node_input', + 'data-type': 'string', + default: null, + label: '', + description: '', + }); + } + } + + // 8. Collect response node names + const responseNodeNames = collectResponseNodeNames(echoEntries); + + // 9. Extract config fields + const agentName = extractString(config?.agent_name) ?? ''; + const label = extractString(config?.label) ?? agentName; + + // 10. Assemble UnifiedAgentSpecification + const spec: UnifiedAgentSpecification = { + 'schema-version': SCHEMA_VERSION, + id: agentName, + label, + definitions: definitions.length > 0 ? definitions : null, + graph: { + 'state-variables': stateVariables, + 'initial-node': initialNode, + nodes, + }, + }; + + // 11. Assemble AgentGraph + const agentGraph: AgentGraph = { + unifiedAgentSpec: spec, + llmProviders, + invokableClients, + responseNodeNames, + trigger: buildCompiledTrigger(triggers), + outputStructures, + }; + + return { + output: agentGraph, + diagnostics: ctx.diagnostics, + }; +} diff --git a/dialect/agentfabric/src/compiler/compiler-context.ts b/dialect/agentfabric/src/compiler/compiler-context.ts new file mode 100644 index 00000000..259da700 --- /dev/null +++ b/dialect/agentfabric/src/compiler/compiler-context.ts @@ -0,0 +1,27 @@ +/** + * Compiler diagnostic context — threads diagnostics through compilation. + */ + +export enum DiagnosticSeverity { + Error = 1, + Warning = 2, + Information = 3, + Hint = 4, +} + +export interface CompilerDiagnostic { + message: string; + severity: DiagnosticSeverity; +} + +export class AgentFabricCompilerContext { + readonly diagnostics: CompilerDiagnostic[] = []; + + error(message: string): void { + this.diagnostics.push({ message, severity: DiagnosticSeverity.Error }); + } + + warn(message: string): void { + this.diagnostics.push({ message, severity: DiagnosticSeverity.Warning }); + } +} diff --git a/dialect/agentfabric/src/compiler/index.ts b/dialect/agentfabric/src/compiler/index.ts new file mode 100644 index 00000000..6330b945 --- /dev/null +++ b/dialect/agentfabric/src/compiler/index.ts @@ -0,0 +1,22 @@ +export { compile } from './compile.js'; +export type { CompileResult, CompileOptions } from './compile.js'; +export type { AgentGraph } from './agent-graph.js'; +export type { + UnifiedAgentSpecification, + AgentNode, + ActionNode, + RouterNode, + HandoffAction, + ActionCallableReference, + ActionDefinition, + LLMRef, + StateVariable, + GraphConfig, + Node, +} from './unified-agent-specification.js'; +export { ObjectTypes } from './unified-agent-specification.js'; +export type { LLMProvider, InvokableClient } from './service-types.js'; +export type { + CompilerDiagnostic, + DiagnosticSeverity, +} from './compiler-context.js'; diff --git a/dialect/agentfabric/src/compiler/service-types.ts b/dialect/agentfabric/src/compiler/service-types.ts new file mode 100644 index 00000000..22494730 --- /dev/null +++ b/dialect/agentfabric/src/compiler/service-types.ts @@ -0,0 +1,25 @@ +/** + * TypeScript types for service-level objects (LLMProvider, InvokableClient) + * derived from module_graph_runtime.schemas.service Pydantic models. + */ + +export interface LLMProviderMetadata { + platform: string; + connection?: string; + headers?: Record | null; + timeout?: number | null; + api_key?: string | null; +} + +export interface LLMProvider { + name: string; + description: string; + metadata: LLMProviderMetadata; +} + +export interface InvokableClient { + name: string; + type: string; + label: string; + metadata: Record; +} diff --git a/dialect/agentfabric/src/compiler/unified-agent-specification.ts b/dialect/agentfabric/src/compiler/unified-agent-specification.ts new file mode 100644 index 00000000..8ed04872 --- /dev/null +++ b/dialect/agentfabric/src/compiler/unified-agent-specification.ts @@ -0,0 +1,366 @@ +/** + * TypeScript types derived from the Pydantic UnifiedAgentSpecification model + * in docs/schemas/spec.py. Field names use kebab-case to match the + * KebabCaseModel alias convention used in the Python runtime. + */ + +// ── Expression type aliases ───────────────────────────────────────── + +export type Expr = string | number | boolean; +export type StateUpdateExpr = Expr | null; +export type BoundInputsExpr = Expr | null; + +// ── Enums ─────────────────────────────────────────────────────────── + +export enum ObjectTypes { + ACTION = 'action', + AGENT = 'agent', + EXTERNAL_AGENT = 'external-agent', + HANDOFF = 'handoff', + ROUTER = 'router', + MCP_ACTION = 'mcp-action', +} + +export enum SubgraphPersistence { + EPHEMERAL = 'ephemeral', + PERSISTENT = 'persistent', +} + +export enum SubgraphInitStateMode { + COPY_PARENT = 'copy_parent', + BLANK_SLATE = 'blank_slate', +} + +export enum SubgraphInitMemoryMode { + COPY_PARENT = 'copy_parent', + BLANK_SLATE = 'blank_slate', +} + +export enum SubgraphResultMode { + TOOL_RESULT = 'tool_result', + FINAL_RESULT = 'final_result', +} + +export enum SubgraphStateApplyMode { + NONE = 'none', + ALL = 'all', + ALLOWLIST = 'allowlist', +} + +// ── Subgraph configuration ────────────────────────────────────────── + +export interface SubgraphToolConfiguration { + 'state-mode'?: SubgraphInitStateMode; + 'memory-mode'?: SubgraphInitMemoryMode; + 'result-mode'?: SubgraphResultMode; + persistence?: SubgraphPersistence; + 'state-apply-mode'?: SubgraphStateApplyMode; +} + +// ── Output & cache behaviors ──────────────────────────────────────── + +export interface OutputBehavior { + name: string; + 'emit-in-response': boolean; + 'add-to-chat-history': boolean; +} + +export interface CacheBehavior { + enabled?: boolean; + ttl?: number; +} + +export interface ActionBehavior { + 'require-user-confirmation': boolean; + 'include-in-progress-indicator': boolean; + 'progress-indicator-message'?: string | null; + outputs?: OutputBehavior[] | null; + cache?: CacheBehavior | null; +} + +// ── Action definitions ────────────────────────────────────────────── + +export interface ActionDefinition { + name: string; + type: ObjectTypes.ACTION; + client: string; + label: string; + description: string; + 'invocation-target-type': string; + 'invocation-target-name': string; + 'input-schema': unknown; + 'output-schema': unknown; + behavior?: ActionBehavior | null; + metadata?: Record | null; +} + +export interface MCPActionDefinition extends Omit { + type: ObjectTypes.MCP_ACTION; + annotations?: Record | null; +} + +// ── Variables ─────────────────────────────────────────────────────── + +export interface RequestVariable { + name: string; + 'data-type': string; + description: string; +} + +export interface StateVariable { + name: string; + label: string; + 'data-type': string; + 'is-list'?: boolean | null; + description: string; + default: unknown; +} + +// ── LLM reference ─────────────────────────────────────────────────── + +export interface LLMRef { + ref: string; + configuration: Record; + 'output-structure-ref'?: string; +} + +// ── System policy ─────────────────────────────────────────────────── + +export interface SystemPolicy { + name: string; + value: unknown; + type: 'system'; +} + +// ── Action callable reference ─────────────────────────────────────── + +export interface ActionCallableReference { + type?: ObjectTypes.ACTION; + target?: string | null; + ref?: string | null; + description?: string | null; + 'bound-inputs'?: Record | null; + enabled?: Expr | null; + 'state-updates'?: Array> | null; +} + +// ── Tool call reference ───────────────────────────────────────────── + +export interface ToolCallReference extends ActionCallableReference { + name: string; + 'llm-inputs'?: string[] | null; + forced?: Expr | null; +} + +// ── MCP tool ──────────────────────────────────────────────────────── + +export interface MCPTool { + type: 'mcp_tool'; + ref: string; + enabled?: Expr | null; + 'bound-inputs'?: Record | null; + 'llm-inputs'?: string[] | null; +} + +// ── A2A tool ──────────────────────────────────────────────────────── + +export interface A2ATool { + type: 'a2a'; + ref: string; + enabled?: Expr | null; + 'bound-inputs'?: Record | null; + 'llm-inputs'?: string[] | null; +} + +// ── Subgraph tool ─────────────────────────────────────────────────── + +export interface SubgraphTool { + type: 'subgraph'; + target: string; + name: string; + description: string; + forced?: Expr | null; + enabled?: Expr | null; + 'state-updates'?: Array> | null; + configuration?: SubgraphToolConfiguration; +} + +export type ToolUnion = ToolCallReference | SubgraphTool | MCPTool | A2ATool; + +// ── Handoff action ────────────────────────────────────────────────── + +export interface HandoffAction { + type: ObjectTypes.HANDOFF; + target: string; + enabled?: string | boolean | null; + 'state-updates'?: Array> | null; +} + +export type HandoffActionUnion = HandoffAction | ActionCallableReference; + +// ── Node reference ────────────────────────────────────────────────── + +export interface NodeReference { + target: string; + description: string; + enabled?: Expr | null; + 'state-updates'?: Array> | null; +} + +// ── External agent metadata ───────────────────────────────────────── + +export interface ExternalAgentMetadata { + protocol: string; +} + +export interface A2AExternalAgentMetadata extends ExternalAgentMetadata { + protocol: 'a2a'; + platform: string; + url: string; +} + +// ── Node system limits ────────────────────────────────────────────── + +export interface NodeSystemLimits { + 'max-reasoning-iterations'?: number; + 'max-node-tool-call-iterations'?: number; + 'max-consecutive-errors'?: number; + 'task-timeout-secs'?: number; +} + +// ── Pre/Post tool call references ─────────────────────────────────── + +export interface PreToolCallReference { + 'target-tool-name': string; + actions: ActionCallableReference[]; +} + +export interface PostToolCallReference { + 'target-tool-name': string; + actions: ActionCallableReference[]; +} + +// ── Nodes ─────────────────────────────────────────────────────────── + +export interface AgentNode { + name: string; + label?: string | null; + description?: string | null; + type: ObjectTypes.AGENT; + llm: LLMRef; + 'on-init'?: HandoffActionUnion[] | null; + 'before-reasoning'?: HandoffActionUnion[] | null; + 'before-reasoning-iteration'?: HandoffActionUnion[] | null; + 'system-prompt': string; + 'focus-prompt'?: string | null; + tools?: ToolUnion[] | null; + 'pre-tool-calls'?: PreToolCallReference[] | null; + 'post-tool-calls'?: PostToolCallReference[] | null; + 'after-all-tool-calls'?: HandoffActionUnion[] | null; + 'after-reasoning'?: HandoffActionUnion[] | null; + 'on-exit'?: ActionCallableReference[] | null; + policies?: SystemPolicy[] | null; + 'system-limits'?: NodeSystemLimits; +} + +export interface LLMToolCallClassifierRef { + type: 'llm-tool-call'; + llm?: LLMRef | null; +} + +export type ClassifierRef = LLMToolCallClassifierRef; + +export interface RouterNode { + name: string; + label?: string | null; + description?: string | null; + type: ObjectTypes.ROUTER; + policies?: SystemPolicy[] | null; + classifier?: ClassifierRef; + 'node-references': NodeReference[]; + 'on-init'?: HandoffActionUnion[] | null; + 'system-prompt'?: string | null; + 'before-reasoning-iteration'?: HandoffActionUnion[] | null; + 'on-exit'?: HandoffActionUnion[] | null; + 'system-limits'?: NodeSystemLimits; +} + +export interface ActionNode { + name: string; + type?: ObjectTypes.ACTION; + label?: string | null; + description?: string | null; + 'on-init'?: HandoffActionUnion[] | null; + tools: ActionCallableReference[]; + 'is-parallel'?: boolean; + 'add-tool-result-to-chat-history'?: boolean; + 'on-exit'?: HandoffActionUnion[] | null; + 'output-template'?: string | null; + policies?: SystemPolicy[] | null; + 'system-limits'?: NodeSystemLimits; +} + +export interface ExternalAgentNode { + name: string; + type: ObjectTypes.EXTERNAL_AGENT; + label?: string | null; + metadata?: ExternalAgentMetadata | null; + ref?: string | null; + 'system-limits'?: NodeSystemLimits; +} + +export type Node = AgentNode | ExternalAgentNode | RouterNode | ActionNode; + +// ── Turn system limits ────────────────────────────────────────────── + +export interface TurnSystemLimits { + 'max-handoff-iterations'?: number; + 'max-subgraph-depth'?: number; + 'max-turn-tool-call-counts'?: number; +} + +export interface Behavior { + 'reset-to-initial-node'?: boolean; + 'disable-groundedness'?: boolean; + 'disable-error-behavior'?: boolean; + 'turn-system-limits'?: TurnSystemLimits; +} + +// ── Graph config ──────────────────────────────────────────────────── + +export interface PluginConfig { + type: 'plugin'; + name: string; + kind: string; + config: Record; +} + +export type GraphConfigItem = PluginConfig; + +export interface GraphConfig { + config?: GraphConfigItem[] | null; + 'request-variables'?: RequestVariable[] | null; + 'state-variables'?: StateVariable[] | null; + 'initial-node': string; + nodes: Node[]; + behaviors?: Behavior | null; +} + +// ── Definitions ───────────────────────────────────────────────────── + +export type Definition = + | ActionDefinition + | ExternalAgentNode + | MCPActionDefinition; + +// ── Top-level specification ───────────────────────────────────────── + +export interface UnifiedAgentSpecification { + 'schema-version': string; + id: string; + label: string; + definitions?: Definition[] | null; + 'pre-orchestration'?: unknown | null; + graph: GraphConfig; + 'post-orchestration'?: unknown | null; +} diff --git a/dialect/agentfabric/src/compiler/utils.ts b/dialect/agentfabric/src/compiler/utils.ts new file mode 100644 index 00000000..4a7aa7dc --- /dev/null +++ b/dialect/agentfabric/src/compiler/utils.ts @@ -0,0 +1,369 @@ +/** + * Compiler utilities mirroring the Python adaptor helper methods. + */ + +import { decomposeAtMemberExpression } from '@agentscript/language'; + +/** + * 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 []; +} + +/** + * Normalize a kebab-case identifier to snake_case (valid Python identifier). + * Mirrors _normalize_id() in the Python adaptor. + */ +export function normalizeId(name: string): string { + return name ? name.replace(/-/g, '_') : name; +} + +/** + * Resolve a handoff target: 'end' means graph-complete (null), otherwise normalize. + * Mirrors _resolve_target() in the Python adaptor. + */ +export function resolveTarget( + target: string | undefined | null +): string | null { + if (!target || target.toLowerCase() === 'end') { + return null; + } + return normalizeId(target); +} + +/** + * Replace hyphens between word characters inside Jinja2 {{ }} blocks with underscores. + * Mirrors _normalize_template() in the Python adaptor. + */ +export function normalizeTemplate(value: string): string { + // Convert AgentScript interpolation form `{!expr}` to `{{expr}}`. + const withJinja = value.replace( + /\{\!\s*([^}]+?)\s*\}/g, + (_m, inner: string) => { + return `{{${inner}}}`; + } + ); + + return withJinja.replace(/\{\{(.*?)\}\}/g, (_match, inner: string) => { + let normalized = inner.replace(/(\w)-(\w)/g, '$1_$2'); + // Runtime context stores mutable variables on state.. + normalized = normalized.replace( + /@variables\.([A-Za-z0-9_-]+)/g, + (_m, name: string) => `state.${name.replace(/-/g, '_')}` + ); + // Runtime context uses state.request.*, so rewrite @request.* in templates. + normalized = normalized.replace(/@request\./g, 'state.request.'); + // Canonical node output reference in templates: + // - @executor..output[.attr...] -> state.outputs[''][.attr...] + // - @orchestrator/@subagent/@generator..output -> system.node_outputs[''] + // - @orchestrator/@subagent/@generator..output. -> parse_json(system.node_outputs['']). + // node_outputs values are JSON strings; attribute access requires parse_json(). + normalized = normalized.replace( + /@(orchestrator|subagent|generator|executor)\.([A-Za-z0-9_-]+)\.output\b((?:\.[A-Za-z_]\w*)*)/g, + (_m, nodeType: string, nodeName: string, tail: string) => { + const normalizedName = nodeName.replace(/-/g, '_'); + if (nodeType === 'executor') { + return `state.outputs['${normalizedName}']${tail}`; + } + if (tail) { + return `parse_json(system.node_outputs['${normalizedName}'])${tail}`; + } + return `system.node_outputs['${normalizedName}']`; + } + ); + // Node input reference in templates: + // - @..input -> state._node_input + normalized = normalized.replace( + /@(orchestrator|subagent|generator|executor|router|echo)\.([A-Za-z0-9_-]+)\.input\b/g, + 'state._node_input' + ); + // Disallow deprecated alias in templates. + normalized = normalized.replace( + /@outputs\.([A-Za-z0-9_-]+)/g, + (_m, nodeName: string) => + `__ERROR__outputs_alias_not_supported__use_@.${nodeName}.output` + ); + return '{{' + normalized + '}}'; + }); +} + +/** + * Prefix Jinja2 template expressions with 'template::' for the runtime evaluator. + * Only applies to strings that start with '{{'. + * Mirrors _template_expr() in the Python adaptor. + */ +export function templateExpr(value: unknown): unknown { + if ( + typeof value === 'string' && + value.trim().startsWith('{{') && + !value.startsWith('template::') + ) { + return `template::${normalizeTemplate(value)}`; + } + return value; +} + +/** + * Extract a plain string from a parsed AST field value. + * Handles StringLiteral, TemplateExpression, and raw string values. + */ +export function extractString(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value === 'string') return value; + if (typeof value === 'object' && value !== null) { + const v = value as Record; + if ('value' in v && typeof v.value === 'string') return v.value; + if ('text' in v && typeof v.text === 'string') return v.text; + } + return String(value); +} + +/** + * Extract an LLM reference string from `config.default_llm` or a node's `llm` field. + * Handles plain strings and `@namespace.member` member expressions from the dialect AST. + */ +export function extractLlmFieldReference(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + + const ref = decomposeAtMemberExpression(value); + if (ref) { + return `@${ref.namespace}.${ref.property}`; + } + + const s = extractString(value); + if (s === undefined || s === '[object Object]') return undefined; + return s; +} + +/** + * Resolve effective system instructions for compiled focus-prompt: + * node-level instructions override document-level defaults when present. + */ +export function combineGlobalSystemInstructions( + globalInstructions: string | undefined, + nodeInstructions: string | undefined +): string { + const g = globalInstructions?.trim() ?? ''; + const n = nodeInstructions?.trim() ?? ''; + if (n) return n; + return g; +} + +/** + * Extract plain text from a procedure / template field (e.g. `instructions: -> ...`). + * Falls back to {@link extractString} for simple string values. + */ +export function extractProcedureText(value: unknown): string { + if (value === undefined || value === null) return ''; + if (typeof value === 'string') return value; + if (typeof value === 'object' && value !== null) { + const v = value as Record; + if (Array.isArray(v.statements)) { + const stmts = v.statements as Array<{ + __emit?: (ctx: { indent: number }) => string; + }>; + const lines = stmts + .map(s => + typeof s.__emit === 'function' ? s.__emit({ indent: 0 }) : '' + ) + .filter(line => line.length > 0); + return lines.join('\n'); + } + if (Array.isArray(v.parts)) { + const parts = v.parts as Array>; + return parts + .map(p => { + if (typeof p.value === 'string') return p.value; + if (typeof p.__emit === 'function') + return (p.__emit as (ctx: { indent: number }) => string)({ + indent: 0, + }); + return ''; + }) + .join(''); + } + } + const fallback = extractString(value); + if (fallback === undefined || fallback === '[object Object]') return ''; + return fallback; +} + +/** + * Extract a transition target reference from a procedure-like value. + * Returns canonical "@namespace.node" or empty string when unresolved. + */ +export function extractTransitionReference(value: unknown): string { + const fromText = extractProcedureText(value); + const extractFrom = (text: string): string => { + const explicitTransition = text.match( + /transition\s+to\s+@([A-Za-z_][\w]*\.[A-Za-z0-9_-]+)/i + ); + if (explicitTransition) return `@${explicitTransition[1]}`; + + const anyReference = text.match(/@([A-Za-z_][\w]*\.[A-Za-z0-9_-]+)/); + return anyReference ? `@${anyReference[1]}` : ''; + }; + + const fromEmitted = extractFrom(fromText); + if (fromEmitted) return fromEmitted; + + try { + const plain = toPlainData(value); + const serialized = JSON.stringify(plain); + const fromSerialized = extractFrom(serialized); + if (fromSerialized) return fromSerialized; + } catch { + // ignore and continue to structural walk + } + + const seen = new Set(); + const queue: unknown[] = [value]; + while (queue.length > 0) { + const current = queue.shift(); + if (current == null || seen.has(current)) continue; + seen.add(current); + + const ref = decomposeAtMemberExpression(current); + if (ref && ref.namespace && ref.property) { + return `@${ref.namespace}.${ref.property}`; + } + + if (typeof current === 'string') { + const fromString = extractFrom(current); + if (fromString) return fromString; + continue; + } + if (Array.isArray(current)) { + for (const item of current) queue.push(item); + continue; + } + if (typeof current === 'object') { + for (const child of Object.values(current as Record)) { + queue.push(child); + } + } + } + + return ''; +} + +/** + * Extract a number from a parsed AST field value. + */ +export function extractNumber(value: unknown): number | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value === 'number') return value; + if (typeof value === 'object' && value !== null) { + const v = value as Record; + if ('value' in v && typeof v.value === 'number') return v.value; + } + const n = Number(value); + return isNaN(n) ? undefined : n; +} + +/** + * Convert parsed AST/schema nodes into plain JSON-like values. + * Strips internal metadata (`__*`), functions, and non-serializable objects. + */ +export function toPlainData(value: unknown, seen?: WeakSet): unknown { + if (value === null || value === undefined) return value; + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value; + } + if (typeof value === 'function') return undefined; + + if (Array.isArray(value)) { + return value + .map(v => toPlainData(v, seen)) + .filter((v): v is unknown => v !== undefined); + } + + if (value instanceof Map) { + const out: Record = {}; + for (const [k, v] of value.entries()) { + const plain = toPlainData(v, seen); + if (plain !== undefined) out[String(k)] = plain; + } + return out; + } + + if (typeof value === 'object') { + const obj = value as Record; + const tracker = seen ?? new WeakSet(); + if (tracker.has(obj)) return undefined; + tracker.add(obj); + + const kind = typeof obj.__kind === 'string' ? obj.__kind : undefined; + if (kind) { + if ( + kind === 'StringLiteral' || + kind === 'NumberLiteral' || + kind === 'BooleanLiteral' + ) { + return obj.value; + } + if (kind === 'NoneLiteral') return null; + if (kind === 'Identifier' && typeof obj.name === 'string') + return obj.name; + if ( + (kind === 'TemplateExpression' || + kind === 'MemberExpression' || + kind === 'CallExpression' || + kind === 'SpreadExpression') && + typeof obj.__emit === 'function' + ) { + return (obj.__emit as (ctx: { indent: number }) => string)({ + indent: 0, + }); + } + } + + // NamedMap/TypedMap: iterate declared entries in source order. + if (Symbol.iterator in obj) { + const out: Record = {}; + try { + for (const item of obj as Iterable) { + if ( + Array.isArray(item) && + item.length >= 2 && + typeof item[0] === 'string' + ) { + const plain = toPlainData(item[1], tracker); + if (plain !== undefined) out[item[0]] = plain; + } + } + if (Object.keys(out).length > 0) return out; + } catch { + // Fall back to own-enumerable traversal below. + } + } + + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if (k.startsWith('__')) continue; + const plain = toPlainData(v, tracker); + if (plain !== undefined) out[k] = plain; + } + return out; + } + + return undefined; +} 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..03cd6f44 100644 --- a/dialect/agentfabric/src/index.ts +++ b/dialect/agentfabric/src/index.ts @@ -1,10 +1,3 @@ -/* - * Copyright (c) 2026, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 - */ - import type { DialectConfig, InferFields, @@ -87,6 +80,40 @@ export type { export { defaultRules } from './lint/passes/index.js'; export { createLintEngine } from './lint/index.js'; +// ── Compiler re-exports ───────────────────────────────────────────── + +export { compile } from './compiler/index.js'; +export type { CompileResult, CompileOptions } from './compiler/index.js'; +export type { AgentGraph } from './compiler/agent-graph.js'; +export { ObjectTypes } from './compiler/unified-agent-specification.js'; +export type { + UnifiedAgentSpecification, + AgentNode, + ActionNode, + RouterNode, + HandoffAction, + ActionCallableReference, + ActionDefinition, + LLMRef, + StateVariable, + GraphConfig, + Node, +} from './compiler/unified-agent-specification.js'; +export type { LLMProvider, InvokableClient } from './compiler/service-types.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..1e237dca 100644 --- a/dialect/agentfabric/src/lint/index.ts +++ b/dialect/agentfabric/src/lint/index.ts @@ -1,17 +1,9 @@ -/* - * Copyright (c) 2026, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 - */ - 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..bf2f09ab 100644 --- a/dialect/agentfabric/src/lint/passes/agentfabric-semantic.ts +++ b/dialect/agentfabric/src/lint/passes/agentfabric-semantic.ts @@ -1,15 +1,11 @@ -/* - * Copyright (c) 2026, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 - */ - 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 +25,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..64645fef 100644 --- a/dialect/agentfabric/src/lint/passes/index.ts +++ b/dialect/agentfabric/src/lint/passes/index.ts @@ -1,10 +1,3 @@ -/* - * Copyright (c) 2026, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 - */ - import type { LintPass } from '@agentscript/language'; import { symbolTableAnalyzer, @@ -18,10 +11,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 +54,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..73460f9b --- /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 } from '../../../compiler/utils.js'; +import { + IMPLICIT_WITH_PARAMS, + listActionDefInputNames, +} from '../../../compiler/build-nodes.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/agentic-llm-rules.ts b/dialect/agentfabric/src/lint/passes/rules/agentic-llm-rules.ts index 18e22cf0..5d88f32a 100644 --- a/dialect/agentfabric/src/lint/passes/rules/agentic-llm-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/agentic-llm-rules.ts @@ -1,10 +1,3 @@ -/* - * Copyright (c) 2026, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 - */ - import { isNamedMap } from '@agentscript/language'; import { attachError, diff --git a/dialect/agentfabric/src/lint/passes/rules/connection-rules.ts b/dialect/agentfabric/src/lint/passes/rules/connection-rules.ts index 63cfd5bc..adca38d3 100644 --- a/dialect/agentfabric/src/lint/passes/rules/connection-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/connection-rules.ts @@ -1,10 +1,3 @@ -/* - * Copyright (c) 2026, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 - */ - import { isNamedMap } from '@agentscript/language'; import { attachError, extractStringValue, type AstLike } from './shared.js'; 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/echo-rules.ts b/dialect/agentfabric/src/lint/passes/rules/echo-rules.ts index f96fa31b..1b8da6af 100644 --- a/dialect/agentfabric/src/lint/passes/rules/echo-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/echo-rules.ts @@ -1,12 +1,5 @@ -/* - * Copyright (c) 2026, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 - */ - import { isNamedMap } from '@agentscript/language'; -import { normalizeId } from '../../utils.js'; +import { normalizeId } from '../../../compiler/utils.js'; import { attachError, hasOwnNonNull, type AstLike } from './shared.js'; export function checkEchoRules(root: Record): void { 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..244ed738 --- /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 } from '../../../compiler/utils.js'; +import { + IMPLICIT_WITH_PARAMS, + listActionDefInputNames, +} from '../../../compiler/build-nodes.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/on-exit-rules.ts b/dialect/agentfabric/src/lint/passes/rules/on-exit-rules.ts index 30934197..3ab53d73 100644 --- a/dialect/agentfabric/src/lint/passes/rules/on-exit-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/on-exit-rules.ts @@ -1,10 +1,3 @@ -/* - * Copyright (c) 2026, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 - */ - import { isNamedMap } from '@agentscript/language'; import { asStatements, attachError, type AstLike } from './shared.js'; diff --git a/dialect/agentfabric/src/lint/passes/rules/output-structure-rules.ts b/dialect/agentfabric/src/lint/passes/rules/output-structure-rules.ts index 0866e4e1..f67d079f 100644 --- a/dialect/agentfabric/src/lint/passes/rules/output-structure-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/output-structure-rules.ts @@ -1,10 +1,3 @@ -/* - * Copyright (c) 2026, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 - */ - import { isNamedMap } from '@agentscript/language'; import { attachError, diff --git a/dialect/agentfabric/src/lint/passes/rules/reasoning-instructions-rules.ts b/dialect/agentfabric/src/lint/passes/rules/reasoning-instructions-rules.ts index 612bef4e..b04590b9 100644 --- a/dialect/agentfabric/src/lint/passes/rules/reasoning-instructions-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/reasoning-instructions-rules.ts @@ -1,10 +1,3 @@ -/* - * Copyright (c) 2026, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 - */ - import { isNamedMap } from '@agentscript/language'; import { attachError, extractStringValue, type AstLike } from './shared.js'; diff --git a/dialect/agentfabric/src/lint/passes/rules/shared.ts b/dialect/agentfabric/src/lint/passes/rules/shared.ts index 5d5a740d..42460544 100644 --- a/dialect/agentfabric/src/lint/passes/rules/shared.ts +++ b/dialect/agentfabric/src/lint/passes/rules/shared.ts @@ -1,10 +1,3 @@ -/* - * Copyright (c) 2026, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 - */ - import { attachDiagnostic, BinaryExpression, @@ -15,10 +8,11 @@ import { TernaryExpression, UnaryExpression, } from '@agentscript/language'; -import { normalizeId } from '../../utils.js'; +import { normalizeId } from '../../../compiler/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 +37,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 +54,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..329da26b 100644 --- a/dialect/agentfabric/src/lint/passes/rules/switch-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/switch-rules.ts @@ -1,47 +1,15 @@ -/* - * Copyright (c) 2026, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 - */ - import { isNamedMap } from '@agentscript/language'; -import { normalizeId } from '../../utils.js'; +import { normalizeId } from '../../../compiler/utils.js'; 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 +83,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/trigger-rules.ts b/dialect/agentfabric/src/lint/passes/rules/trigger-rules.ts index 3fdbf334..7581cccd 100644 --- a/dialect/agentfabric/src/lint/passes/rules/trigger-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/trigger-rules.ts @@ -1,10 +1,3 @@ -/* - * Copyright (c) 2026, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 - */ - import { isNamedMap } from '@agentscript/language'; import { attachError, 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/passes/suppress-tools-namespace-undefined-reference.ts b/dialect/agentfabric/src/lint/passes/suppress-tools-namespace-undefined-reference.ts index e373bbac..4e123ddc 100644 --- a/dialect/agentfabric/src/lint/passes/suppress-tools-namespace-undefined-reference.ts +++ b/dialect/agentfabric/src/lint/passes/suppress-tools-namespace-undefined-reference.ts @@ -1,10 +1,3 @@ -/* - * Copyright (c) 2026, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 - */ - import { storeKey, recurseAstChildren } from '@agentscript/language'; import type { LintPass } from '@agentscript/language'; diff --git a/dialect/agentfabric/src/lint/utils.ts b/dialect/agentfabric/src/lint/utils.ts deleted file mode 100644 index 5dc1c403..00000000 --- a/dialect/agentfabric/src/lint/utils.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright (c) 2026, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 - */ - -/** Convert kebab-case identifiers to snake_case. */ -export function normalizeId(name: string): string { - return name ? name.replace(/-/g, '_') : name; -} diff --git a/dialect/agentfabric/src/schema.ts b/dialect/agentfabric/src/schema.ts index 8c22c1f0..5e264e43 100644 --- a/dialect/agentfabric/src/schema.ts +++ b/dialect/agentfabric/src/schema.ts @@ -1,10 +1,3 @@ -/* - * Copyright (c) 2026, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 - */ - import { Block, NamedBlock, @@ -26,8 +19,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 +59,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 +111,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 +167,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 +206,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 +262,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 +362,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 +383,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 +400,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 +416,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 +436,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 +453,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 +470,7 @@ export const RouterOtherwiseBlock = Block('RouterOtherwiseBlock', { 'Default transition target when no route condition matches.' ) .allowedNamespaces(ROUTER_TARGET_NAMESPACES) + .resolvedType('transitionTarget') .required(), }); @@ -426,7 +478,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 +505,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 +519,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 +540,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/compiler.test.ts b/dialect/agentfabric/src/tests/compiler.test.ts new file mode 100644 index 00000000..cdb82cb9 --- /dev/null +++ b/dialect/agentfabric/src/tests/compiler.test.ts @@ -0,0 +1,1755 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import YAML from 'yaml'; +import { parseDocument, toRecord } from './test-utils.js'; +import { compile } from '../compiler/compile.js'; +import { toPlainData } from '../compiler/utils.js'; +import { + ObjectTypes, + type Definition, +} from '../compiler/unified-agent-specification.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +describe('AgentFabric Compiler', () => { + it('compiles minimal strict syntax agent', () => { + const source = ` +config: + agent_name: "minimal" + +trigger t: + kind: "a2a" + target: "brokers://minimal/a2a" + on_message: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + expect(result.output.unifiedAgentSpec.graph.nodes.length).toBeGreaterThan( + 0 + ); + expect(result.output.trigger?.kind).toBe('a2a'); + }); + + it('compiles generator with prompt and outputs configuration', () => { + const source = ` +config: + agent_name: "gen" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +trigger t: + kind: "a2a" + target: "brokers://gen/a2a" + on_message: -> transition to @generator.main + +generator main: + llm: @llm.g + system: + instructions: "You are helpful" + prompt: -> Summarize + outputs: + properties: + summary: + type: "string" + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + const agentNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'main' + ); + expect(agentNode).toBeDefined(); + expect(result.output.outputStructures).toHaveProperty('os_main'); + }); + + it('compiles executor node with IdentityAction state updates', () => { + const source = ` +config: + agent_name: "exec" + +variables: + status: mutable string = "" + +trigger t: + kind: "a2a" + target: "brokers://exec/a2a" + on_message: -> transition to @executor.step + +executor step: + do: -> + set @variables.status = "done" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + const node = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'step' + ) as Record | undefined; + expect(node).toBeDefined(); + expect(node?.type).toBe(ObjectTypes.ACTION); + }); + + it('normalizes request headers and compiles case-insensitive header lookups', () => { + const source = ` +config: + agent_name: "request-headers" + +variables: + h1: mutable string = "" + h2: mutable string = "" + +trigger t: + kind: "a2a" + target: "brokers://request-headers/a2a" + on_message: -> transition to @executor.step + +executor step: + do: -> + set @variables.h1 = @request.headers.Authorization + set @variables.h2 = @request.headers["X-Request-Id"] +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const node = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'step' + ) as Record | undefined; + expect(node).toBeDefined(); + + const onInit = (node?.['on-init'] as Array>) ?? []; + const requestInitExpr = (((onInit[0]?.['state-updates'] as Array< + Record + >) ?? [])[0]?.request ?? '') as string; + expect(requestInitExpr).toBe("normalize_headers(variables['request'])"); + + const tools = (node?.tools as Array>) ?? []; + const stateUpdates = ((tools[0]?.['state-updates'] as Array< + Record + >) ?? []) as Array>; + const h1Expr = (stateUpdates.find(s => 'h1' in s)?.h1 ?? '') as string; + const h2Expr = (stateUpdates.find(s => 'h2' in s)?.h2 ?? '') as string; + + expect(h1Expr).toBe("state.request.headers['authorization']"); + expect(h2Expr).toBe('state.request.headers[lower("X-Request-Id")]'); + }); + + it('passes actions description and label into definitions and invokable clients', () => { + const source = ` +config: + agent_name: "labels" + +actions: + my_a2a: + label: "Billing tool" + description: "Calls the billing A2A agent" + target: "a2a://billing" + kind: "a2a:send_message" + my_mcp: + label: "Article lookup" + description: "Searches the knowledge base" + target: "mcp://kb" + kind: "mcp:tool" + tool_name: "search" + +trigger t: + kind: "a2a" + target: "brokers://labels/a2a" + on_message: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const a2aDef = result.output.unifiedAgentSpec.definitions?.find( + (d: Definition) => d.name === 'my_a2a-action' + ) as Record | undefined; + expect(a2aDef?.label).toBe('Billing tool'); + expect(a2aDef?.description).toBe('Calls the billing A2A agent'); + + const mcpDef = result.output.unifiedAgentSpec.definitions?.find( + (d: Definition) => d.name === 'my_mcp-action' + ) as Record | undefined; + expect(mcpDef?.label).toBe('Article lookup'); + expect(mcpDef?.description).toBe('Searches the knowledge base'); + + const mcpClient = result.output.invokableClients.find( + c => c.name === 'my_mcp-client' + ) as Record | undefined; + expect(mcpClient?.label).toBe('Article lookup'); + expect((mcpClient?.metadata as Record)?.description).toBe( + 'Searches the knowledge base' + ); + + const a2aClient = result.output.invokableClients.find( + c => c.name === 'my_a2a-client' + ) as Record | undefined; + expect(a2aClient?.label).toBe('Billing tool'); + expect((a2aClient?.metadata as Record)?.description).toBe( + 'Calls the billing A2A agent' + ); + }); + + it('compiles http_headers passed via with binding in subagent actions', () => { + const source = ` +config: + agent_name: "with-headers" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +actions: + hr_agent: + target: "a2a://hr_agent_connection" + kind: "a2a:send_message" + send_slack: + target: "mcp://slack" + kind: "mcp:tool" + tool_name: "send_message" + +trigger t: + kind: "a2a" + target: "brokers://with-headers/a2a" + on_message: -> transition to @subagent.worker + +subagent worker: + description: "uses actions with http_headers via with binding" + llm: @llm.g + reasoning: + instructions: -> onboard new hires + actions: + my_hr: @actions.hr_agent + with http_headers = {"Authorization": "Bearer token123", "X-CorrelationId": "corr-456"} + slack: @actions.send_slack + with http_headers = {"X-Slack-Token": "slack-secret"} + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const hrClient = result.output.invokableClients.find( + c => c.name === 'hr_agent-client' + ) as Record | undefined; + const slackClient = result.output.invokableClients.find( + c => c.name === 'send_slack-client' + ) as Record | undefined; + + expect(hrClient).toBeDefined(); + expect(slackClient).toBeDefined(); + + const workerNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'worker' + ) as Record | undefined; + expect(workerNode).toBeDefined(); + const tools = (workerNode?.tools as Array>) ?? []; + expect(tools.length).toBeGreaterThan(0); + + const hrTool = tools.find(t => t.ref === 'hr_agent-client'); + expect(hrTool).toBeDefined(); + const hrBindings = (hrTool?.['bound-inputs'] ?? {}) as Record< + string, + unknown + >; + const hrHeaders = hrBindings.http_headers as string; + expect(hrHeaders).toBeDefined(); + expect(hrHeaders).toContain('"authorization"'); + expect(hrHeaders).not.toMatch(/"Authorization"/); + expect(hrHeaders).toContain('"x-correlationid"'); + expect(hrHeaders).not.toMatch(/"X-CorrelationId"/); + + const slackTool = tools.find(t => t.ref === 'send_slack-client'); + expect(slackTool).toBeDefined(); + const slackBindings = (slackTool?.['bound-inputs'] ?? {}) as Record< + string, + unknown + >; + const slackHeaders = slackBindings.http_headers as string; + expect(slackHeaders).toBeDefined(); + expect(slackHeaders).toContain('"x-slack-token"'); + expect(slackHeaders).not.toMatch(/"X-Slack-Token"/); + }); + + it('compiles http_headers with embedded expression references', () => { + const source = ` +config: + agent_name: "expr-headers" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +actions: + hr_agent: + target: "a2a://hr_agent_connection" + kind: "a2a:send_message" + send_slack: + target: "mcp://slack" + kind: "mcp:tool" + tool_name: "send_message" + +trigger t: + kind: "a2a" + target: "brokers://expr-headers/a2a" + on_message: -> transition to @subagent.worker + +subagent worker: + description: "uses actions with expression headers via with binding" + llm: @llm.g + reasoning: + instructions: -> onboard new hires + actions: + my_hr: @actions.hr_agent + with http_headers = {"Authorization": @request.headers.authorization, "X-CorrelationId": @variables.conversationId} + slack: @actions.send_slack + with http_headers = {"X-Static-Key": "static-value"} + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + + expect(result.diagnostics).toHaveLength(0); + + const workerNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'worker' + ) as Record | undefined; + expect(workerNode).toBeDefined(); + const tools = (workerNode?.tools as Array>) ?? []; + expect(tools).toHaveLength(2); + + const hrTool = tools.find(t => t.ref === 'hr_agent-client'); + const slackTool = tools.find(t => t.ref === 'send_slack-client'); + expect(hrTool).toBeDefined(); + expect(slackTool).toBeDefined(); + + const hrBindings = (hrTool?.['bound-inputs'] ?? {}) as Record< + string, + unknown + >; + const hrHttpHeaders = hrBindings.http_headers as string; + expect(hrHttpHeaders).toBeDefined(); + expect(hrHttpHeaders).toContain('"authorization"'); + expect(hrHttpHeaders).not.toMatch(/"Authorization"/); + expect(hrHttpHeaders).toContain('"x-correlationid"'); + expect(hrHttpHeaders).not.toMatch(/"X-CorrelationId"/); + + const slackBindings = (slackTool?.['bound-inputs'] ?? {}) as Record< + string, + unknown + >; + const slackHttpHeaders = slackBindings.http_headers as string; + expect(slackHttpHeaders).toBeDefined(); + expect(slackHttpHeaders).toContain('"x-static-key"'); + expect(slackHttpHeaders).not.toMatch(/"X-Static-Key"/); + expect(slackHttpHeaders).toContain('static-value'); + }); + + it('slot-fills all declared action inputs by default without explicit ...', () => { + const source = ` +config: + agent_name: "slot-default" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +actions: + slot_tool: + target: "mcp://conn" + kind: "mcp:tool" + tool_name: "tool" + inputs: + foo: {} + bar: {} + +trigger t: + kind: "a2a" + target: "brokers://slot-default/a2a" + on_message: -> transition to @subagent.worker + +subagent worker: + description: "slot-fill default" + llm: @llm.g + reasoning: + instructions: -> use tools + actions: + invoke: @actions.slot_tool + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const workerNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'worker' + ) as Record | undefined; + expect(workerNode).toBeDefined(); + const tools = (workerNode?.tools as Array>) ?? []; + const tool = tools.find(t => t.ref === 'slot_tool-client'); + expect(tool).toBeDefined(); + expect(tool?.['bound-inputs']).toBeUndefined(); + expect(tool?.['llm-inputs']).toEqual(['foo', 'bar']); + }); + + it('respects bound action parameters and slot-fills only unbound declared inputs', () => { + const source = ` +config: + agent_name: "slot-mixed" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +actions: + slot_tool: + target: "mcp://conn" + kind: "mcp:tool" + tool_name: "tool" + inputs: + foo: {} + bar: {} + +trigger t: + kind: "a2a" + target: "brokers://slot-mixed/a2a" + on_message: -> transition to @subagent.worker + +subagent worker: + description: "bound + slot-fill" + llm: @llm.g + reasoning: + instructions: -> use tools + actions: + invoke: @actions.slot_tool + with foo = "bound-val" + redundant: @actions.slot_tool + with foo = "x" + with bar = ... + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const workerNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'worker' + ) as Record | undefined; + const tools = (workerNode?.tools as Array>) ?? []; + const slotTools = tools.filter(t => t.ref === 'slot_tool-client'); + expect(slotTools).toHaveLength(2); + + expect( + slotTools.some( + t => + (t['bound-inputs'] as Record | undefined)?.foo === + '"bound-val"' && + (t['llm-inputs'] as string[] | undefined)?.join() === 'bar' + ) + ).toBe(true); + expect( + slotTools.some( + t => + (t['bound-inputs'] as Record | undefined)?.foo === + '"x"' && (t['llm-inputs'] as string[] | undefined)?.join() === 'bar' + ) + ).toBe(true); + }); + + it('does not infer llm-inputs when actions omits inputs', () => { + const source = ` +config: + agent_name: "no-inputs-def" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +actions: + bare_tool: + target: "mcp://conn" + kind: "mcp:tool" + tool_name: "tool" + +trigger t: + kind: "a2a" + target: "brokers://no-inputs-def/a2a" + on_message: -> transition to @subagent.worker + +subagent worker: + description: "no inputs block" + llm: @llm.g + reasoning: + instructions: -> use tools + actions: + invoke: @actions.bare_tool + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const workerNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'worker' + ) as Record | undefined; + const tools = (workerNode?.tools as Array>) ?? []; + const tool = tools.find(t => t.ref === 'bare_tool-client'); + expect(tool).toBeDefined(); + expect(tool?.['llm-inputs']).toBeUndefined(); + expect(tool?.['bound-inputs']).toBeUndefined(); + }); + + it('compiles echo label and description onto the graph action node', () => { + const source = ` +config: + agent_name: "echo-meta" + +trigger t: + kind: "a2a" + target: "brokers://echo-meta/a2a" + on_message: -> transition to @echo.reply + +echo reply: + kind: "a2a:response" + label: "Reply node" + description: "Sends the final reply to the client." + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const echoNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'reply' + ) as Record | undefined; + expect(echoNode).toBeDefined(); + expect(echoNode?.label).toBe('Reply node'); + expect(echoNode?.description).toBe('Sends the final reply to the client.'); + }); + + it('accepts router syntax with routes and otherwise', () => { + const source = ` +config: + agent_name: "router" + +trigger t: + kind: "a2a" + target: "brokers://router/a2a" + on_message: -> transition to @router.main + +router main: + routes: + - target: @echo.a + when: @request.payload.kind == "a" + otherwise: + target: @echo.b + +echo a: + kind: "a2a:response" + message: "a" + +echo b: + kind: "a2a:response" + message: "b" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + expect(result.output.unifiedAgentSpec.graph.nodes.length).toBeGreaterThan( + 0 + ); + }); + + it('compiles echo task expression with a2a namespace functions to a2a_ underscore form', () => { + const source = ` +config: + agent_name: "echo-task" + +variables: + msg: mutable string = "" + +trigger t: + kind: "a2a" + target: "brokers://echo-task/a2a" + on_message: -> transition to @echo.reply + +echo reply: + kind: "a2a:response" + task: a2a.task({state:"completed",message:a2a.message({parts:[a2a.textPart("hello")]})}) +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const echoNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'reply' + ) as Record | undefined; + expect(echoNode).toBeDefined(); + + const tools = (echoNode?.tools as Array>) ?? []; + const stateUpdates = + (tools[0]?.['state-updates'] as Array>) ?? []; + const taskValue = stateUpdates.find(s => '__reply_value' in s) + ?.__reply_value as string; + expect(taskValue).toBeDefined(); + expect(taskValue).not.toContain('template::'); + expect(taskValue).toContain('a2a_task('); + expect(taskValue).toContain('a2a_message('); + expect(taskValue).toContain('a2a_textPart('); + expect(taskValue).toContain('state='); + expect(taskValue).toContain('message='); + expect(taskValue).toContain('parts='); + expect(taskValue).not.toContain('{state'); + }); + + it('compiles a2a.X() without @ prefix to a2a_X() in executor expressions', () => { + const source = ` +config: + agent_name: "a2a-no-at" + +variables: + result: mutable string = "" + +trigger t: + kind: "a2a" + target: "brokers://a2a-no-at/a2a" + on_message: -> transition to @executor.step + +executor step: + do: -> + set @variables.result = a2a.message(a2a.textPart("test")) +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + + const node = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'step' + ) as Record | undefined; + expect(node).toBeDefined(); + + const tools = (node?.tools as Array>) ?? []; + const stateUpdates = + (tools[0]?.['state-updates'] as Array>) ?? []; + const expr = stateUpdates.find(s => 'result' in s)?.result as string; + expect(expr).toContain('a2a_message('); + expect(expr).toContain('a2a_textPart('); + expect(expr).not.toContain('a2a.message'); + expect(expr).not.toContain('a2a.textPart'); + }); + + it('compiles executor with uuid() function call', () => { + const source = ` +config: + agent_name: "fn-call" + +variables: + id: mutable string = "" + +trigger t: + kind: "a2a" + target: "brokers://fn-call/a2a" + on_message: -> transition to @executor.step + +executor step: + do: -> + set @variables.id = uuid() +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + + const node = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'step' + ) as Record | undefined; + expect(node).toBeDefined(); + + const tools = (node?.tools as Array>) ?? []; + const stateUpdates = + (tools[0]?.['state-updates'] as Array>) ?? []; + const idExpr = stateUpdates.find(s => 'id' in s)?.id as string; + expect(idExpr).toBe('uuid()'); + }); + + it('matches compiled YAML for customer-support-netwrok example fixture', async () => { + const agentPath = resolve( + __dirname, + './resources/agentfabric-customer-support-netwrok.agent' + ); + const snapshotPath = resolve( + __dirname, + './resources/agentfabric-customer-support-netwrok.yaml' + ); + const source = readFileSync(agentPath, 'utf8'); + + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + await expect(YAML.stringify(result.output)).toMatchFileSnapshot( + snapshotPath + ); + }); + + it('compiles @echo..input to state._node_input in echo task expression', () => { + const source = ` +config: + agent_name: "input-ref" + +trigger t: + kind: "a2a" + target: "brokers://input-ref/a2a" + on_message: -> transition to @echo.reply + +echo reply: + kind: "a2a:response" + task: a2a.task({state:"completed",message:a2a.message({parts:[a2a.textPart(@echo.reply.input)]})}) +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const echoNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'reply' + ) as Record | undefined; + expect(echoNode).toBeDefined(); + + const tools = (echoNode?.tools as Array>) ?? []; + const stateUpdates = + (tools[0]?.['state-updates'] as Array>) ?? []; + const taskValue = stateUpdates.find(s => '__reply_value' in s) + ?.__reply_value as string; + expect(taskValue).toBeDefined(); + expect(taskValue).toContain('state._node_input'); + }); + + it('compiles @executor..input to state._node_input in executor do expression', () => { + const source = ` +config: + agent_name: "exec-input" + +variables: + result: mutable string = "" + +trigger t: + kind: "a2a" + target: "brokers://exec-input/a2a" + on_message: -> transition to @executor.step + +executor step: + do: -> + set @variables.result = @executor.step.input +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + + const node = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'step' + ) as Record | undefined; + expect(node).toBeDefined(); + + const tools = (node?.tools as Array>) ?? []; + const stateUpdates = + (tools[0]?.['state-updates'] as Array>) ?? []; + const expr = stateUpdates.find(s => 'result' in s)?.result as string; + expect(expr).toBe('state._node_input'); + }); + + it('parses and compiles echo node with spread expression in task field', () => { + const source = ` +config: + agent_name: "spread_test" + +trigger t: + kind: "a2a" + target: "brokers://spread_test/a2a" + on_message: -> transition to @echo.a2a_response + +echo a2a_response: + kind: "a2a:response" + task: a2a_parts(*@variables.artifacts) + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + // Compilation should succeed without errors + expect( + result.diagnostics.filter(d => d.severity === 1 /* error */) + ).toHaveLength(0); + + // Verify the parsed task expression round-trips through toPlainData + const echoEntries = ast.echo as unknown as Map< + string, + Record + >; + const responseEntry = echoEntries.get('a2a_response')!; + expect(responseEntry).toBeDefined(); + + const taskPlain = toPlainData(responseEntry.task); + expect(taskPlain).toBe('a2a_parts(*@variables.artifacts)'); + }); + + it('parses echo node with spread inside list literal in task field', () => { + const source = ` +config: + agent_name: "spread_list_test" + +trigger t: + kind: "a2a" + target: "brokers://spread_list_test/a2a" + on_message: -> transition to @echo.resp + +echo resp: + kind: "a2a:response" + task: make_list([*@variables.parts, "extra"]) + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect( + result.diagnostics.filter(d => d.severity === 1 /* error */) + ).toHaveLength(0); + + const echoEntries = ast.echo as unknown as Map< + string, + Record + >; + const responseEntry = echoEntries.get('resp')!; + expect(responseEntry).toBeDefined(); + + const taskPlain = toPlainData(responseEntry.task); + expect(taskPlain).toBe('make_list([*@variables.parts, "extra"])'); + }); + + it('emits ActionDefinition for mcp:tool actions so executor nodes can resolve refs', () => { + const source = ` +config: + agent_name: "mcp-executor" + +actions: + lookup: + label: "KB Lookup" + description: "Searches knowledge base articles" + target: "mcp://kb_connection" + kind: "mcp:tool" + tool_name: "search_articles" + +variables: + query: mutable string = "" + +trigger t: + kind: "a2a" + target: "brokers://mcp-executor/a2a" + on_message: -> transition to @executor.run_lookup + +executor run_lookup: + do: -> + run @actions.lookup + with query = @variables.query + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const mcpDef = result.output.unifiedAgentSpec.definitions?.find( + (d: Definition) => d.name === 'lookup-action' + ) as Record | undefined; + expect(mcpDef).toBeDefined(); + expect(mcpDef?.type).toBe(ObjectTypes.ACTION); + expect(mcpDef?.client).toBe('lookup-client'); + expect(mcpDef?.label).toBe('KB Lookup'); + expect(mcpDef?.description).toBe('Searches knowledge base articles'); + expect(mcpDef?.['invocation-target-type']).toBe('mcp'); + expect(mcpDef?.['invocation-target-name']).toBe('search_articles'); + + const metadata = mcpDef?.metadata as Record | undefined; + expect(metadata?.protocol).toBe('mcp'); + expect(metadata?.connection).toBe('kb_connection'); + expect(metadata?.tool_name).toBe('search_articles'); + + const executorNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'run_lookup' + ) as Record | undefined; + expect(executorNode).toBeDefined(); + const tools = (executorNode?.tools as Array>) ?? []; + const toolRef = tools.find(t => t.ref === 'lookup-action'); + expect(toolRef).toBeDefined(); + expect(toolRef?.type).toBe(ObjectTypes.ACTION); + }); + + it('matches compiled YAML for it-help-investigation fixture', async () => { + const agentPath = resolve( + __dirname, + './resources/it-help-investigation.agent' + ); + const snapshotPath = resolve( + __dirname, + './resources/it-help-investigation.yaml' + ); + const source = readFileSync(agentPath, 'utf8'); + + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + await expect(YAML.stringify(result.output)).toMatchFileSnapshot( + snapshotPath + ); + }); + + it('emits ActionDefinition for mcp:tool with correct defaults when label/description are omitted', () => { + const source = ` +config: + agent_name: "mcp-defaults" + +actions: + my_tool: + target: "mcp://server" + kind: "mcp:tool" + tool_name: "do_thing" + +trigger t: + kind: "a2a" + target: "brokers://mcp-defaults/a2a" + on_message: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const mcpDef = result.output.unifiedAgentSpec.definitions?.find( + (d: Definition) => d.name === 'my_tool-action' + ) as Record | undefined; + expect(mcpDef).toBeDefined(); + expect(mcpDef?.label).toBe('my_tool-action'); + expect(mcpDef?.description).toBe('MCP tool: my_tool'); + expect(mcpDef?.['invocation-target-name']).toBe('do_thing'); + }); + + it('lowercases http_headers keys in executor run blocks', () => { + const source = ` +config: + agent_name: "exec-headers" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +actions: + billing_agent: + target: "a2a://billing_connection" + kind: "a2a:send_message" + +trigger t: + kind: "a2a" + target: "brokers://exec-headers/a2a" + on_message: -> transition to @executor.run_billing + +executor run_billing: + do: + run @actions.billing_agent + with http_headers = {"X-API-Key": "key-123", "Authorization": "Bearer exec-token"} + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const runNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'run_billing' + ) as Record | undefined; + expect(runNode).toBeDefined(); + const tools = (runNode?.tools as Array>) ?? []; + expect(tools.length).toBeGreaterThan(0); + + const billingTool = tools.find(t => + (t.ref as string)?.includes('billing_agent') + ); + expect(billingTool).toBeDefined(); + const bindings = (billingTool?.['bound-inputs'] ?? {}) as Record< + string, + unknown + >; + const headers = bindings.http_headers as string; + expect(headers).toBeDefined(); + expect(headers).toContain('"x-api-key"'); + expect(headers).not.toMatch(/"X-API-Key"/); + expect(headers).toContain('"authorization"'); + expect(headers).not.toMatch(/"Authorization"/); + }); + + it('does not warn when with params match declared inputs', () => { + const source = ` +config: + agent_name: "lint-ok" + +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: {} + bar: {} + +trigger t: + kind: "a2a" + target: "brokers://lint-ok/a2a" + on_message: -> transition to @subagent.worker + +subagent worker: + description: "lint ok" + llm: @llm.g + reasoning: + instructions: -> go + actions: + invoke: @actions.my_tool + with foo = "value_a" + with bar = "value_b" + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + }); + + it('does not warn when http_headers is used without being declared in inputs', () => { + const source = ` +config: + agent_name: "lint-implicit" + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +actions: + my_tool: + target: "mcp://conn" + kind: "mcp:tool" + tool_name: "tool" + +trigger t: + kind: "a2a" + target: "brokers://lint-implicit/a2a" + on_message: -> transition to @subagent.worker + +subagent worker: + description: "implicit http_headers" + llm: @llm.g + reasoning: + instructions: -> go + actions: + invoke: @actions.my_tool + with http_headers = {"X-Token": "abc"} + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + }); + + it('wraps system.node_outputs attribute access with parse_json in router enabled conditions', () => { + const source = ` +config: + agent_name: "routing" + +trigger t: + kind: "a2a" + target: "brokers://routing/a2a" + on_message: -> transition to @subagent.classify + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +subagent classify: + description: "classify" + llm: @llm.g + reasoning: + instructions: -> classify + on_exit: -> transition to @router.route + +router route: + routes: + - target: @echo.billing + when: @subagent.classify.output.category == "billing" + - target: @echo.tech + when: @subagent.classify.output.category == "technical" + otherwise: + target: @echo.general + +echo billing: + kind: "a2a:response" + message: "billing" + +echo tech: + kind: "a2a:response" + message: "tech" + +echo general: + kind: "a2a:response" + message: "general" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const routerNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'route' + ) as Record | undefined; + expect(routerNode).toBeDefined(); + + const onExit = routerNode?.['on-exit'] as + | Array> + | undefined; + expect(onExit).toBeDefined(); + expect(onExit!.length).toBeGreaterThanOrEqual(2); + + expect(onExit![0].enabled).toBe( + 'parse_json(system.node_outputs[\'classify\']).category == "billing"' + ); + expect(onExit![1].enabled).toBe( + 'parse_json(system.node_outputs[\'classify\']).category == "technical"' + ); + }); + + it('preserves hyphens in router when conditions using bracket access', () => { + const source = ` +config: + agent_name: "header-router" + +trigger t: + kind: "a2a" + target: "brokers://header-router/a2a" + on_message: -> transition to @router.check + +router check: + routes: + - target: @echo.slack + when: @request.headers["Slack-UUID"] != "" + otherwise: + target: @echo.fallback + +echo slack: + kind: "a2a:response" + message: "slack" + +echo fallback: + kind: "a2a:response" + message: "fallback" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const routerNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'check' + ) as Record | undefined; + expect(routerNode).toBeDefined(); + + const onExit = routerNode?.['on-exit'] as + | Array> + | undefined; + expect(onExit).toBeDefined(); + + expect(onExit![0].enabled).toBe( + 'state.request.headers[lower("Slack-UUID")] != ""' + ); + }); + + it('wraps system.node_outputs attribute access with parse_json in executor bound-inputs', () => { + const source = ` +config: + agent_name: "bound" + +trigger t: + kind: "a2a" + target: "brokers://bound/a2a" + on_message: -> transition to @subagent.analyze + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +subagent analyze: + description: "analyze" + llm: @llm.g + reasoning: + instructions: -> analyze + on_exit: -> transition to @executor.step + +actions: + my_tool: + kind: "mcp:tool" + connection: "conn" + tool_name: "do_something" + inputs: + ticket_id: + type: "string" + +executor step: + do: -> + run @actions.my_tool + with ticket_id = @subagent.analyze.output.ticket_id + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const execNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'step' + ) as Record | undefined; + expect(execNode).toBeDefined(); + + const tools = (execNode?.tools as Array>) ?? []; + const actionTool = tools.find(t => t.ref === 'my_tool-action'); + expect(actionTool).toBeDefined(); + + const boundInputs = actionTool?.['bound-inputs'] as + | Record + | undefined; + expect(boundInputs).toBeDefined(); + expect(boundInputs!.ticket_id).toBe( + "parse_json(system.node_outputs['analyze']).ticket_id" + ); + }); + + it('does not wrap bare system.node_outputs references without attribute access', () => { + const source = ` +config: + agent_name: "bare" + +trigger t: + kind: "a2a" + target: "brokers://bare/a2a" + on_message: -> transition to @subagent.agent + +llm: + g: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4o-mini" + +subagent agent: + description: "agent" + llm: @llm.g + reasoning: + instructions: -> do it + on_exit: -> transition to @echo.reply + +echo reply: + kind: "a2a:response" + message: "{{@subagent.agent.output}}" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const echoNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'reply' + ) as Record | undefined; + expect(echoNode).toBeDefined(); + + const tools = (echoNode?.tools as Array>) ?? []; + const identityTool = tools.find(t => t.ref === 'IdentityAction'); + expect(identityTool).toBeDefined(); + + const stateUpdates = identityTool?.['state-updates'] as + | Array> + | undefined; + expect(stateUpdates).toBeDefined(); + + const valueUpdate = stateUpdates![0]; + const valueStr = Object.values(valueUpdate)[0]; + expect(valueStr).toContain("system.node_outputs['agent']"); + expect(valueStr).not.toContain('parse_json'); + }); + + it('injects _handoff_source breadcrumb on generator after-reasoning handoff', () => { + const source = ` +config: + agent_name: "gen-breadcrumb" + +llm: + default: + target: "llm://conn" + kind: "OpenAI" + model: "gpt-4" + +trigger t: + kind: "a2a" + target: "brokers://gen-breadcrumb/a2a" + on_message: -> transition to @generator.step + +generator step: + llm: @llm.default + prompt: -> | hello + on_exit: -> + transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const genNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'step' + ) as Record | undefined; + expect(genNode).toBeDefined(); + expect(genNode!.type).toBe(ObjectTypes.AGENT); + + const afterReasoning = genNode!['after-reasoning'] as Array< + Record + >; + expect(afterReasoning).toBeDefined(); + expect(afterReasoning).toHaveLength(1); + + const handoff = afterReasoning[0]; + expect(handoff.type).toBe('handoff'); + expect(handoff.target).toBe('done'); + + const stateUpdates = handoff['state-updates'] as Array< + Record + >; + expect(stateUpdates).toBeDefined(); + expect(stateUpdates).toContainEqual({ _handoff_source: "'step'" }); + }); + + it('injects _handoff_source breadcrumb on executor on-exit handoff', () => { + const source = ` +config: + agent_name: "exec-breadcrumb" + +actions: + tool1: + target: "mcp://conn" + kind: "mcp:tool" + tool_name: "do_thing" + +trigger t: + kind: "a2a" + target: "brokers://exec-breadcrumb/a2a" + on_message: -> transition to @executor.step + +executor step: + do: -> + run @actions.tool1 + on_exit: -> + transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const execNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'step' + ) as Record | undefined; + expect(execNode).toBeDefined(); + + const onExit = execNode!['on-exit'] as Array>; + expect(onExit).toBeDefined(); + expect(onExit).toHaveLength(1); + + const handoff = onExit[0]; + expect(handoff.type).toBe('handoff'); + const stateUpdates = handoff['state-updates'] as Array< + Record + >; + expect(stateUpdates).toContainEqual({ _handoff_source: "'step'" }); + }); + + it('does not inject _handoff_source on non-producing nodes', () => { + const source = ` +config: + agent_name: "no-breadcrumb" + +trigger t: + kind: "a2a" + target: "brokers://no-breadcrumb/a2a" + on_message: -> transition to @router.decide + +router decide: + routes: + - target: @echo.a + when: "true" + otherwise: + target: @echo.b + +echo a: + kind: "a2a:response" + message: "a" + +echo b: + kind: "a2a:response" + message: "b" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const routerNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'decide' + ) as Record | undefined; + expect(routerNode).toBeDefined(); + + const onExit = routerNode!['on-exit'] as Array>; + expect(onExit).toBeDefined(); + for (const handoff of onExit) { + expect(handoff['state-updates']).toBeUndefined(); + } + }); + + it('prepends on-init _node_input lookup when node references state._node_input', () => { + const source = ` +config: + agent_name: "on-init-lookup" + +llm: + default: + target: "llm://conn" + kind: "OpenAI" + model: "gpt-4" + +trigger t: + kind: "a2a" + target: "brokers://on-init-lookup/a2a" + on_message: -> transition to @generator.gen + +generator gen: + llm: @llm.default + prompt: -> | hello + on_exit: -> + transition to @echo.reply + +echo reply: + kind: "a2a:response" + task: a2a.task({state:"completed",message:a2a.message({parts:[a2a.textPart(@echo.reply.input)]})}) +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const echoNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'reply' + ) as Record | undefined; + expect(echoNode).toBeDefined(); + + const onInit = echoNode!['on-init'] as Array>; + expect(onInit).toBeDefined(); + expect(onInit.length).toBeGreaterThanOrEqual(1); + + const lookup = onInit[0]; + expect(lookup.type).toBe('action'); + expect(lookup.ref).toBe('IdentityAction'); + const updates = lookup['state-updates'] as Array>; + expect(updates).toContainEqual({ + _node_input: "get(system.node_outputs, state._handoff_source, '')", + }); + }); + + it('places _node_input lookup before normalize_headers on initial node', () => { + const source = ` +config: + agent_name: "init-order" + +llm: + default: + target: "llm://conn" + kind: "OpenAI" + model: "gpt-4" + +trigger t: + kind: "a2a" + target: "brokers://init-order/a2a" + on_message: -> + transition to @echo.first + +echo first: + kind: "a2a:response" + task: a2a.task({state:"completed",message:a2a.message({parts:[a2a.textPart(@echo.first.input)]})}) +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const node = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'first' + ) as Record | undefined; + expect(node).toBeDefined(); + + const onInit = node!['on-init'] as Array>; + expect(onInit).toBeDefined(); + expect(onInit.length).toBe(2); + + const lookupUpdates = onInit[0]['state-updates'] as Array< + Record + >; + expect(lookupUpdates[0]).toHaveProperty('_node_input'); + + const normalizeUpdates = onInit[1]['state-updates'] as Array< + Record + >; + expect(normalizeUpdates[0]).toHaveProperty('request'); + }); + + it('adds _handoff_source and _node_input state variables when tracking is injected', () => { + const source = ` +config: + agent_name: "state-vars" + +llm: + default: + target: "llm://conn" + kind: "OpenAI" + model: "gpt-4" + +trigger t: + kind: "a2a" + target: "brokers://state-vars/a2a" + on_message: -> + transition to @generator.gen + +generator gen: + llm: @llm.default + prompt: -> | hello + on_exit: -> + transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const stateVars = result.output.unifiedAgentSpec.graph[ + 'state-variables' + ] as unknown as Array>; + const varNames = stateVars.map(v => v.name); + expect(varNames).toContain('_handoff_source'); + expect(varNames).toContain('_node_input'); + + const handoffVar = stateVars.find(v => v.name === '_handoff_source')!; + expect(handoffVar['data-type']).toBe('string'); + expect(handoffVar.default).toBeNull(); + }); + + it('omits tracking state variables when no producing nodes exist', () => { + const source = ` +config: + agent_name: "no-tracking" + +trigger t: + kind: "a2a" + target: "brokers://no-tracking/a2a" + on_message: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const stateVars = result.output.unifiedAgentSpec.graph[ + 'state-variables' + ] as unknown as Array>; + const varNames = stateVars.map(v => v.name); + expect(varNames).not.toContain('_handoff_source'); + expect(varNames).not.toContain('_node_input'); + }); + + it('emits result["content"] for mcp:tool and result["result"] for a2a:send_message', () => { + const source = ` +config: + agent_name: "result-field" + +actions: + mcp_action: + target: "mcp://conn" + kind: "mcp:tool" + tool_name: "search" + a2a_action: + target: "a2a://conn" + kind: "a2a:send_message" + +trigger t: + kind: "a2a" + target: "brokers://result-field/a2a" + on_message: -> + transition to @executor.mcp_step + +executor mcp_step: + do: -> + run @actions.mcp_action + on_exit: -> + transition to @executor.a2a_step + +executor a2a_step: + do: -> + run @actions.a2a_action + on_exit: -> + transition to @echo.done + +echo done: + kind: "a2a:response" + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const mcpNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'mcp_step' + ) as Record | undefined; + const a2aNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'a2a_step' + ) as Record | undefined; + expect(mcpNode).toBeDefined(); + expect(a2aNode).toBeDefined(); + + const mcpTools = mcpNode!.tools as Array>; + const mcpUpdates = mcpTools.flatMap( + t => (t['state-updates'] as Array>) ?? [] + ); + const mcpOutputExpr = mcpUpdates.find(s => 'outputs' in s) + ?.outputs as string; + expect(mcpOutputExpr).toContain('result["content"]'); + + const a2aTools = a2aNode!.tools as Array>; + const a2aUpdates = a2aTools.flatMap( + t => (t['state-updates'] as Array>) ?? [] + ); + const a2aOutputExpr = a2aUpdates.find(s => 'outputs' in s) + ?.outputs as string; + expect(a2aOutputExpr).toContain('result["result"]'); + }); + + it('buildOnInit emits type: action on the IdentityAction reference', () => { + const source = ` +config: + agent_name: "init-type" + +trigger t: + kind: "a2a" + target: "brokers://init-type/a2a" + on_message: -> transition to @echo.first + +echo first: + kind: "a2a:response" + message: "ok" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + expect(result.diagnostics).toHaveLength(0); + + const node = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'first' + ) as Record | undefined; + expect(node).toBeDefined(); + + const onInit = node!['on-init'] as Array>; + expect(onInit).toBeDefined(); + + const identityAction = onInit.find(a => a.ref === 'IdentityAction'); + expect(identityAction).toBeDefined(); + expect(identityAction!.type).toBe('action'); + }); +}); diff --git a/dialect/agentfabric/src/tests/dialect.test.ts b/dialect/agentfabric/src/tests/dialect.test.ts index b8229e4a..ef8d2f65 100644 --- a/dialect/agentfabric/src/tests/dialect.test.ts +++ b/dialect/agentfabric/src/tests/dialect.test.ts @@ -1,10 +1,3 @@ -/* - * Copyright (c) 2026, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 - */ - import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; diff --git a/dialect/agentfabric/src/tests/lint.test.ts b/dialect/agentfabric/src/tests/lint.test.ts index eb4a9e08..f0e59d2a 100644 --- a/dialect/agentfabric/src/tests/lint.test.ts +++ b/dialect/agentfabric/src/tests/lint.test.ts @@ -1,14 +1,8 @@ -/* - * Copyright (c) 2026, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 - */ - 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 +734,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 +755,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 +804,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..0967fa04 --- /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 (W-22415806): 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/schema-validation.test.ts b/dialect/agentfabric/src/tests/schema-validation.test.ts new file mode 100644 index 00000000..0b0bb1b8 --- /dev/null +++ b/dialect/agentfabric/src/tests/schema-validation.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'vitest'; +import { parseDocument, toRecord } from './test-utils.js'; +import { compile } from '../compiler/compile.js'; +import { AgentFabricSchemaInfo } from '../schema.js'; + +describe('AgentFabric Schema Validation', () => { + it('exposes request globalScopes and a2a namespacedFunctions on the schema info', () => { + const gs = AgentFabricSchemaInfo.globalScopes; + expect(gs).toBeDefined(); + expect(gs?.request?.has('payload')).toBe(true); + expect(gs?.request?.has('interface')).toBe(true); + expect(gs?.request?.has('headers')).toBe(true); + + const nf = AgentFabricSchemaInfo.namespacedFunctions; + expect(nf).toBeDefined(); + expect(nf?.a2a?.has('task')).toBe(true); + expect(nf?.a2a?.has('message')).toBe(true); + expect(nf?.a2a?.has('textPart')).toBe(true); + expect(nf?.a2a?.has('parts')).toBe(true); + }); + + it('compiled output has correct top-level structure', () => { + const source = ` +config: + agent_name: "schema-test" + label: "Schema Test" + +trigger t: + kind: "a2a" + target: "brokers://schema-test/a2a" + on_message: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "OK" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + const spec = result.output.unifiedAgentSpec; + + // UnifiedAgentSpecification required fields + expect(spec).toHaveProperty('schema-version'); + expect(spec).toHaveProperty('id'); + expect(spec).toHaveProperty('label'); + expect(spec).toHaveProperty('graph'); + expect(spec.graph).toHaveProperty('initial-node'); + expect(spec.graph).toHaveProperty('nodes'); + expect(spec.graph).toHaveProperty('state-variables'); + }); + + it('AgentGraph has all required fields', () => { + const source = ` +config: + agent_name: "graph-test" + +trigger t: + kind: "a2a" + target: "brokers://graph-test/a2a" + on_message: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "OK" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + + expect(result.output).toHaveProperty('unifiedAgentSpec'); + expect(result.output).toHaveProperty('llmProviders'); + expect(result.output).toHaveProperty('invokableClients'); + expect(result.output).toHaveProperty('responseNodeNames'); + expect(result.output).toHaveProperty('trigger'); + expect(result.output).toHaveProperty('outputStructures'); + expect(Array.isArray(result.output.llmProviders)).toBe(true); + expect(Array.isArray(result.output.invokableClients)).toBe(true); + expect(Array.isArray(result.output.responseNodeNames)).toBe(true); + expect(result.output.trigger).toEqual({ + id: 't', + kind: 'a2a', + namespace: 'brokers', + target_id: 'graph-test', + on_message: { transition_to: '@echo.done' }, + }); + expect(typeof result.output.outputStructures).toBe('object'); + }); + + it('agent nodes conform to AgentNode schema', () => { + const source = ` +config: + agent_name: "node-test" + +llm: + test_llm: + target: "llm://openai" + kind: "OpenAI" + model: "gpt-4" + +trigger t: + kind: "a2a" + target: "brokers://node-test/a2a" + on_message: -> transition to @orchestrator.main + +orchestrator main: + description: "Test node" + llm: @llm.test_llm + reasoning: + instructions: -> Do the task + on_exit: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "OK" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + + const agentNode = result.output.unifiedAgentSpec.graph.nodes.find( + n => n.name === 'main' + ); + expect(agentNode).toBeDefined(); + expect(agentNode).toHaveProperty('type', 'agent'); + expect(agentNode).toHaveProperty('llm'); + expect(agentNode).toHaveProperty('system-prompt'); + }); + + it('definitions include IdentityAction', () => { + const source = ` +config: + agent_name: "def-test" + +trigger t: + kind: "a2a" + target: "brokers://def-test/a2a" + on_message: -> transition to @echo.done + +echo done: + kind: "a2a:response" + message: "OK" +`; + const ast = parseDocument(source); + const result = compile(toRecord(ast)); + + const defs = result.output.unifiedAgentSpec.definitions ?? []; + const identity = defs.find(d => d.name === 'IdentityAction'); + expect(identity).toBeDefined(); + if (identity && 'client' in identity) { + expect(identity.client).toBe('in-built'); + } + }); +}); 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..7103a4ad --- /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: W-22181425 — 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 (W-22181425 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/agentfabric/src/tests/test-utils.ts b/dialect/agentfabric/src/tests/test-utils.ts index 07174fb3..1553538b 100644 --- a/dialect/agentfabric/src/tests/test-utils.ts +++ b/dialect/agentfabric/src/tests/test-utils.ts @@ -1,10 +1,3 @@ -/* - * Copyright (c) 2026, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 - */ - /** Test utilities for parsing AgentFabric dialect sources. */ import { parse } from '@agentscript/parser'; import { diff --git a/dialect/agentfabric/src/tests/utils.test.ts b/dialect/agentfabric/src/tests/utils.test.ts new file mode 100644 index 00000000..0dfdbb81 --- /dev/null +++ b/dialect/agentfabric/src/tests/utils.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest'; +import { + normalizeId, + iterateCollection, + combineGlobalSystemInstructions, + normalizeTemplate, + toPlainData, +} from '../compiler/utils.js'; +import { + SpreadExpression, + CallExpression, + MemberExpression, + AtIdentifier, + Identifier, +} from '@agentscript/language'; + +describe('compiler utils', () => { + it('normalizeId converts kebab-case to snake_case', () => { + expect(normalizeId('my-node')).toBe('my_node'); + expect(normalizeId('a-b-c')).toBe('a_b_c'); + }); + + it('iterateCollection reads native Map', () => { + const m = new Map>([['a', { x: 1 }]]); + expect(iterateCollection(m)).toEqual([['a', { x: 1 }]]); + }); + + it('combineGlobalSystemInstructions prefers node text over global', () => { + expect(combineGlobalSystemInstructions('Global line.', 'Node line.')).toBe( + 'Node line.' + ); + }); + + it('combineGlobalSystemInstructions returns node-only when global empty', () => { + expect(combineGlobalSystemInstructions('', 'Only node')).toBe('Only node'); + expect(combineGlobalSystemInstructions(undefined, 'Only node')).toBe( + 'Only node' + ); + }); + + it('combineGlobalSystemInstructions returns global-only when node empty', () => { + expect(combineGlobalSystemInstructions('Global only', '')).toBe( + 'Global only' + ); + expect(combineGlobalSystemInstructions('Global only', ' ')).toBe( + 'Global only' + ); + }); + + it('normalizeTemplate rewrites {!@request.*} to {{state.request.*}}', () => { + expect( + normalizeTemplate('| {!@request.payload.message.parts[0].text}') + ).toBe('| {{state.request.payload.message.parts[0].text}}'); + }); + + it('normalizeTemplate rewrites {!@variables.*} to {{state.*}}', () => { + expect(normalizeTemplate('| {!@variables.customerMessage}')).toBe( + '| {{state.customerMessage}}' + ); + }); + + it('normalizeTemplate rewrites bare @generator..output without parse_json', () => { + expect( + normalizeTemplate('{{@generator.generateHrSlackUpdateMessage.output}}') + ).toBe("{{system.node_outputs['generateHrSlackUpdateMessage']}}"); + }); + + it('normalizeTemplate wraps @generator..output. with parse_json', () => { + expect(normalizeTemplate('{{@generator.myNode.output.summary}}')).toBe( + "{{parse_json(system.node_outputs['myNode']).summary}}" + ); + }); + + it('normalizeTemplate wraps @subagent..output with nested attrs via parse_json', () => { + expect( + normalizeTemplate('{{@subagent.analysis.output.field.nested}}') + ).toBe("{{parse_json(system.node_outputs['analysis']).field.nested}}"); + }); + + it('normalizeTemplate rewrites @executor..output to state.outputs', () => { + expect(normalizeTemplate('{{@executor.execStep.output}}')).toBe( + "{{state.outputs['execStep']}}" + ); + }); + + it('normalizeTemplate does not wrap @executor..output. with parse_json', () => { + expect(normalizeTemplate('{{@executor.execStep.output.field}}')).toBe( + "{{state.outputs['execStep'].field}}" + ); + }); + + it('normalizeTemplate marks deprecated @outputs alias as error marker', () => { + expect( + normalizeTemplate('{{@outputs.generateHrSlackUpdateMessage}}') + ).toContain('__ERROR__outputs_alias_not_supported__'); + }); + + it('normalizeTemplate rewrites @echo..input to state._node_input', () => { + expect(normalizeTemplate('{{@echo.send_response.input}}')).toBe( + '{{state._node_input}}' + ); + }); + + it('normalizeTemplate rewrites @executor..input to state._node_input', () => { + expect(normalizeTemplate('{{@executor.billing_handler.input}}')).toBe( + '{{state._node_input}}' + ); + }); + + it('normalizeTemplate rewrites @generator..input to state._node_input', () => { + expect(normalizeTemplate('{{@generator.responder.input}}')).toBe( + '{{state._node_input}}' + ); + }); + + it('normalizeTemplate rewrites @orchestrator..input to state._node_input', () => { + expect(normalizeTemplate('{{@orchestrator.main.input}}')).toBe( + '{{state._node_input}}' + ); + }); + + it('normalizeTemplate rewrites @router..input to state._node_input', () => { + expect(normalizeTemplate('{{@router.classify.input}}')).toBe( + '{{state._node_input}}' + ); + }); +}); + +describe('toPlainData', () => { + it('emits SpreadExpression via __emit', () => { + const spread = new SpreadExpression( + new MemberExpression(new AtIdentifier('variables'), 'artifacts') + ); + expect(toPlainData(spread)).toBe('*@variables.artifacts'); + }); + + it('emits CallExpression with spread arg via __emit', () => { + const call = new CallExpression(new Identifier('a2a_parts'), [ + new SpreadExpression( + new MemberExpression(new AtIdentifier('variables'), 'artifacts') + ), + ]); + expect(toPlainData(call)).toBe('a2a_parts(*@variables.artifacts)'); + }); + + it('emits plain CallExpression with named args via __emit', () => { + const call = new CallExpression(new Identifier('a2a_task'), [ + new Identifier('state'), + ]); + expect(toPlainData(call)).toBe('a2a_task(state)'); + }); +}); 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..b565ee6f 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 for W-22415806; 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, From d2a608fc59897599cfd69c0b0212737fb47919e5 Mon Sep 17 00:00:00 2001 From: "ashwin.venkataraman" Date: Fri, 5 Jun 2026 20:14:19 +0530 Subject: [PATCH 2/5] update lock file --- pnpm-lock.yaml | 179 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 160 insertions(+), 19 deletions(-) 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 From d0a5332c03897f3d4539aefa563c2f35c4e16ef7 Mon Sep 17 00:00:00 2001 From: "ashwin.venkataraman" Date: Mon, 8 Jun 2026 15:01:13 +0530 Subject: [PATCH 3/5] agentfabric: Add new lint rules --- dialect/agentfabric/CHANGELOG.md | 16 +- dialect/agentfabric/README.md | 48 + .../agentfabric/src/compiler/agent-graph.ts | 28 - .../src/compiler/build-definitions.ts | 167 -- .../agentfabric/src/compiler/build-nodes.ts | 1389 ------------- .../src/compiler/build-providers.ts | 253 --- .../src/compiler/compile-execute-do.ts | 460 ----- dialect/agentfabric/src/compiler/compile.ts | 218 -- .../src/compiler/compiler-context.ts | 27 - dialect/agentfabric/src/compiler/index.ts | 22 - .../agentfabric/src/compiler/service-types.ts | 25 - .../compiler/unified-agent-specification.ts | 366 ---- dialect/agentfabric/src/compiler/utils.ts | 369 ---- dialect/agentfabric/src/index.ts | 21 - .../lint/passes/rules/action-binding-rules.ts | 4 +- .../src/lint/passes/rules/echo-rules.ts | 2 +- .../src/lint/passes/rules/execute-rules.ts | 4 +- .../src/lint/passes/rules/shared.ts | 3 +- .../src/lint/passes/rules/switch-rules.ts | 2 +- dialect/agentfabric/src/lint/utils.ts | 50 + .../agentfabric/src/tests/compiler.test.ts | 1755 ----------------- .../src/tests/llm-value-completions.test.ts | 2 +- .../src/tests/schema-validation.test.ts | 150 -- .../src/tests/snippet-indentation.test.ts | 4 +- dialect/agentfabric/src/tests/utils.test.ts | 152 -- .../language/src/core/analysis/completions.ts | 2 +- 26 files changed, 124 insertions(+), 5415 deletions(-) create mode 100644 dialect/agentfabric/README.md delete mode 100644 dialect/agentfabric/src/compiler/agent-graph.ts delete mode 100644 dialect/agentfabric/src/compiler/build-definitions.ts delete mode 100644 dialect/agentfabric/src/compiler/build-nodes.ts delete mode 100644 dialect/agentfabric/src/compiler/build-providers.ts delete mode 100644 dialect/agentfabric/src/compiler/compile-execute-do.ts delete mode 100644 dialect/agentfabric/src/compiler/compile.ts delete mode 100644 dialect/agentfabric/src/compiler/compiler-context.ts delete mode 100644 dialect/agentfabric/src/compiler/index.ts delete mode 100644 dialect/agentfabric/src/compiler/service-types.ts delete mode 100644 dialect/agentfabric/src/compiler/unified-agent-specification.ts delete mode 100644 dialect/agentfabric/src/compiler/utils.ts create mode 100644 dialect/agentfabric/src/lint/utils.ts delete mode 100644 dialect/agentfabric/src/tests/compiler.test.ts delete mode 100644 dialect/agentfabric/src/tests/schema-validation.test.ts delete mode 100644 dialect/agentfabric/src/tests/utils.test.ts diff --git a/dialect/agentfabric/CHANGELOG.md b/dialect/agentfabric/CHANGELOG.md index 98a2db65..c358a8bd 100644 --- a/dialect/agentfabric/CHANGELOG.md +++ b/dialect/agentfabric/CHANGELOG.md @@ -1,5 +1,19 @@ # @agentscript/agentfabric-dialect +## 0.1.24 + +### Patch Changes + +- d01c76b: Fix publish: rewrite `@agentscript/*` → `@sf-agentscript/*` in `dist/` and `src/`, not just `package.json`. + + Previously, `scripts/publish.mjs` only rewrote `package.json` files at publish time. The compiled JavaScript in `dist/` and the shipped TypeScript in `src/` still contained `import ... from '@agentscript/*'`, so consumers installing `@sf-agentscript/*` packages from npm hit `ERR_MODULE_NOT_FOUND: Cannot find package '@agentscript/...'` at runtime. + + `scripts/publish.mjs` now also rewrites `.js`, `.mjs`, `.cjs`, `.ts`, `.tsx`, `.cts`, `.mts`, and `.map` files inside each package's `dist/` and `src/` directories, so published artifacts resolve cleanly under the `@sf-agentscript` scope. + +- Updated dependencies [d01c76b] + - @agentscript/agentscript-dialect@2.5.20 + - @agentscript/language@2.5.4 + ## 0.1.8 ### Breaking Changes @@ -54,7 +68,7 @@ ### Patch Changes -- Revert TDX rename (`tool_definitions` back to `actions`, `tools` back to `actions` in reasoning blocks). Add support for discriminant-based polymorphic variants via `.discriminant()` on block factories. Refactor `block.ts` into focused modules (block-factory, named-block-factory, typed-map-factory, collection-block-factory, factory-utils). Fix variant type propagation through `InferFieldType` and collection factories. Improve comment attachment parity with tree-sitter parser. +- Revert rename (`tool_definitions` back to `actions`, `tools` back to `actions` in reasoning blocks). Add support for discriminant-based polymorphic variants via `.discriminant()` on block factories. Refactor `block.ts` into focused modules (block-factory, named-block-factory, typed-map-factory, collection-block-factory, factory-utils). Fix variant type propagation through `InferFieldType` and collection factories. Improve comment attachment parity with tree-sitter parser. - Updated dependencies - @agentscript/language@2.4.4 - @agentscript/agentscript-dialect@2.5.4 diff --git a/dialect/agentfabric/README.md b/dialect/agentfabric/README.md new file mode 100644 index 00000000..e7205cfa --- /dev/null +++ b/dialect/agentfabric/README.md @@ -0,0 +1,48 @@ +# @agentscript/agentfabric-dialect + +AgentFabric dialect — defines the schema, lint rules, and compiler for the AgentFabric platform. + +## Overview + +This dialect extends the base AgentScript schema with AgentFabric-specific blocks, fields, and a full compiler. It is an alternative to the Agentforce dialect for targeting the AgentFabric runtime. + +## Installation + +```bash +pnpm add @agentscript/agentfabric-dialect +``` + +## Usage + +```typescript +import { agentfabricDialect } from '@agentscript/agentfabric-dialect'; + +// Use as a DialectConfig +console.log(agentfabricDialect.name); // 'agentfabric' +console.log(agentfabricDialect.schemaInfo); // AgentFabric-specific schema +``` + +## What It Provides + +- **Schema** — AgentFabric-specific block types and field definitions +- **Lint rules** — AgentFabric-specific validation passes +- **Compiler** — full compilation pipeline for the AgentFabric output format +- **Dialect config** — `DialectConfig` object for use with `@agentscript/language` and `@agentscript/lsp` + +## Dependencies + +- `@agentscript/agentscript-dialect` — inherits the base schema and rules +- `@agentscript/language` — language infrastructure + +## Scripts + +```bash +pnpm build # Compile TypeScript +pnpm test # Run tests +pnpm typecheck # Type-check +pnpm dev # Watch mode +``` + +## License + +MIT \ No newline at end of file diff --git a/dialect/agentfabric/src/compiler/agent-graph.ts b/dialect/agentfabric/src/compiler/agent-graph.ts deleted file mode 100644 index 389efa10..00000000 --- a/dialect/agentfabric/src/compiler/agent-graph.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * AgentGraph — the top-level compiler output type. - * Mirrors the Python AgentGraph class from unified_agent_specification_adaptor.py. - */ - -import type { UnifiedAgentSpecification } from './unified-agent-specification.js'; -import type { LLMProvider, InvokableClient } from './service-types.js'; - -export interface AgentGraphTrigger { - id: string; - kind: 'a2a'; - namespace: string; - target_id: string; - on_message: { - transition_to: string; - }; -} - -export interface AgentGraph { - unifiedAgentSpec: UnifiedAgentSpecification; - llmProviders: LLMProvider[]; - invokableClients: InvokableClient[]; - responseNodeNames: string[]; - trigger: AgentGraphTrigger | null; - /** Mapping of output-structure ref ids to outputStructure schemas. - * Node-level linkage is carried by `llm.output-structure-ref`. */ - outputStructures: Record>; -} diff --git a/dialect/agentfabric/src/compiler/build-definitions.ts b/dialect/agentfabric/src/compiler/build-definitions.ts deleted file mode 100644 index d7e6b893..00000000 --- a/dialect/agentfabric/src/compiler/build-definitions.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Build ActionDefinition entries from actions and the built-in IdentityAction. - * Mirrors _get_definitions() in the Python adaptor. - */ - -import type { - ActionDefinition, - Definition, -} from './unified-agent-specification.js'; -import { ObjectTypes } from './unified-agent-specification.js'; -import type { AgentFabricCompilerContext } from './compiler-context.js'; -import { extractString } from './utils.js'; - -/** - * JSON schema for A2A MessageSendParams input. - * Simplified static schema matching the Python adaptor's MessageSendParams.model_json_schema(). - */ -const MESSAGE_SEND_PARAMS_SCHEMA = { - type: 'object', - properties: { - message: { - type: 'object', - properties: { - role: { type: 'string' }, - parts: { - type: 'array', - items: { - type: 'object', - properties: { - kind: { type: 'string' }, - text: { type: 'string' }, - }, - }, - }, - }, - }, - }, -}; - -/** - * Permissive JSON schema for MCP tool input. - * Actual schemas are discovered at runtime via MCP protocol; this placeholder - * ensures the definition is valid and the runtime can resolve the ref. - */ -const MCP_TOOL_INPUT_SCHEMA = { - type: 'object', - properties: {}, - additionalProperties: true, -}; - -/** - * JSON schema for ToolCallResultEvent output. - */ -const TOOL_CALL_RESULT_SCHEMA = { - type: 'object', - properties: { - result: { type: 'object' }, - }, -}; - -function cloneSchema(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -export function buildDefinitions( - actionDefs: Map> | undefined, - _ctx: AgentFabricCompilerContext -): Definition[] { - const result: Definition[] = []; - - if (actionDefs) { - for (const [name, def] of actionDefs) { - const kind = extractString((def as Record).kind); - if (kind === 'a2a:send_message') { - const target = - extractString((def as Record).target) ?? ''; - const connectionUrl = target.replace( - /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, - '' - ); - const record = def as Record; - const defaultLabel = `${name}-action`; - const defaultDescription = `A2A tool: ${name}`; - const label = extractString(record.label) ?? defaultLabel; - const description = - extractString(record.description) ?? defaultDescription; - - const actionDef: ActionDefinition = { - name: `${name}-action`, - type: ObjectTypes.ACTION, - client: `${name}-client`, - label, - description, - 'invocation-target-type': 'agent', - 'invocation-target-name': name, - 'input-schema': cloneSchema(MESSAGE_SEND_PARAMS_SCHEMA), - 'output-schema': cloneSchema(TOOL_CALL_RESULT_SCHEMA), - behavior: { - 'require-user-confirmation': false, - 'include-in-progress-indicator': false, - }, - metadata: { - protocol: 'a2a', - url: connectionUrl, - platform: 'Mulesoft', - }, - }; - result.push(actionDef); - } else if (kind === 'mcp:tool') { - const record = def as Record; - const toolName = extractString(record.tool_name) ?? name; - const target = extractString(record.target) ?? ''; - const connection = target.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, ''); - const defaultLabel = `${name}-action`; - const defaultDescription = `MCP tool: ${name}`; - const label = extractString(record.label) ?? defaultLabel; - const description = - extractString(record.description) ?? defaultDescription; - - const actionDef: ActionDefinition = { - name: `${name}-action`, - type: ObjectTypes.ACTION, - client: `${name}-client`, - label, - description, - 'invocation-target-type': 'mcp', - 'invocation-target-name': toolName, - 'input-schema': cloneSchema(MCP_TOOL_INPUT_SCHEMA), - 'output-schema': cloneSchema(TOOL_CALL_RESULT_SCHEMA), - behavior: { - 'require-user-confirmation': false, - 'include-in-progress-indicator': false, - }, - metadata: { - protocol: 'mcp', - connection, - tool_name: toolName, - }, - }; - result.push(actionDef); - } - } - } - - // Always include IdentityAction - result.push({ - name: 'IdentityAction', - type: ObjectTypes.ACTION, - client: 'in-built', - label: 'State Update Action', - description: 'Generic action for updating state variables', - 'invocation-target-type': 'internal', - 'invocation-target-name': 'state-update-action', - 'input-schema': { - type: 'object', - properties: {}, - additionalProperties: true, - }, - 'output-schema': { - type: 'object', - properties: {}, - additionalProperties: true, - }, - }); - - return result; -} diff --git a/dialect/agentfabric/src/compiler/build-nodes.ts b/dialect/agentfabric/src/compiler/build-nodes.ts deleted file mode 100644 index 2afb7f13..00000000 --- a/dialect/agentfabric/src/compiler/build-nodes.ts +++ /dev/null @@ -1,1389 +0,0 @@ -/** - * Build UnifiedAgentSpecification nodes from parsed AgentFabric AST blocks. - * Mirrors the _build_*_node() methods from the Python adaptor. - */ - -import type { - Node, - AgentNode, - ActionNode, - HandoffAction, - HandoffActionUnion, - ActionCallableReference, - LLMRef, - ToolUnion, - MCPTool, - A2ATool, - StateVariable, - NodeSystemLimits, -} from './unified-agent-specification.js'; -import { ObjectTypes } from './unified-agent-specification.js'; -import { - isNamedMap, - decomposeAtMemberExpression, - Identifier, - WithClause, - Ellipsis, - SubscriptExpression, - StringLiteral, - NumberLiteral, - BooleanLiteral, - NoneLiteral, - VariableDeclarationNode, -} from '@agentscript/language'; -import type { Expression } from '@agentscript/language'; -import { - normalizeId, - resolveTarget, - normalizeTemplate, - extractString, - extractNumber, - extractLlmFieldReference, - iterateCollection, - combineGlobalSystemInstructions, - extractProcedureText, - extractTransitionReference, - toPlainData, -} from './utils.js'; -import { - compileExecuteDoProcedure, - compileExecuteExpression, - collectExecuteVariableEnv, - type ExecuteVariableEnv, -} from './compile-execute-do.js'; - -/** Extract top-level `system.instructions` as the global system prompt for all agent nodes. */ -function extractGlobalSystemInstructions(ast: Record): string { - const system = ast.system; - if (system == null || typeof system !== 'object') return ''; - return extractString((system as Record).instructions) ?? ''; -} - -// ── Shared helpers ────────────────────────────────────────────────── - -function buildOnInit(firstNode: boolean): ActionCallableReference[] | null { - if (firstNode) { - return [ - { - type: ObjectTypes.ACTION, - ref: 'IdentityAction', - 'state-updates': [ - { - request: "normalize_headers(variables['request'])", - }, - ], - }, - ]; - } - return null; -} - -/** Strip optional `@llm.` prefix from a config/node LLM reference. */ -function stripLlmRef(raw: string | undefined): string | undefined { - if (raw === undefined || raw === null) return undefined; - const t = String(raw).trim(); - if (!t) return undefined; - if (t.startsWith('@llm.')) return t.slice(5); - return t; -} - -function extractLlmRefFromText(text: string): string | undefined { - const m = text.match(/@llm\.([A-Za-z0-9_-]+)/); - return m?.[1]; -} - -function extractDefaultLlmRefFromSource(source: string): string | undefined { - const lines = source.split(/\r?\n/); - let inConfig = false; - for (const line of lines) { - const trimmed = line.trim(); - if (!inConfig) { - if (trimmed === 'config:') inConfig = true; - continue; - } - if (trimmed.length === 0) continue; - if (!line.startsWith(' ')) break; - const m = line.match(/^\s{2}default_llm:\s*(.+)\s*$/); - if (m) return extractLlmRefFromText(m[1]); - } - return undefined; -} - -function extractNodeLlmRefFromSource( - source: string, - nodeType: string, - nodeName: string -): string | undefined { - const lines = source.split(/\r?\n/); - const escapedType = nodeType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const escapedName = nodeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const headerRe = new RegExp(`^${escapedType}\\s+${escapedName}:\\s*$`); - - for (let i = 0; i < lines.length; i++) { - if (!headerRe.test(lines[i].trim())) continue; - for (let j = i + 1; j < lines.length; j++) { - const line = lines[j]; - const trimmed = line.trim(); - if (trimmed.length === 0) continue; - if (!line.startsWith(' ')) return undefined; - if (line.startsWith(' ')) continue; - const m = line.match(/^\s{2}llm:\s*(.+)\s*$/); - if (m) return extractLlmRefFromText(m[1]); - } - } - return undefined; -} - -interface SourceNodeTool { - actionDefName: string; - llmInputs: string[]; - boundInputs: Record; -} - -interface CollectedToolInputs { - llmInputs: string[]; - boundInputs: Record; -} - -function parseNodeToolsFromSource( - source: string, - nodeType: string, - nodeName: string -): SourceNodeTool[] { - const lines = source.split(/\r?\n/); - const escapedType = nodeType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const escapedName = nodeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const headerRe = new RegExp(`^${escapedType}\\s+${escapedName}:\\s*$`); - - for (let i = 0; i < lines.length; i++) { - if (!headerRe.test(lines[i].trim())) continue; - - let actionsLine = -1; - let actionsIndent = 0; - let reasoningLine = -1; - let reasoningIndent = 0; - for (let j = i + 1; j < lines.length; j++) { - const line = lines[j]; - const trimmed = line.trim(); - if (trimmed.length === 0) continue; - if (!line.startsWith(' ')) return []; - const actionsMatch = line.match(/^(\s*)actions:\s*$/); - if (actionsMatch) { - actionsLine = j; - actionsIndent = actionsMatch[1]?.length ?? 0; - break; - } - const reasoningMatch = line.match(/^(\s*)reasoning:\s*$/); - if (reasoningMatch) { - reasoningLine = j; - reasoningIndent = reasoningMatch[1]?.length ?? 0; - break; - } - } - if (actionsLine === -1 && reasoningLine !== -1) { - for (let j = reasoningLine + 1; j < lines.length; j++) { - const line = lines[j]; - const trimmed = line.trim(); - if (trimmed.length === 0) continue; - const indent = line.match(/^(\s*)/)?.[1]?.length ?? 0; - if (indent <= reasoningIndent) break; - const actionsMatch = line.match(/^(\s*)actions:\s*$/); - if (actionsMatch) { - actionsLine = j; - actionsIndent = actionsMatch[1]?.length ?? 0; - break; - } - } - } - if (actionsLine === -1) return []; - - const result: SourceNodeTool[] = []; - let current: SourceNodeTool | undefined; - let currentEntryIndent = actionsIndent + 2; - for (let k = actionsLine + 1; k < lines.length; k++) { - const line = lines[k]; - const trimmed = line.trim(); - if (trimmed.length === 0) continue; - const indent = line.match(/^(\s*)/)?.[1]?.length ?? 0; - if (indent <= actionsIndent) break; - - const entryMatch = trimmed.match(/^([\w-]+):\s*@actions\.([\w-]+)\s*$/); - if (entryMatch) { - current = { - actionDefName: entryMatch[2], - llmInputs: [], - boundInputs: {}, - }; - currentEntryIndent = indent; - result.push(current); - continue; - } - - const withMatch = trimmed.match(/^with\s+([\w-]+)\s*=\s*(.+)\s*$/); - if (withMatch && current && indent > currentEntryIndent) { - const key = withMatch[1]; - const raw = withMatch[2].trim(); - if (raw === '...') { - current.llmInputs.push(key); - } else { - const quoted = raw.match(/^(['"])(.*)\1$/); - current.boundInputs[key] = quoted ? quoted[2] : raw; - } - } - } - return result; - } - - return []; -} - -function extractConfigString(value: unknown): string | undefined { - const s = extractString(value); - if (s !== undefined && s !== '[object Object]') return s; - const plain = toPlainData(value); - if ( - typeof plain === 'string' || - typeof plain === 'number' || - typeof plain === 'boolean' - ) { - return String(plain); - } - return undefined; -} - -function parseEnumYamlListString(value: string): string[] | undefined { - const lines = value.split(/\r?\n/).filter(line => line.trim().length > 0); - if (lines.length === 0) return undefined; - - const parsed: string[] = []; - for (const line of lines) { - const match = line.match(/^\s*-\s+(.+)\s*$/); - if (!match) return undefined; - const raw = match[1].trim(); - const quoted = raw.match(/^(['"])(.*)\1$/); - parsed.push(quoted ? quoted[2] : raw); - } - return parsed.length > 0 ? parsed : undefined; -} - -function normalizeOutputStructureEnums(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(v => normalizeOutputStructureEnums(v)); - } - if (!value || typeof value !== 'object') return value; - - const rec = value as Record; - const out: Record = {}; - for (const [key, fieldValue] of Object.entries(rec)) { - if (key === 'enum' && typeof fieldValue === 'string') { - out[key] = parseEnumYamlListString(fieldValue) ?? fieldValue; - continue; - } - out[key] = normalizeOutputStructureEnums(fieldValue); - } - return out; -} - -/** - * Resolve the LLM ref for an agent node and optionally attach - * `output-structure-ref` when outputs are declared on the node. - * - * When the node omits `llm`, uses `defaultLlmRef` from `config.default_llm` if set, - * otherwise falls back to the connection name `"default"`. - */ -function resolveLLMRef( - nodeEntry: Record, - nodeType: string, - nodeId: string, - llmEntries: Map> | undefined, - outputStructures: Record>, - defaultLlmRef: string | undefined, - source: string | undefined, - llmNameAliases: Map | undefined -): LLMRef { - const explicitParsed = stripLlmRef(extractLlmFieldReference(nodeEntry.llm)); - const explicitSource = source - ? extractNodeLlmRefFromSource(source, nodeType, nodeId) - : undefined; - const explicit = explicitParsed ?? explicitSource; - const fromConfig = stripLlmRef(defaultLlmRef); - const providerName = - (explicit && (llmNameAliases?.get(explicit) ?? explicit)) ?? - (fromConfig && (llmNameAliases?.get(fromConfig) ?? fromConfig)) ?? - 'default'; - - const llmEntryKey = - (explicitParsed && - llmEntries && - llmEntries.has(explicitParsed) && - explicitParsed) || - (fromConfig && llmEntries && llmEntries.has(fromConfig) && fromConfig) || - [...(llmNameAliases?.entries() ?? [])].find( - ([parsed, canonical]) => - canonical === providerName && Boolean(llmEntries?.has(parsed)) - )?.[0] || - providerName; - - const configuration: Record = {}; - const llmConfig = llmEntries?.get(llmEntryKey); - if (llmConfig) { - const stringFields = [ - 'model', - 'reasoning_effort', - 'thinking_level', - 'response_logprobs', - ] as const; - const numberFields = [ - 'temperature', - 'top_p', - 'max_output_tokens', - 'thinking_budget', - 'top_logprobs', - ] as const; - - for (const field of stringFields) { - const value = extractConfigString(llmConfig[field]); - if (value !== undefined) { - configuration[field] = value; - } - } - for (const field of numberFields) { - const value = extractNumber(llmConfig[field]); - if (value !== undefined) { - configuration[field] = String(value); - } - } - } - - // If the node declares outputs, register it under a stable key and - // link it via llm.output-structure-ref (without mutating provider ref). - const outputStructure = - nodeType === 'generator' - ? ((nodeEntry.outputs as Record | undefined) ?? - undefined) - : ((nodeEntry.reasoning as Record | undefined) - ?.outputs as Record | undefined); - if (outputStructure) { - const normalizedNodeId = normalizeId(nodeId); - const outputStructureRef = `os_${normalizedNodeId}`; - // Extract the properties map as a plain dict for the runtime - const properties = outputStructure.properties as - | Record - | undefined; - if (properties) { - const plain = toPlainData(properties); - outputStructures[outputStructureRef] = - (normalizeOutputStructureEnums(plain) as Record) ?? - ({} as Record); - } else { - const plain = toPlainData(outputStructure); - outputStructures[outputStructureRef] = - (normalizeOutputStructureEnums(plain) as Record) ?? - ({} as Record); - } - return { - ref: providerName, - configuration, - 'output-structure-ref': outputStructureRef, - }; - } - - return { ref: providerName, configuration }; -} - -function buildAgentTools( - nodeTools: Map> | undefined, - actionDefs: Map> | undefined, - env: ExecuteVariableEnv, - source: string | undefined, - nodeType: string, - nodeName: string -): ToolUnion[] | null { - const sourceTools = - source !== undefined - ? parseNodeToolsFromSource(source, nodeType, nodeName) - : []; - if (!nodeTools && sourceTools.length === 0) return null; - - const tools: ToolUnion[] = []; - const parsedEntries: Array<{ - actionDefName: string; - bodyStatements: unknown[]; - fallbackLlmInputs: string[]; - fallbackBoundInputs: Record; - }> = []; - - for (const [, toolEntry] of nodeTools ?? []) { - const rawColinear = - toolEntry.value ?? - toolEntry.__colinear ?? - toolEntry.colinear ?? - toolEntry.__value; - const colinearRef = decomposeAtMemberExpression(rawColinear); - - let actionDefName: string | undefined; - if (colinearRef && colinearRef.namespace === 'actions') { - actionDefName = colinearRef.property; - } else { - const colinearValue = extractString(rawColinear); - if (colinearValue) { - actionDefName = colinearValue.startsWith('@actions.') - ? colinearValue.substring(9) - : colinearValue; - } - } - if (!actionDefName) continue; - - const body = (toolEntry.body as { statements?: unknown[] } | undefined) ?? { - statements: Array.isArray(toolEntry.statements) - ? toolEntry.statements - : [], - }; - parsedEntries.push({ - actionDefName, - bodyStatements: body?.statements ?? [], - fallbackLlmInputs: [], - fallbackBoundInputs: {}, - }); - } - - for (const st of sourceTools) { - if (parsedEntries.some(p => p.actionDefName === st.actionDefName)) continue; - parsedEntries.push({ - actionDefName: st.actionDefName, - bodyStatements: [], - fallbackLlmInputs: st.llmInputs, - fallbackBoundInputs: st.boundInputs, - }); - } - - for (const entry of parsedEntries) { - const actionDefName = entry.actionDefName; - if (actionDefs && actionDefs.has(actionDefName)) { - const actionDef = actionDefs.get(actionDefName)!; - const kind = extractString(actionDef.kind); - - if (kind === 'mcp:tool' || kind === 'a2a:send_message') { - const collected = collectToolInputs(entry, env, actionDef); - const tool = createCompiledAgentTool(kind, actionDefName, collected); - if (tool) tools.push(tool); - } - } - } - - return tools.length > 0 ? tools : null; -} - -/** - * 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']); - -/** - * Lowercase all JSON/dict-literal keys in an expression string so that - * HTTP header names comply with RFC 9110 case-insensitivity. - */ -export function lowercaseHttpHeaderKeys(expr: string): string { - return expr.replace( - /"([^"]+)"\s*:/g, - (_, key: string) => `"${key.toLowerCase()}":` - ); -} - -function collectToolInputs( - entry: { - bodyStatements: unknown[]; - fallbackLlmInputs: string[]; - fallbackBoundInputs: Record; - }, - env: ExecuteVariableEnv, - actionDef: Record -): CollectedToolInputs { - const llmInputs = [...entry.fallbackLlmInputs]; - const boundInputs = { ...entry.fallbackBoundInputs }; - - for (const stmt of entry.bodyStatements) { - if (!(stmt instanceof WithClause)) continue; - if (stmt.value instanceof Ellipsis) { - llmInputs.push(stmt.param); - continue; - } - - const compiled = compileExecuteExpression(stmt.value, env, 'run-body'); - boundInputs[stmt.param] = - stmt.param === 'http_headers' - ? lowercaseHttpHeaderKeys(compiled) - : compiled; - } - - const llmSeen = new Set(llmInputs); - for (const name of listActionDefInputNames(actionDef)) { - if (Object.prototype.hasOwnProperty.call(boundInputs, name)) continue; - if (llmSeen.has(name)) continue; - llmInputs.push(name); - llmSeen.add(name); - } - - return { llmInputs, boundInputs }; -} - -function createCompiledAgentTool( - kind: string, - actionDefName: string, - collected: CollectedToolInputs -): ToolUnion | null { - if (kind !== 'mcp:tool' && kind !== 'a2a:send_message') { - return null; - } - - const tool: MCPTool | A2ATool = { - type: kind === 'mcp:tool' ? 'mcp_tool' : 'a2a', - ref: `${actionDefName}-client`, - enabled: true, - }; - - if (Object.keys(collected.boundInputs).length > 0) { - tool['bound-inputs'] = collected.boundInputs; - } - if (collected.llmInputs.length > 0) { - tool['llm-inputs'] = collected.llmInputs; - } - - return tool; -} - -function buildHandoffTarget(target: string | null): HandoffAction[] { - if (!target) return []; - return [ - { - type: ObjectTypes.HANDOFF, - target: normalizeId(target), - }, - ]; -} - -/** - * Build the orchestration instructions wrapper. - * Mirrors _build_instructions() in the Python adaptor. - */ -function buildOrchestrationInstructions(instructions: string): string { - const parts: string[] = []; - - parts.push( - 'You are a task decomposition expert that analyzes user requests, ' + - 'identifies required sub-tasks, selects appropriate tools, and ' + - 'synthesizes final answers.\n' - ); - - parts.push( - '1. **Decompose** the query into atomic sub-tasks\n' + - '2. **Match** each sub-task to the appropriate tool below\n' + - '3. **Execute** tools in optimal sequence\n' + - '4. **Synthesize** results into final response\n' - ); - - parts.push( - 'Here is an Example of how to break down a user prompt\n' + - "**User Query:** 'Analyze Q2 earnings for Tesla and compare to Ford in EUR'\n" + - '**Sub-tasks:**\n' + - '1. Get Tesla financials (USD) → Financial Summary Tool\n' + - '2. Get Ford financials (USD) → Financial Summary Tool\n' + - '3. Convert USD figures to EUR → Currency Converter Tool\n' + - '4. Perform comparative analysis → Built-in Analysis Module\n' - ); - - parts.push( - "The User's instructions section contains directives " + - '*YOU MUST* follow when deciding which action to take next.\n\n' + - "### User's instructions\n\n" - ); - parts.push(normalizeTemplate(instructions)); - parts.push('\n'); - - parts.push( - '### Instructions for executing steps, selecting tools and generating output\n\n' + - '- Execute the list of steps in order. For each step, determine if ' + - 'invoking a tool is necessary\n' + - '- When you reach a step that requires a tool, look at the available ' + - 'tools and conversation history to determine the *single best tool* ' + - 'to call next.\n' - ); - - parts.push( - '### Constraints\n\n' + - '- Use the conversation history to avoid redundant tool calls and ' + - 'to track progress toward the goal.\n' - ); - - return parts.join('\n'); -} - -// ── Extract on_exit target ────────────────────────────────────────── - -function extractOnExitTarget(onExitProcedure: unknown): string | null { - if (!onExitProcedure) return null; - - // Prefer procedure-emitted text to avoid matching unrelated references that - // may exist in serialized AST internals. - const emitted = extractProcedureText(onExitProcedure); - const text = - emitted || (typeof onExitProcedure === 'string' ? onExitProcedure : ''); - - // Match only explicit transition targets. - const match = text.match(/transition\s+to\s+@(\w+)\.(\w[\w-]*)/i); - if (match) { - return match[2]; - } - return null; -} - -// ── System limits extraction ──────────────────────────────────────── - -function extractSystemLimits( - entry: Record -): NodeSystemLimits | undefined { - const reasoning = entry.reasoning as Record | undefined; - if (!reasoning) return undefined; - - const maxLoops = extractNumber(reasoning.max_number_of_loops); - const maxErrors = extractNumber(reasoning.max_consecutive_errors); - const timeout = extractNumber(reasoning.task_timeout_secs); - - if (maxLoops == null && maxErrors == null && timeout == null) { - return undefined; - } - - const limits: NodeSystemLimits = {}; - if (maxLoops != null) limits['max-reasoning-iterations'] = maxLoops; - if (maxErrors != null) limits['max-consecutive-errors'] = maxErrors; - if (timeout != null) limits['task-timeout-secs'] = timeout; - return limits; -} - -// ── Node builders ─────────────────────────────────────────────────── - -function buildOrchestrationNode( - name: string, - entry: Record, - isInitialNode: boolean, - llmEntries: Map> | undefined, - actionDefs: Map> | undefined, - outputStructures: Record>, - globalSystemInstructions: string, - defaultLlmRef: string | undefined, - source: string | undefined, - llmNameAliases: Map | undefined, - env: ExecuteVariableEnv -): Node[] { - const normalizedName = normalizeId(name); - const onExitTarget = resolveTarget( - extractOnExitTarget(entry.on_exit) ?? null - ); - - const systemInstructions = combineGlobalSystemInstructions( - globalSystemInstructions, - extractProcedureText( - (entry.system as Record | undefined)?.instructions - ) - ); - const prompt = extractProcedureText( - (entry.reasoning as Record | undefined)?.instructions - ); - - const agentNode: AgentNode = { - name: normalizedName, - label: extractString(entry.label) ?? null, - description: extractString(entry.description) ?? null, - type: ObjectTypes.AGENT, - llm: resolveLLMRef( - entry, - 'orchestrator', - name, - llmEntries, - outputStructures, - defaultLlmRef, - source, - llmNameAliases - ), - 'on-init': buildOnInit(isInitialNode), - 'system-prompt': normalizeTemplate(prompt), - 'focus-prompt': buildOrchestrationInstructions(systemInstructions), - tools: buildAgentTools( - (entry.reasoning as Record | undefined)?.actions as - | Map> - | undefined, - actionDefs, - env, - source, - 'orchestrator', - name - ), - 'after-reasoning': buildHandoffTarget(onExitTarget), - }; - const systemLimits = extractSystemLimits(entry); - if (systemLimits) agentNode['system-limits'] = systemLimits; - return [agentNode]; -} - -function buildReasoningNode( - name: string, - entry: Record, - isInitialNode: boolean, - llmEntries: Map> | undefined, - actionDefs: Map> | undefined, - outputStructures: Record>, - globalSystemInstructions: string, - defaultLlmRef: string | undefined, - source: string | undefined, - llmNameAliases: Map | undefined, - env: ExecuteVariableEnv -): Node[] { - const normalizedName = normalizeId(name); - const onExitTarget = resolveTarget( - extractOnExitTarget(entry.on_exit) ?? null - ); - - const systemInstructions = combineGlobalSystemInstructions( - globalSystemInstructions, - extractProcedureText( - (entry.system as Record | undefined)?.instructions - ) - ); - const prompt = extractProcedureText( - (entry.reasoning as Record | undefined)?.instructions - ); - - const agentNode: AgentNode = { - name: normalizedName, - label: extractString(entry.label) ?? null, - description: extractString(entry.description) ?? null, - type: ObjectTypes.AGENT, - llm: resolveLLMRef( - entry, - 'subagent', - name, - llmEntries, - outputStructures, - defaultLlmRef, - source, - llmNameAliases - ), - 'on-init': buildOnInit(isInitialNode), - 'system-prompt': normalizeTemplate(prompt), - 'focus-prompt': systemInstructions.trim() - ? normalizeTemplate(systemInstructions) - : null, - tools: buildAgentTools( - (entry.reasoning as Record | undefined)?.actions as - | Map> - | undefined, - actionDefs, - env, - source, - 'subagent', - name - ), - 'after-reasoning': buildHandoffTarget(onExitTarget), - }; - const systemLimits = extractSystemLimits(entry); - if (systemLimits) agentNode['system-limits'] = systemLimits; - return [agentNode]; -} - -function buildGenerateNode( - name: string, - entry: Record, - isInitialNode: boolean, - llmEntries: Map> | undefined, - outputStructures: Record>, - globalSystemInstructions: string, - defaultLlmRef: string | undefined, - source: string | undefined, - llmNameAliases: Map | undefined -): Node[] { - const normalizedName = normalizeId(name); - const onExitTarget = resolveTarget( - extractOnExitTarget(entry.on_exit) ?? null - ); - - const systemInstructions = combineGlobalSystemInstructions( - globalSystemInstructions, - extractProcedureText( - (entry.system as Record | undefined)?.instructions - ) - ); - const prompt = extractProcedureText(entry.prompt); - - const agentNode: AgentNode = { - name: normalizedName, - label: extractString(entry.label) ?? null, - description: extractString(entry.description) ?? null, - type: ObjectTypes.AGENT, - llm: resolveLLMRef( - entry, - 'generator', - name, - llmEntries, - outputStructures, - defaultLlmRef, - source, - llmNameAliases - ), - 'on-init': buildOnInit(isInitialNode), - 'system-prompt': normalizeTemplate(prompt), - 'focus-prompt': systemInstructions.trim() - ? normalizeTemplate(systemInstructions) - : null, - tools: null, - 'after-reasoning': buildHandoffTarget(onExitTarget), - }; - return [agentNode]; -} - -function buildExecuteNode( - name: string, - entry: Record, - isInitialNode: boolean, - actionDefs: Map> | undefined, - ast: Record -): Node[] { - const normalizedName = normalizeId(name); - const onExitTarget = resolveTarget( - extractOnExitTarget(entry.on_exit) ?? null - ); - - const compiledTools = compileExecuteDoProcedure( - entry.do, - actionDefs, - ast, - normalizedName - ); - const tools: ActionCallableReference[] = - compiledTools.length > 0 - ? compiledTools - : [{ ref: 'IdentityAction', 'state-updates': [] }]; - - const node: ActionNode = { - name: normalizedName, - type: ObjectTypes.ACTION, - label: extractString(entry.label) ?? null, - tools, - 'on-init': buildOnInit(isInitialNode), - 'on-exit': onExitTarget ? buildHandoffTarget(onExitTarget) : null, - 'add-tool-result-to-chat-history': false, - 'output-template': null, - }; - - return [node]; -} - -const SWITCH_TARGET_NAMESPACES = new Set([ - 'orchestrator', - 'subagent', - 'generator', - 'executor', - 'router', - 'echo', -]); - -function asSwitchTarget(value: unknown): string | undefined { - const candidates: unknown[] = [value]; - if (value && typeof value === 'object') { - const rec = value as Record; - if (rec.value !== undefined) candidates.push(rec.value); - } - - for (const candidate of candidates) { - const ref = decomposeAtMemberExpression(candidate); - if (ref && SWITCH_TARGET_NAMESPACES.has(ref.namespace)) { - return normalizeId(ref.property); - } - - const s = extractString(candidate); - if (s === undefined || s === '[object Object]') continue; - const m = s.match(/^@(\w+)\.([\w-]+)$/); - if (!m) continue; - if (!SWITCH_TARGET_NAMESPACES.has(m[1])) continue; - return normalizeId(m[2]); - } - return undefined; -} - -function asObjectList(value: unknown): Record[] { - if (Array.isArray(value)) { - return value.filter( - (v): v is Record => v != null && typeof v === 'object' - ); - } - if (value && typeof value === 'object' && Symbol.iterator in value) { - const out: Record[] = []; - for (const item of value as Iterable) { - const candidate = - Array.isArray(item) && item.length === 2 ? item[1] : item; - if (candidate && typeof candidate === 'object') { - out.push(candidate as Record); - } - } - return out; - } - if (value && typeof value === 'object') { - const rec = value as Record; - if (Array.isArray(rec.items)) { - return rec.items.filter( - (v): v is Record => v != null && typeof v === 'object' - ); - } - } - return []; -} - -function buildSwitchNode( - name: string, - entry: Record, - isInitialNode: boolean, - env: ExecuteVariableEnv -): Node[] { - const normalizedName = normalizeId(name); - - const onExit: HandoffAction[] = []; - - const routes = asObjectList(entry.routes); - for (const route of routes) { - if (!route || typeof route !== 'object') continue; - const r = route as Record; - const target = asSwitchTarget(r.target); - const whenRaw = extractString(r.when); - const when = - whenRaw && whenRaw !== '[object Object]' - ? whenRaw - : r.when && typeof r.when === 'object' - ? compileExecuteExpression(r.when as Expression, env, 'execute') - : undefined; - if (!target || !when || when === '[object Object]') { - continue; - } - onExit.push({ - type: ObjectTypes.HANDOFF, - target, - enabled: when.trim(), - }); - } - - const otherwiseBlock = entry.otherwise as Record | undefined; - if (otherwiseBlock && typeof otherwiseBlock === 'object') { - const otherwiseTarget = asSwitchTarget(otherwiseBlock.target); - if (otherwiseTarget) { - onExit.push({ - type: ObjectTypes.HANDOFF, - target: otherwiseTarget, - }); - } - } - - const node: ActionNode = { - name: normalizedName, - type: ObjectTypes.ACTION, - label: extractString(entry.label) ?? null, - tools: [], - 'on-init': buildOnInit(isInitialNode), - 'on-exit': onExit.length > 0 ? onExit : null, - 'add-tool-result-to-chat-history': false, - 'output-template': null, - }; - - return [node]; -} - -function buildEchoNode( - name: string, - entry: Record, - isInitialNode: boolean, - env: ExecuteVariableEnv -): Node[] { - const normalizedName = normalizeId(name); - const onExitTarget = resolveTarget( - extractOnExitTarget(entry.on_exit) ?? null - ); - - const tmpVar = `__${normalizedName}_value`; - let stateUpdateValue: string; - - if ( - entry.task != null && - typeof entry.task === 'object' && - '__kind' in entry.task - ) { - stateUpdateValue = compileExecuteExpression(entry.task as Expression, env); - } else { - const message = extractString(entry.message) ?? ''; - const outputJson = JSON.stringify({ - state: 'completed', - message: { - kind: 'text', - role: 'agent', - parts: [{ kind: 'text', text: message }], - }, - }); - stateUpdateValue = `template::${normalizeTemplate(outputJson)}`; - } - - const stateUpdates = [ - { [tmpVar]: stateUpdateValue }, - { - outputs: `add(state.outputs, "${normalizedName}", state.${tmpVar})`, - }, - ]; - - const node: ActionNode = { - name: normalizedName, - type: ObjectTypes.ACTION, - label: extractString(entry.label) ?? null, - tools: [ - { - ref: 'IdentityAction', - 'state-updates': stateUpdates, - }, - ], - 'on-init': buildOnInit(isInitialNode), - 'on-exit': onExitTarget ? buildHandoffTarget(onExitTarget) : null, - 'add-tool-result-to-chat-history': false, - 'output-template': null, - }; - - const echoDescription = extractString(entry.description); - if (echoDescription !== undefined && echoDescription !== '') { - node.description = echoDescription; - } - - return [node]; -} - -// ── _node_input tracking injection ────────────────────────────────── - -function isProducingNode(node: Node): boolean { - if (node.type === ObjectTypes.AGENT) return true; - if (node.type === ObjectTypes.ACTION) { - const action = node as ActionNode; - return action.tools.some(t => t.ref !== 'IdentityAction'); - } - return false; -} - -function nodeContainsNodeInputRef(node: Node): boolean { - return JSON.stringify(node).includes('state._node_input'); -} - -function appendHandoffBreadcrumb( - handoffs: HandoffActionUnion[], - sourceName: string -): void { - for (const h of handoffs) { - if ((h as HandoffAction).type !== ObjectTypes.HANDOFF) continue; - const handoff = h as HandoffAction; - const updates = handoff['state-updates'] ?? []; - updates.push({ _handoff_source: `'${sourceName}'` }); - handoff['state-updates'] = updates; - } -} - -function prependNodeInputLookup(node: Node): void { - const lookup: ActionCallableReference = { - type: ObjectTypes.ACTION, - ref: 'IdentityAction', - 'state-updates': [ - { _node_input: "get(system.node_outputs, state._handoff_source, '')" }, - ], - }; - const existing: HandoffActionUnion[] = - (node as AgentNode | ActionNode)['on-init'] ?? []; - (node as AgentNode | ActionNode)['on-init'] = [lookup, ...existing]; -} - -/** - * Post-process compiled nodes to inject _node_input tracking plumbing. - * - * 1. Producing nodes get a `_handoff_source` breadcrumb on every handoff. - * 2. Nodes whose compiled output references `state._node_input` get an - * on-init IdentityAction that resolves the deferred lookup. - * - * Returns true if any injection occurred (caller should add state variables). - */ -export function injectNodeInputTracking(nodes: Node[]): boolean { - let injected = false; - - for (const node of nodes) { - if (!isProducingNode(node)) continue; - - const agentNode = node as AgentNode; - if (agentNode['after-reasoning']) { - appendHandoffBreadcrumb(agentNode['after-reasoning'], node.name); - injected = true; - } - - const actionNode = node as ActionNode; - if (actionNode['on-exit']) { - appendHandoffBreadcrumb(actionNode['on-exit'], node.name); - injected = true; - } - } - - for (const node of nodes) { - if (nodeContainsNodeInputRef(node)) { - prependNodeInputLookup(node); - injected = true; - } - } - - return injected; -} - -// ── Public API ────────────────────────────────────────────────────── - -/** - * Build all nodes from parsed AST blocks. - * Iterates blocks in definition order and creates the appropriate node types. - */ -export function buildNodes( - ast: Record, - llmEntries: Map> | undefined, - actionDefs: Map> | undefined, - initialNode: string, - /** Original source — used by source-based fallbacks (llm/tool extraction). */ - source?: string, - llmNameAliases?: Map -): { - nodes: Node[]; - outputStructures: Record>; -} { - const outputStructures: Record> = {}; - const result: Node[] = []; - const env = collectExecuteVariableEnv(ast); - const globalSystemInstructions = extractGlobalSystemInstructions(ast); - const config = ast.config as Record | undefined; - const defaultLlmFromConfig = - extractLlmFieldReference(config?.default_llm) ?? - (source ? extractDefaultLlmRefFromSource(source) : undefined); - - const nodeBlocks: Array<{ - type: string; - entries: Map>; - }> = []; - - // Collect all node-type blocks (NamedMap or Map — not `instanceof Map`) - for (const nodeType of [ - 'orchestrator', - 'subagent', - 'generator', - 'executor', - 'router', - 'echo', - ]) { - const block = ast[nodeType]; - for (const [name, entry] of iterateCollection(block)) { - nodeBlocks.push({ - type: nodeType, - entries: new Map([[name, entry]]), - }); - } - } - - for (const { type, entries } of nodeBlocks) { - for (const [name, entry] of entries) { - let nodes: Node[]; - const isInitialNode = normalizeId(name) === normalizeId(initialNode); - - switch (type) { - case 'orchestrator': - nodes = buildOrchestrationNode( - name, - entry, - isInitialNode, - llmEntries, - actionDefs, - outputStructures, - globalSystemInstructions, - defaultLlmFromConfig, - source, - llmNameAliases, - env - ); - break; - case 'subagent': - nodes = buildReasoningNode( - name, - entry, - isInitialNode, - llmEntries, - actionDefs, - outputStructures, - globalSystemInstructions, - defaultLlmFromConfig, - source, - llmNameAliases, - env - ); - break; - case 'generator': - nodes = buildGenerateNode( - name, - entry, - isInitialNode, - llmEntries, - outputStructures, - globalSystemInstructions, - defaultLlmFromConfig, - source, - llmNameAliases - ); - break; - case 'executor': - nodes = buildExecuteNode(name, entry, isInitialNode, actionDefs, ast); - break; - case 'router': - nodes = buildSwitchNode(name, entry, isInitialNode, env); - break; - case 'echo': - nodes = buildEchoNode(name, entry, isInitialNode, env); - break; - default: - continue; - } - - result.push(...nodes); - } - } - - return { - nodes: result, - outputStructures, - }; -} - -function primitiveTypeString(type: Expression): string { - if (type instanceof Identifier) return type.name; - if ( - type instanceof SubscriptExpression && - type.object instanceof Identifier - ) { - return `${type.object.name}[]`; - } - return 'string'; -} - -function defaultFromVariableExpression(expr: Expression | undefined): unknown { - if (expr === undefined) return undefined; - if (expr instanceof StringLiteral) return expr.value; - if (expr instanceof NumberLiteral) return expr.value; - if (expr instanceof BooleanLiteral) return expr.value; - if (expr instanceof NoneLiteral) return null; - return undefined; -} - -function defaultForDataType(dataType: string): unknown { - if (dataType === 'object') return {}; - if (dataType.endsWith('[]')) return []; - if (dataType === 'number') return 0; - if (dataType === 'boolean') return false; - return ''; -} - -/** - * Build state variable entries from the `variables:` block (excluding reserved `outputs`). - */ -function buildUserDeclaredStateVariables( - ast: Record -): StateVariable[] { - const result: StateVariable[] = []; - const vars = ast.variables; - if (!isNamedMap(vars)) return result; - - for (const [name, entry] of vars) { - if (!(entry instanceof VariableDeclarationNode)) continue; - const n = normalizeId(name); - if (n === 'outputs') continue; - - const dataType = primitiveTypeString(entry.type); - const explicit = defaultFromVariableExpression(entry.defaultValue); - const defaultVal = - explicit !== undefined ? explicit : defaultForDataType(dataType); - - result.push({ - name: n, - label: name, - 'data-type': dataType, - description: '', - default: defaultVal, - }); - } - - return result; -} - -/** - * Build graph state variables: built-in `outputs` map plus declarations from `variables:`. - */ -export function buildStateVariables( - ast: Record -): StateVariable[] { - const outputs: StateVariable = { - name: 'outputs', - label: 'Node outputs', - 'data-type': 'object', - description: - 'Map of action/execute node outputs in state namespace (agent outputs are in system.node_outputs)', - default: {}, - }; - - const user = buildUserDeclaredStateVariables(ast); - return [outputs, ...user.filter(v => v.name !== 'outputs')]; -} - -/** - * Resolve the initial node from the trigger's on_message transition target. - */ -export function resolveInitialNode( - triggers: Map> -): string { - const [, triggerEntry] = triggers.entries().next().value as [ - string, - Record, - ]; - const transitionRef = extractTransitionReference(triggerEntry.on_message); - const targetMatch = transitionRef.match( - /^@([A-Za-z_][\w]*)\.([A-Za-z0-9_-]+)$/ - )!; - return normalizeId(targetMatch[2]); -} - -/** - * Collect echo node names that are a2a:response type. - */ -export function collectResponseNodeNames( - echoEntries: Map> | undefined -): string[] { - const names = new Set(); - if (!echoEntries) return []; - - for (const [name, entry] of echoEntries) { - const kind = extractString((entry as Record).kind); - if (kind === 'a2a:response') { - names.add(normalizeId(name)); - } - } - - return [...names]; -} diff --git a/dialect/agentfabric/src/compiler/build-providers.ts b/dialect/agentfabric/src/compiler/build-providers.ts deleted file mode 100644 index 318ff77e..00000000 --- a/dialect/agentfabric/src/compiler/build-providers.ts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * Build LLMProvider[] and InvokableClient[] from parsed AST. - * Mirrors _get_llm_providers() and _get_invokable_clients() in the Python adaptor. - */ - -import type { LLMProvider, InvokableClient } from './service-types.js'; -import type { AgentFabricCompilerContext } from './compiler-context.js'; -import { - extractNumber, - extractString, - iterateCollection, - toPlainData, -} from './utils.js'; - -function stripConnectionPrefix(target: string | undefined): string | undefined { - if (!target) return undefined; - return target.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, ''); -} - -function safeExtractString(value: unknown): string | undefined { - const s = extractString(value); - if (s === undefined || s === '[object Object]') return undefined; - return normalizeQuoted(s); -} - -function normalizeQuoted(s: string): string { - let out = s.trim(); - out = out.replace(/^\\?['"]/, ''); - out = out.replace(/\\?['"]$/, ''); - return out; -} - -function extractHeadersMap( - headersValue: unknown -): Record | undefined { - const byCollection = iterateCollection(headersValue); - if (byCollection.length > 0) { - const out: Record = {}; - for (const [headerName, headerEntry] of byCollection) { - let headerRaw = safeExtractString( - (headerEntry as Record).__colinear ?? - (headerEntry as Record).colinear - ); - if (headerRaw === undefined) { - const plain = toPlainData(headerEntry); - if ( - typeof plain === 'string' || - typeof plain === 'number' || - typeof plain === 'boolean' - ) { - headerRaw = String(plain); - } - } - out[normalizeQuoted(headerName)] = headerRaw ?? null; - } - return out; - } - - if (headersValue && typeof headersValue === 'object') { - const hv = headersValue as Record; - const rawEntries = hv.entries; - if (Array.isArray(rawEntries)) { - const out: Record = {}; - for (const item of rawEntries) { - if (!item || typeof item !== 'object') continue; - const kv = item as Record; - const plainKey = toPlainData(kv.key); - const plainValue = toPlainData(kv.value); - const key = - safeExtractString(kv.key) ?? - (typeof plainKey === 'string' || - typeof plainKey === 'number' || - typeof plainKey === 'boolean' - ? String(plainKey) - : undefined) ?? - safeExtractString(kv.name) ?? - safeExtractString(kv.__key); - const value = - safeExtractString(kv.value) ?? - (typeof plainValue === 'string' || - typeof plainValue === 'number' || - typeof plainValue === 'boolean' - ? String(plainValue) - : undefined) ?? - safeExtractString(kv.__value); - if (key !== undefined) out[normalizeQuoted(key)] = value ?? null; - } - if (Object.keys(out).length > 0) return out; - } - } - - const plainHeaders = toPlainData(headersValue); - if ( - plainHeaders && - typeof plainHeaders === 'object' && - !Array.isArray(plainHeaders) - ) { - const out: Record = {}; - for (const [headerName, headerValue] of Object.entries(plainHeaders)) { - if (headerName === 'entries' && Array.isArray(headerValue)) continue; - out[normalizeQuoted(headerName)] = - headerValue === null || headerValue === undefined - ? null - : normalizeQuoted(String(headerValue)); - } - if (Object.keys(out).length > 0) return out; - } - - return undefined; -} - -function lowercaseHeaderKeys( - headers: Record | undefined -): Record | undefined { - if (!headers) return undefined; - const out: Record = {}; - for (const [key, value] of Object.entries(headers)) { - out[key.toLowerCase()] = value; - } - return out; -} - -export function buildLLMProviders( - llmEntries: Map> | undefined, - llmNameAliases: Map | undefined, - _ctx: AgentFabricCompilerContext -): LLMProvider[] { - const providers: LLMProvider[] = []; - - if (!llmEntries) return providers; - - for (const [name, entry] of llmEntries) { - const providerName = llmNameAliases?.get(name) ?? name; - const kind = extractString((entry as Record).kind) ?? ''; - const normalizedKind = kind.toLowerCase(); - - let platform: string; - if (normalizedKind.startsWith('openai')) { - platform = 'openai'; - } else if (normalizedKind.startsWith('gemini')) { - platform = 'gemini'; - } else { - platform = normalizedKind; - } - - const target = extractString((entry as Record).target); - const connection = stripConnectionPrefix(target); - - const headersValue = (entry as Record).headers; - const headers = lowercaseHeaderKeys(extractHeadersMap(headersValue)); - - const timeout = extractNumber((entry as Record).timeout); - const apiKey = extractString((entry as Record).api_key); - - const metadata: LLMProvider['metadata'] = { - platform, - connection, - }; - if (headers !== undefined) metadata.headers = headers; - if (timeout !== undefined) metadata.timeout = timeout; - if (apiKey !== undefined) metadata.api_key = apiKey; - - providers.push({ - name: providerName, - description: `LLM provider: ${providerName}`, - metadata, - }); - } - - return providers; -} - -export function buildInvokableClients( - toolDefs: Map> | undefined, - _ctx: AgentFabricCompilerContext -): InvokableClient[] { - const clients: InvokableClient[] = []; - - function buildToolClientBase( - name: string, - type: InvokableClient['type'], - metadata: Record, - label?: string - ): InvokableClient { - return { - name: `${name}-client`, - type, - label: label ?? name, - metadata, - }; - } - - function buildMcpToolClient( - name: string, - connection: string, - display: { label?: string; description?: string }, - toolName?: string - ): InvokableClient { - const metadata: Record = { - description: display.description ?? `MCP tool: ${name}`, - connection, - transport: 'streamable-http', - }; - if (toolName) metadata.tool_name = toolName; - return buildToolClientBase(name, 'mcp_tool', metadata, display.label); - } - - function buildA2AClient( - name: string, - connection: string, - display: { label?: string; description?: string } - ): InvokableClient { - const metadata: Record = { connection }; - if (display.description !== undefined) { - metadata.description = display.description; - } - return buildToolClientBase(name, 'a2a', metadata, display.label); - } - - if (toolDefs) { - for (const [name, def] of toolDefs) { - const rec = def as Record; - const kind = extractString(rec.kind); - const target = extractString(rec.target) ?? ''; - const connection = stripConnectionPrefix(target) ?? ''; - const userLabel = extractString(rec.label); - const userDescription = extractString(rec.description); - const display = { - label: userLabel, - description: userDescription, - }; - - if (kind === 'mcp:tool') { - const toolName = extractString(rec.tool_name); - clients.push(buildMcpToolClient(name, connection, display, toolName)); - } else if (kind === 'a2a:send_message') { - clients.push(buildA2AClient(name, connection, display)); - } - } - } - - // Always include built-in internal client - clients.push({ - name: 'in-built', - type: 'internal-action', - label: 'Internal Actions', - metadata: { - description: 'Built-in actions for state management', - }, - }); - - return clients; -} diff --git a/dialect/agentfabric/src/compiler/compile-execute-do.ts b/dialect/agentfabric/src/compiler/compile-execute-do.ts deleted file mode 100644 index c8b2b426..00000000 --- a/dialect/agentfabric/src/compiler/compile-execute-do.ts +++ /dev/null @@ -1,460 +0,0 @@ -/** - * Compile `execute.do` procedures into ActionCallableReference[] for the graph. - * Supports `set` (IdentityAction) and `run @actions.*` (action definitions) with the same - * expression shapes used in AgentScript: @variables.*, @request.*, and - * @..output (for graph node outputs). - */ - -import type { - Expression, - Statement, - TemplatePart, -} from '@agentscript/language'; -import { - AtIdentifier, - BinaryExpression, - BooleanLiteral, - CallExpression, - ComparisonExpression, - DictLiteral, - Ellipsis, - Identifier, - ListLiteral, - MemberExpression, - NoneLiteral, - NumberLiteral, - RunStatement, - SetClause, - SpreadExpression, - StringLiteral, - SubscriptExpression, - TemplateExpression, - TemplateInterpolation, - TemplateText, - TernaryExpression, - UnaryExpression, - WithClause, - decomposeAtMemberExpression, - decomposeMemberExpression, - isNamedMap, -} from '@agentscript/language'; -import type { ActionCallableReference } from './unified-agent-specification.js'; -import { ObjectTypes } from './unified-agent-specification.js'; -import { normalizeId, extractString } from './utils.js'; -import { AgentFabricSchemaInfo } from '../schema.js'; -import { lowercaseHttpHeaderKeys } from './build-nodes.js'; - -export interface ExecuteVariableEnv { - /** Mutable variable names (normalized snake_case). */ - mutable: ReadonlySet; - /** Linked variable names (read from external context). */ - linked: ReadonlySet; -} - -/** - * - `execute`: top-level `executor.do` (previous-node data lives in `state.outputs`). - * - `run-body`: inside `run @actions.*` — `@outputs.*` refers to the action invocation result. - */ -export type ExecuteExpressionMode = 'execute' | 'run-body'; - -const NAMESPACED_FUNCTION_NAMES: ReadonlySet = new Set( - Object.keys(AgentFabricSchemaInfo.namespacedFunctions!) -); - -function isA2aNamespaceCall(expr: CallExpression): boolean { - if (!(expr.func instanceof MemberExpression)) return false; - const ref = - decomposeAtMemberExpression(expr.func) ?? - decomposeMemberExpression(expr.func, NAMESPACED_FUNCTION_NAMES); - return ref?.namespace === 'a2a'; -} - -const SYSTEM_NODE_OUTPUT_NAMESPACES = new Set([ - 'orchestrator', - 'subagent', - 'generator', -]); - -const NODE_OUTPUTS_RE = /^system\.node_outputs\['[^']+'\]$/; - -/** - * Wrap a bare `system.node_outputs['X']` reference with `parse_json()` so that - * subsequent attribute / subscript access works at runtime (node_outputs values - * are JSON strings, not dicts). Bare references without further access are - * left unchanged — they're used as whole string values. - */ -function wrapNodeOutputParseJson(compiled: string): string { - return NODE_OUTPUTS_RE.test(compiled) ? `parse_json(${compiled})` : compiled; -} - -const ALL_NODE_NAMESPACES = new Set([ - 'orchestrator', - 'subagent', - 'generator', - 'executor', - 'router', - 'echo', -]); - -/** - * Collect variable namespaces from the parsed `variables:` block. - */ -export function collectExecuteVariableEnv( - ast: Record -): ExecuteVariableEnv { - const mutable = new Set(); - const linked = new Set(); - - const vars = ast.variables; - if (!isNamedMap(vars)) { - return { mutable, linked }; - } - - for (const [name, entry] of vars) { - if (entry == null || typeof entry !== 'object') continue; - const e = entry as Record; - const mod = e.modifier as { name?: string } | undefined; - const key = normalizeId(name); - if (mod?.name === 'linked') { - linked.add(key); - } else { - // mutable or unmarked — treat as assignable state for execute - mutable.add(key); - } - } - - return { mutable, linked }; -} - -function getProcedureStatements(doValue: unknown): Statement[] { - if (doValue == null || typeof doValue !== 'object') return []; - const rec = doValue as Record; - if (!Array.isArray(rec.statements)) return []; - return rec.statements as Statement[]; -} - -/** - * Compile an expression for IdentityAction / tool state-updates (Python-style runtime strings). - */ -export function compileExecuteExpression( - expr: Expression, - env: ExecuteVariableEnv, - mode: ExecuteExpressionMode = 'execute' -): string { - return compileExpr(expr, env, mode); -} - -function compileExpr( - expr: Expression, - env: ExecuteVariableEnv, - mode: ExecuteExpressionMode -): string { - if (expr instanceof MemberExpression) { - const objectRef = decomposeAtMemberExpression(expr.object as Expression); - if ( - objectRef?.namespace === 'request' && - objectRef.property === 'headers' - ) { - return `state.request.headers['${expr.property.toLowerCase()}']`; - } - } - - if ( - expr instanceof MemberExpression && - expr.property === 'output' && - expr.object instanceof MemberExpression - ) { - const nodeRef = decomposeAtMemberExpression(expr.object); - if (nodeRef && nodeRef.namespace === 'executor') { - return `state.outputs['${normalizeId(nodeRef.property)}']`; - } - if (nodeRef && SYSTEM_NODE_OUTPUT_NAMESPACES.has(nodeRef.namespace)) { - return `system.node_outputs['${normalizeId(nodeRef.property)}']`; - } - } - - if ( - expr instanceof MemberExpression && - expr.property === 'input' && - expr.object instanceof MemberExpression - ) { - const nodeRef = decomposeAtMemberExpression(expr.object); - if (nodeRef && ALL_NODE_NAMESPACES.has(nodeRef.namespace)) { - return 'state._node_input'; - } - } - - if (expr instanceof MemberExpression) { - // Unify @namespace.property and bare namespace.property into the same path. - const decomposed = - decomposeAtMemberExpression(expr) ?? - decomposeMemberExpression(expr, NAMESPACED_FUNCTION_NAMES); - if (decomposed) { - const { namespace, property } = decomposed; - const prop = normalizeId(property); - - switch (namespace) { - case 'variables': { - if (env.linked.has(prop)) { - return `variables['${prop}']`; - } - return `state.${prop}`; - } - case 'outputs': - return `result.${prop}`; - case 'request': - return `state.request.${prop}`; - case 'a2a': - return `a2a_${prop}`; - default: { - const obj = compileExpr(expr.object as Expression, env, mode); - return `${wrapNodeOutputParseJson(obj)}.${expr.property}`; - } - } - } - - const obj = compileExpr(expr.object as Expression, env, mode); - if (expr.property === 'length') { - return `len(${wrapNodeOutputParseJson(obj)})`; - } - return `${wrapNodeOutputParseJson(obj)}.${expr.property}`; - } - - if (expr instanceof SubscriptExpression) { - const objectRef = decomposeAtMemberExpression(expr.object as Expression); - if ( - objectRef?.namespace === 'request' && - objectRef.property === 'headers' - ) { - const index = compileExpr(expr.index as Expression, env, mode); - return `state.request.headers[lower(${index})]`; - } - if (expr.object instanceof AtIdentifier && expr.object.name === 'outputs') { - const index = compileExpr(expr.index as Expression, env, mode); - if (mode === 'run-body') { - return `result[${index}]`; - } - return `state.outputs[${index}]`; - } - const obj = compileExpr(expr.object as Expression, env, mode); - const index = compileExpr(expr.index as Expression, env, mode); - return `${wrapNodeOutputParseJson(obj)}[${index}]`; - } - - if (expr instanceof Identifier) { - return expr.name; - } - - if (expr instanceof StringLiteral) { - return JSON.stringify(expr.value); - } - - if (expr instanceof NumberLiteral) { - return String(expr.value); - } - - if (expr instanceof BooleanLiteral) { - return expr.value ? 'True' : 'False'; - } - - if (expr instanceof NoneLiteral) { - return 'None'; - } - - if (expr instanceof UnaryExpression) { - const operand = compileExpr(expr.operand, env, mode); - if (expr.operator === 'not') { - return `not ${operand}`; - } - return `${expr.operator}${operand}`; - } - - if (expr instanceof BinaryExpression) { - const left = compileExpr(expr.left, env, mode); - const right = compileExpr(expr.right, env, mode); - return `${left} ${expr.operator} ${right}`; - } - - if (expr instanceof ComparisonExpression) { - const left = compileExpr(expr.left, env, mode); - const right = compileExpr(expr.right, env, mode); - return `${left} ${expr.operator} ${right}`; - } - - if (expr instanceof TernaryExpression) { - const consequence = compileExpr(expr.consequence, env, mode); - const condition = compileExpr(expr.condition, env, mode); - const alternative = compileExpr(expr.alternative, env, mode); - return `${consequence} if ${condition} else ${alternative}`; - } - - if (expr instanceof CallExpression) { - const func = compileExpr(expr.func as Expression, env, mode); - if ( - isA2aNamespaceCall(expr) && - expr.args.length === 1 && - expr.args[0] instanceof DictLiteral - ) { - const dict = expr.args[0]; - const kwargs = dict.entries - .map( - e => - `${compileExpr(e.key, env, mode)}=${compileExpr(e.value, env, mode)}` - ) - .join(', '); - return `${func}(${kwargs})`; - } - const args = expr.args - .map((a: Expression) => compileExpr(a, env, mode)) - .join(', '); - return `${func}(${args})`; - } - - if (expr instanceof ListLiteral) { - const elements = expr.elements - .map((e: Expression) => compileExpr(e, env, mode)) - .join(', '); - return `[${elements}]`; - } - - if (expr instanceof DictLiteral) { - const pairs = expr.entries - .map( - e => - `${compileExpr(e.key, env, mode)}: ${compileExpr(e.value, env, mode)}` - ) - .join(', '); - return `{${pairs}}`; - } - - if (expr instanceof TemplateExpression) { - if (expr.parts.length === 0) { - // Parser can represent "" as an empty template expression; runtime expects - // a valid Python string literal expression, not an empty expression. - return '""'; - } - return expr.parts - .map((part: TemplatePart) => compileTemplatePart(part, env, mode)) - .join(''); - } - - if (expr instanceof Ellipsis) { - return '...'; - } - - if (expr instanceof SpreadExpression) { - return `*${compileExpr(expr.expression, env, mode)}`; - } - - return ''; -} - -function compileTemplatePart( - part: TemplatePart, - env: ExecuteVariableEnv, - mode: ExecuteExpressionMode -): string { - if (part instanceof TemplateText) { - return part.value; - } - if (part instanceof TemplateInterpolation) { - const compiled = compileExpr(part.expression, env, mode); - const normalized = compiled.replace(/\brequest\./g, 'state.request.'); - return `{{${normalized}}}`; - } - return ''; -} - -function actionDefRef(actionDefName: string): string { - return `${actionDefName}-action`; -} - -/** - * Compile `execute` node's `do` procedure into ordered tool references. - */ -export function compileExecuteDoProcedure( - doValue: unknown, - actionDefs: Map> | undefined, - ast: Record, - executeNodeName: string -): ActionCallableReference[] { - const env = collectExecuteVariableEnv(ast); - const statements = getProcedureStatements(doValue); - const tools: ActionCallableReference[] = []; - const pendingStateUpdates: Array> = []; - - const flushPendingIdentityAction = (): void => { - if (pendingStateUpdates.length === 0) return; - tools.push({ - type: ObjectTypes.ACTION, - ref: 'IdentityAction', - 'state-updates': [...pendingStateUpdates], - }); - pendingStateUpdates.length = 0; - }; - - for (const stmt of statements) { - if (stmt instanceof SetClause) { - const varName = normalizeId( - decomposeAtMemberExpression(stmt.target)!.property - ); - const valueExpr = compileExecuteExpression(stmt.value, env, 'execute'); - pendingStateUpdates.push({ [varName]: valueExpr }); - continue; - } - - if (stmt instanceof RunStatement) { - flushPendingIdentityAction(); - const actionDefName = normalizeId( - decomposeAtMemberExpression(stmt.target)!.property - ); - - const boundInputs: Record = {}; - const stateUpdates: Array> = []; - - for (const child of stmt.body) { - if (child instanceof WithClause) { - const compiled = compileExecuteExpression( - child.value, - env, - 'execute' - ); - boundInputs[child.param] = - child.param === 'http_headers' - ? lowercaseHttpHeaderKeys(compiled) - : compiled; - } else if (child instanceof SetClause) { - const key = normalizeId( - decomposeAtMemberExpression(child.target)!.property - ); - stateUpdates.push({ - [key]: compileExecuteExpression(child.value, env, 'run-body'), - }); - } - } - - const actionDef = actionDefs!.get(actionDefName)!; - const resultField = - extractString(actionDef.kind) === 'mcp:tool' ? 'content' : 'result'; - stateUpdates.push({ - outputs: `add(state.outputs, "${executeNodeName}", result["${resultField}"])`, - }); - - const action: ActionCallableReference = { - type: ObjectTypes.ACTION, - ref: actionDefRef(actionDefName), - }; - if (Object.keys(boundInputs).length > 0) { - action['bound-inputs'] = boundInputs; - } - if (stateUpdates.length > 0) { - action['state-updates'] = stateUpdates; - } - tools.push(action); - continue; - } - } - - flushPendingIdentityAction(); - return tools; -} diff --git a/dialect/agentfabric/src/compiler/compile.ts b/dialect/agentfabric/src/compiler/compile.ts deleted file mode 100644 index 20ab5125..00000000 --- a/dialect/agentfabric/src/compiler/compile.ts +++ /dev/null @@ -1,218 +0,0 @@ -/** - * Main compile() function — transforms parsed AgentFabric AST into AgentGraph. - * Mirrors the UnifiedAgentSpecificationAdaptor._adapt() flow from the Python adaptor. - */ - -import type { AgentGraph, AgentGraphTrigger } from './agent-graph.js'; -import type { UnifiedAgentSpecification } from './unified-agent-specification.js'; -import type { CompilerDiagnostic } from './compiler-context.js'; -import { AgentFabricCompilerContext } from './compiler-context.js'; -import { buildDefinitions } from './build-definitions.js'; -import { buildLLMProviders, buildInvokableClients } from './build-providers.js'; -import { - buildNodes, - buildStateVariables, - resolveInitialNode, - collectResponseNodeNames, - injectNodeInputTracking, -} from './build-nodes.js'; -import { extractString, extractTransitionReference } from './utils.js'; - -export interface CompileResult { - output: AgentGraph; - diagnostics: CompilerDiagnostic[]; -} - -/** Optional original source text (used by source-based fallbacks, e.g. llm/tool extraction). */ -export interface CompileOptions { - source?: string; -} - -const SCHEMA_VERSION = '2.0.0'; - -function parseTriggerTarget(target: string | undefined): { - namespace: string; - target_id: string; -} { - if (!target) return { namespace: '', target_id: '' }; - const trimmed = target.trim(); - const match = trimmed.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/?#]+)/); - if (!match) return { namespace: '', target_id: '' }; - const namespace = match[1]; - const authority = match[2]; - return { - namespace, - target_id: authority.split(':')[0] ?? '', - }; -} - -function buildCompiledTrigger( - triggers: Map> | undefined -): AgentGraphTrigger | null { - if (!triggers || triggers.size === 0) return null; - const [triggerId, triggerEntry] = triggers.entries().next().value as [ - string, - Record, - ]; - const kind = extractString(triggerEntry.kind) ?? 'a2a'; - if (kind !== 'a2a') return null; - const parsedTarget = parseTriggerTarget(extractString(triggerEntry.target)); - - return { - id: triggerId, - kind: 'a2a', - namespace: parsedTarget.namespace, - target_id: parsedTarget.target_id, - on_message: { - transition_to: extractTransitionReference(triggerEntry.on_message), - }, - }; -} - -function extractLlmNamesFromSource(source: string): string[] { - const lines = source.split(/\r?\n/); - const names: string[] = []; - let inLlmBlock = false; - for (const line of lines) { - const trimmed = line.trim(); - if (!inLlmBlock) { - if (trimmed === 'llm:') inLlmBlock = true; - continue; - } - if (trimmed.length === 0) continue; - if (!line.startsWith(' ')) break; - if (line.startsWith(' ')) continue; - const m = line.match(/^\s{2}([^:]+):\s*$/); - if (m) names.push(m[1].trim()); - } - return names; -} - -function createLlmNameAliases( - llmEntries: Map> | undefined, - source: string | undefined -): Map { - const aliases = new Map(); - if (!llmEntries) return aliases; - if (!source) { - for (const [name] of llmEntries) aliases.set(name, name); - return aliases; - } - const sourceNames = extractLlmNamesFromSource(source); - let i = 0; - for (const [name] of llmEntries) { - aliases.set(name, sourceNames[i] ?? name); - i += 1; - } - return aliases; -} - -export function compile( - ast: Record, - options?: CompileOptions -): CompileResult { - const ctx = new AgentFabricCompilerContext(); - - // Extract top-level blocks from the parsed AST - const config = ast.config as Record | undefined; - const llmEntries = ast.llm as - | Map> - | undefined; - const actionDefs = ast.actions as - | Map> - | undefined; - const triggers = ast.trigger as - | Map> - | undefined; - const echoEntries = ast.echo as - | Map> - | undefined; - - const llmNameAliases = createLlmNameAliases(llmEntries, options?.source); - - // 1. Build LLM providers - const llmProviders = buildLLMProviders(llmEntries, llmNameAliases, ctx); - - // 2. Build invokable clients - const invokableClients = buildInvokableClients(actionDefs, ctx); - - // 3. Build definitions (ActionDefinitions + IdentityAction) - const definitions = buildDefinitions(actionDefs, ctx); - - // 4. Resolve initial node from trigger (linter guarantees trigger exists) - const initialNode = resolveInitialNode(triggers!); - - // 5. Build graph nodes and collect outputStructures discovered during - // node-level LLM/output-structure resolution. - const builtNodes = buildNodes( - ast, - llmEntries, - actionDefs, - initialNode, - options?.source, - llmNameAliases - ); - const { nodes, outputStructures } = builtNodes; - - // 6. Inject _node_input tracking (handoff breadcrumbs + on-init lookups) - const trackingInjected = injectNodeInputTracking(nodes); - - // 7. Build state variables (built-in outputs + `variables:` declarations) - const stateVariables = buildStateVariables(ast); - if (trackingInjected) { - const trackingVarNames = new Set(stateVariables.map(v => v.name)); - if (!trackingVarNames.has('_handoff_source')) { - stateVariables.push({ - name: '_handoff_source', - 'data-type': 'string', - default: null, - label: '', - description: '', - }); - } - if (!trackingVarNames.has('_node_input')) { - stateVariables.push({ - name: '_node_input', - 'data-type': 'string', - default: null, - label: '', - description: '', - }); - } - } - - // 8. Collect response node names - const responseNodeNames = collectResponseNodeNames(echoEntries); - - // 9. Extract config fields - const agentName = extractString(config?.agent_name) ?? ''; - const label = extractString(config?.label) ?? agentName; - - // 10. Assemble UnifiedAgentSpecification - const spec: UnifiedAgentSpecification = { - 'schema-version': SCHEMA_VERSION, - id: agentName, - label, - definitions: definitions.length > 0 ? definitions : null, - graph: { - 'state-variables': stateVariables, - 'initial-node': initialNode, - nodes, - }, - }; - - // 11. Assemble AgentGraph - const agentGraph: AgentGraph = { - unifiedAgentSpec: spec, - llmProviders, - invokableClients, - responseNodeNames, - trigger: buildCompiledTrigger(triggers), - outputStructures, - }; - - return { - output: agentGraph, - diagnostics: ctx.diagnostics, - }; -} diff --git a/dialect/agentfabric/src/compiler/compiler-context.ts b/dialect/agentfabric/src/compiler/compiler-context.ts deleted file mode 100644 index 259da700..00000000 --- a/dialect/agentfabric/src/compiler/compiler-context.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Compiler diagnostic context — threads diagnostics through compilation. - */ - -export enum DiagnosticSeverity { - Error = 1, - Warning = 2, - Information = 3, - Hint = 4, -} - -export interface CompilerDiagnostic { - message: string; - severity: DiagnosticSeverity; -} - -export class AgentFabricCompilerContext { - readonly diagnostics: CompilerDiagnostic[] = []; - - error(message: string): void { - this.diagnostics.push({ message, severity: DiagnosticSeverity.Error }); - } - - warn(message: string): void { - this.diagnostics.push({ message, severity: DiagnosticSeverity.Warning }); - } -} diff --git a/dialect/agentfabric/src/compiler/index.ts b/dialect/agentfabric/src/compiler/index.ts deleted file mode 100644 index 6330b945..00000000 --- a/dialect/agentfabric/src/compiler/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -export { compile } from './compile.js'; -export type { CompileResult, CompileOptions } from './compile.js'; -export type { AgentGraph } from './agent-graph.js'; -export type { - UnifiedAgentSpecification, - AgentNode, - ActionNode, - RouterNode, - HandoffAction, - ActionCallableReference, - ActionDefinition, - LLMRef, - StateVariable, - GraphConfig, - Node, -} from './unified-agent-specification.js'; -export { ObjectTypes } from './unified-agent-specification.js'; -export type { LLMProvider, InvokableClient } from './service-types.js'; -export type { - CompilerDiagnostic, - DiagnosticSeverity, -} from './compiler-context.js'; diff --git a/dialect/agentfabric/src/compiler/service-types.ts b/dialect/agentfabric/src/compiler/service-types.ts deleted file mode 100644 index 22494730..00000000 --- a/dialect/agentfabric/src/compiler/service-types.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * TypeScript types for service-level objects (LLMProvider, InvokableClient) - * derived from module_graph_runtime.schemas.service Pydantic models. - */ - -export interface LLMProviderMetadata { - platform: string; - connection?: string; - headers?: Record | null; - timeout?: number | null; - api_key?: string | null; -} - -export interface LLMProvider { - name: string; - description: string; - metadata: LLMProviderMetadata; -} - -export interface InvokableClient { - name: string; - type: string; - label: string; - metadata: Record; -} diff --git a/dialect/agentfabric/src/compiler/unified-agent-specification.ts b/dialect/agentfabric/src/compiler/unified-agent-specification.ts deleted file mode 100644 index 8ed04872..00000000 --- a/dialect/agentfabric/src/compiler/unified-agent-specification.ts +++ /dev/null @@ -1,366 +0,0 @@ -/** - * TypeScript types derived from the Pydantic UnifiedAgentSpecification model - * in docs/schemas/spec.py. Field names use kebab-case to match the - * KebabCaseModel alias convention used in the Python runtime. - */ - -// ── Expression type aliases ───────────────────────────────────────── - -export type Expr = string | number | boolean; -export type StateUpdateExpr = Expr | null; -export type BoundInputsExpr = Expr | null; - -// ── Enums ─────────────────────────────────────────────────────────── - -export enum ObjectTypes { - ACTION = 'action', - AGENT = 'agent', - EXTERNAL_AGENT = 'external-agent', - HANDOFF = 'handoff', - ROUTER = 'router', - MCP_ACTION = 'mcp-action', -} - -export enum SubgraphPersistence { - EPHEMERAL = 'ephemeral', - PERSISTENT = 'persistent', -} - -export enum SubgraphInitStateMode { - COPY_PARENT = 'copy_parent', - BLANK_SLATE = 'blank_slate', -} - -export enum SubgraphInitMemoryMode { - COPY_PARENT = 'copy_parent', - BLANK_SLATE = 'blank_slate', -} - -export enum SubgraphResultMode { - TOOL_RESULT = 'tool_result', - FINAL_RESULT = 'final_result', -} - -export enum SubgraphStateApplyMode { - NONE = 'none', - ALL = 'all', - ALLOWLIST = 'allowlist', -} - -// ── Subgraph configuration ────────────────────────────────────────── - -export interface SubgraphToolConfiguration { - 'state-mode'?: SubgraphInitStateMode; - 'memory-mode'?: SubgraphInitMemoryMode; - 'result-mode'?: SubgraphResultMode; - persistence?: SubgraphPersistence; - 'state-apply-mode'?: SubgraphStateApplyMode; -} - -// ── Output & cache behaviors ──────────────────────────────────────── - -export interface OutputBehavior { - name: string; - 'emit-in-response': boolean; - 'add-to-chat-history': boolean; -} - -export interface CacheBehavior { - enabled?: boolean; - ttl?: number; -} - -export interface ActionBehavior { - 'require-user-confirmation': boolean; - 'include-in-progress-indicator': boolean; - 'progress-indicator-message'?: string | null; - outputs?: OutputBehavior[] | null; - cache?: CacheBehavior | null; -} - -// ── Action definitions ────────────────────────────────────────────── - -export interface ActionDefinition { - name: string; - type: ObjectTypes.ACTION; - client: string; - label: string; - description: string; - 'invocation-target-type': string; - 'invocation-target-name': string; - 'input-schema': unknown; - 'output-schema': unknown; - behavior?: ActionBehavior | null; - metadata?: Record | null; -} - -export interface MCPActionDefinition extends Omit { - type: ObjectTypes.MCP_ACTION; - annotations?: Record | null; -} - -// ── Variables ─────────────────────────────────────────────────────── - -export interface RequestVariable { - name: string; - 'data-type': string; - description: string; -} - -export interface StateVariable { - name: string; - label: string; - 'data-type': string; - 'is-list'?: boolean | null; - description: string; - default: unknown; -} - -// ── LLM reference ─────────────────────────────────────────────────── - -export interface LLMRef { - ref: string; - configuration: Record; - 'output-structure-ref'?: string; -} - -// ── System policy ─────────────────────────────────────────────────── - -export interface SystemPolicy { - name: string; - value: unknown; - type: 'system'; -} - -// ── Action callable reference ─────────────────────────────────────── - -export interface ActionCallableReference { - type?: ObjectTypes.ACTION; - target?: string | null; - ref?: string | null; - description?: string | null; - 'bound-inputs'?: Record | null; - enabled?: Expr | null; - 'state-updates'?: Array> | null; -} - -// ── Tool call reference ───────────────────────────────────────────── - -export interface ToolCallReference extends ActionCallableReference { - name: string; - 'llm-inputs'?: string[] | null; - forced?: Expr | null; -} - -// ── MCP tool ──────────────────────────────────────────────────────── - -export interface MCPTool { - type: 'mcp_tool'; - ref: string; - enabled?: Expr | null; - 'bound-inputs'?: Record | null; - 'llm-inputs'?: string[] | null; -} - -// ── A2A tool ──────────────────────────────────────────────────────── - -export interface A2ATool { - type: 'a2a'; - ref: string; - enabled?: Expr | null; - 'bound-inputs'?: Record | null; - 'llm-inputs'?: string[] | null; -} - -// ── Subgraph tool ─────────────────────────────────────────────────── - -export interface SubgraphTool { - type: 'subgraph'; - target: string; - name: string; - description: string; - forced?: Expr | null; - enabled?: Expr | null; - 'state-updates'?: Array> | null; - configuration?: SubgraphToolConfiguration; -} - -export type ToolUnion = ToolCallReference | SubgraphTool | MCPTool | A2ATool; - -// ── Handoff action ────────────────────────────────────────────────── - -export interface HandoffAction { - type: ObjectTypes.HANDOFF; - target: string; - enabled?: string | boolean | null; - 'state-updates'?: Array> | null; -} - -export type HandoffActionUnion = HandoffAction | ActionCallableReference; - -// ── Node reference ────────────────────────────────────────────────── - -export interface NodeReference { - target: string; - description: string; - enabled?: Expr | null; - 'state-updates'?: Array> | null; -} - -// ── External agent metadata ───────────────────────────────────────── - -export interface ExternalAgentMetadata { - protocol: string; -} - -export interface A2AExternalAgentMetadata extends ExternalAgentMetadata { - protocol: 'a2a'; - platform: string; - url: string; -} - -// ── Node system limits ────────────────────────────────────────────── - -export interface NodeSystemLimits { - 'max-reasoning-iterations'?: number; - 'max-node-tool-call-iterations'?: number; - 'max-consecutive-errors'?: number; - 'task-timeout-secs'?: number; -} - -// ── Pre/Post tool call references ─────────────────────────────────── - -export interface PreToolCallReference { - 'target-tool-name': string; - actions: ActionCallableReference[]; -} - -export interface PostToolCallReference { - 'target-tool-name': string; - actions: ActionCallableReference[]; -} - -// ── Nodes ─────────────────────────────────────────────────────────── - -export interface AgentNode { - name: string; - label?: string | null; - description?: string | null; - type: ObjectTypes.AGENT; - llm: LLMRef; - 'on-init'?: HandoffActionUnion[] | null; - 'before-reasoning'?: HandoffActionUnion[] | null; - 'before-reasoning-iteration'?: HandoffActionUnion[] | null; - 'system-prompt': string; - 'focus-prompt'?: string | null; - tools?: ToolUnion[] | null; - 'pre-tool-calls'?: PreToolCallReference[] | null; - 'post-tool-calls'?: PostToolCallReference[] | null; - 'after-all-tool-calls'?: HandoffActionUnion[] | null; - 'after-reasoning'?: HandoffActionUnion[] | null; - 'on-exit'?: ActionCallableReference[] | null; - policies?: SystemPolicy[] | null; - 'system-limits'?: NodeSystemLimits; -} - -export interface LLMToolCallClassifierRef { - type: 'llm-tool-call'; - llm?: LLMRef | null; -} - -export type ClassifierRef = LLMToolCallClassifierRef; - -export interface RouterNode { - name: string; - label?: string | null; - description?: string | null; - type: ObjectTypes.ROUTER; - policies?: SystemPolicy[] | null; - classifier?: ClassifierRef; - 'node-references': NodeReference[]; - 'on-init'?: HandoffActionUnion[] | null; - 'system-prompt'?: string | null; - 'before-reasoning-iteration'?: HandoffActionUnion[] | null; - 'on-exit'?: HandoffActionUnion[] | null; - 'system-limits'?: NodeSystemLimits; -} - -export interface ActionNode { - name: string; - type?: ObjectTypes.ACTION; - label?: string | null; - description?: string | null; - 'on-init'?: HandoffActionUnion[] | null; - tools: ActionCallableReference[]; - 'is-parallel'?: boolean; - 'add-tool-result-to-chat-history'?: boolean; - 'on-exit'?: HandoffActionUnion[] | null; - 'output-template'?: string | null; - policies?: SystemPolicy[] | null; - 'system-limits'?: NodeSystemLimits; -} - -export interface ExternalAgentNode { - name: string; - type: ObjectTypes.EXTERNAL_AGENT; - label?: string | null; - metadata?: ExternalAgentMetadata | null; - ref?: string | null; - 'system-limits'?: NodeSystemLimits; -} - -export type Node = AgentNode | ExternalAgentNode | RouterNode | ActionNode; - -// ── Turn system limits ────────────────────────────────────────────── - -export interface TurnSystemLimits { - 'max-handoff-iterations'?: number; - 'max-subgraph-depth'?: number; - 'max-turn-tool-call-counts'?: number; -} - -export interface Behavior { - 'reset-to-initial-node'?: boolean; - 'disable-groundedness'?: boolean; - 'disable-error-behavior'?: boolean; - 'turn-system-limits'?: TurnSystemLimits; -} - -// ── Graph config ──────────────────────────────────────────────────── - -export interface PluginConfig { - type: 'plugin'; - name: string; - kind: string; - config: Record; -} - -export type GraphConfigItem = PluginConfig; - -export interface GraphConfig { - config?: GraphConfigItem[] | null; - 'request-variables'?: RequestVariable[] | null; - 'state-variables'?: StateVariable[] | null; - 'initial-node': string; - nodes: Node[]; - behaviors?: Behavior | null; -} - -// ── Definitions ───────────────────────────────────────────────────── - -export type Definition = - | ActionDefinition - | ExternalAgentNode - | MCPActionDefinition; - -// ── Top-level specification ───────────────────────────────────────── - -export interface UnifiedAgentSpecification { - 'schema-version': string; - id: string; - label: string; - definitions?: Definition[] | null; - 'pre-orchestration'?: unknown | null; - graph: GraphConfig; - 'post-orchestration'?: unknown | null; -} diff --git a/dialect/agentfabric/src/compiler/utils.ts b/dialect/agentfabric/src/compiler/utils.ts deleted file mode 100644 index 4a7aa7dc..00000000 --- a/dialect/agentfabric/src/compiler/utils.ts +++ /dev/null @@ -1,369 +0,0 @@ -/** - * Compiler utilities mirroring the Python adaptor helper methods. - */ - -import { decomposeAtMemberExpression } from '@agentscript/language'; - -/** - * 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 []; -} - -/** - * Normalize a kebab-case identifier to snake_case (valid Python identifier). - * Mirrors _normalize_id() in the Python adaptor. - */ -export function normalizeId(name: string): string { - return name ? name.replace(/-/g, '_') : name; -} - -/** - * Resolve a handoff target: 'end' means graph-complete (null), otherwise normalize. - * Mirrors _resolve_target() in the Python adaptor. - */ -export function resolveTarget( - target: string | undefined | null -): string | null { - if (!target || target.toLowerCase() === 'end') { - return null; - } - return normalizeId(target); -} - -/** - * Replace hyphens between word characters inside Jinja2 {{ }} blocks with underscores. - * Mirrors _normalize_template() in the Python adaptor. - */ -export function normalizeTemplate(value: string): string { - // Convert AgentScript interpolation form `{!expr}` to `{{expr}}`. - const withJinja = value.replace( - /\{\!\s*([^}]+?)\s*\}/g, - (_m, inner: string) => { - return `{{${inner}}}`; - } - ); - - return withJinja.replace(/\{\{(.*?)\}\}/g, (_match, inner: string) => { - let normalized = inner.replace(/(\w)-(\w)/g, '$1_$2'); - // Runtime context stores mutable variables on state.. - normalized = normalized.replace( - /@variables\.([A-Za-z0-9_-]+)/g, - (_m, name: string) => `state.${name.replace(/-/g, '_')}` - ); - // Runtime context uses state.request.*, so rewrite @request.* in templates. - normalized = normalized.replace(/@request\./g, 'state.request.'); - // Canonical node output reference in templates: - // - @executor..output[.attr...] -> state.outputs[''][.attr...] - // - @orchestrator/@subagent/@generator..output -> system.node_outputs[''] - // - @orchestrator/@subagent/@generator..output. -> parse_json(system.node_outputs['']). - // node_outputs values are JSON strings; attribute access requires parse_json(). - normalized = normalized.replace( - /@(orchestrator|subagent|generator|executor)\.([A-Za-z0-9_-]+)\.output\b((?:\.[A-Za-z_]\w*)*)/g, - (_m, nodeType: string, nodeName: string, tail: string) => { - const normalizedName = nodeName.replace(/-/g, '_'); - if (nodeType === 'executor') { - return `state.outputs['${normalizedName}']${tail}`; - } - if (tail) { - return `parse_json(system.node_outputs['${normalizedName}'])${tail}`; - } - return `system.node_outputs['${normalizedName}']`; - } - ); - // Node input reference in templates: - // - @..input -> state._node_input - normalized = normalized.replace( - /@(orchestrator|subagent|generator|executor|router|echo)\.([A-Za-z0-9_-]+)\.input\b/g, - 'state._node_input' - ); - // Disallow deprecated alias in templates. - normalized = normalized.replace( - /@outputs\.([A-Za-z0-9_-]+)/g, - (_m, nodeName: string) => - `__ERROR__outputs_alias_not_supported__use_@.${nodeName}.output` - ); - return '{{' + normalized + '}}'; - }); -} - -/** - * Prefix Jinja2 template expressions with 'template::' for the runtime evaluator. - * Only applies to strings that start with '{{'. - * Mirrors _template_expr() in the Python adaptor. - */ -export function templateExpr(value: unknown): unknown { - if ( - typeof value === 'string' && - value.trim().startsWith('{{') && - !value.startsWith('template::') - ) { - return `template::${normalizeTemplate(value)}`; - } - return value; -} - -/** - * Extract a plain string from a parsed AST field value. - * Handles StringLiteral, TemplateExpression, and raw string values. - */ -export function extractString(value: unknown): string | undefined { - if (value === undefined || value === null) return undefined; - if (typeof value === 'string') return value; - if (typeof value === 'object' && value !== null) { - const v = value as Record; - if ('value' in v && typeof v.value === 'string') return v.value; - if ('text' in v && typeof v.text === 'string') return v.text; - } - return String(value); -} - -/** - * Extract an LLM reference string from `config.default_llm` or a node's `llm` field. - * Handles plain strings and `@namespace.member` member expressions from the dialect AST. - */ -export function extractLlmFieldReference(value: unknown): string | undefined { - if (value === undefined || value === null) return undefined; - - const ref = decomposeAtMemberExpression(value); - if (ref) { - return `@${ref.namespace}.${ref.property}`; - } - - const s = extractString(value); - if (s === undefined || s === '[object Object]') return undefined; - return s; -} - -/** - * Resolve effective system instructions for compiled focus-prompt: - * node-level instructions override document-level defaults when present. - */ -export function combineGlobalSystemInstructions( - globalInstructions: string | undefined, - nodeInstructions: string | undefined -): string { - const g = globalInstructions?.trim() ?? ''; - const n = nodeInstructions?.trim() ?? ''; - if (n) return n; - return g; -} - -/** - * Extract plain text from a procedure / template field (e.g. `instructions: -> ...`). - * Falls back to {@link extractString} for simple string values. - */ -export function extractProcedureText(value: unknown): string { - if (value === undefined || value === null) return ''; - if (typeof value === 'string') return value; - if (typeof value === 'object' && value !== null) { - const v = value as Record; - if (Array.isArray(v.statements)) { - const stmts = v.statements as Array<{ - __emit?: (ctx: { indent: number }) => string; - }>; - const lines = stmts - .map(s => - typeof s.__emit === 'function' ? s.__emit({ indent: 0 }) : '' - ) - .filter(line => line.length > 0); - return lines.join('\n'); - } - if (Array.isArray(v.parts)) { - const parts = v.parts as Array>; - return parts - .map(p => { - if (typeof p.value === 'string') return p.value; - if (typeof p.__emit === 'function') - return (p.__emit as (ctx: { indent: number }) => string)({ - indent: 0, - }); - return ''; - }) - .join(''); - } - } - const fallback = extractString(value); - if (fallback === undefined || fallback === '[object Object]') return ''; - return fallback; -} - -/** - * Extract a transition target reference from a procedure-like value. - * Returns canonical "@namespace.node" or empty string when unresolved. - */ -export function extractTransitionReference(value: unknown): string { - const fromText = extractProcedureText(value); - const extractFrom = (text: string): string => { - const explicitTransition = text.match( - /transition\s+to\s+@([A-Za-z_][\w]*\.[A-Za-z0-9_-]+)/i - ); - if (explicitTransition) return `@${explicitTransition[1]}`; - - const anyReference = text.match(/@([A-Za-z_][\w]*\.[A-Za-z0-9_-]+)/); - return anyReference ? `@${anyReference[1]}` : ''; - }; - - const fromEmitted = extractFrom(fromText); - if (fromEmitted) return fromEmitted; - - try { - const plain = toPlainData(value); - const serialized = JSON.stringify(plain); - const fromSerialized = extractFrom(serialized); - if (fromSerialized) return fromSerialized; - } catch { - // ignore and continue to structural walk - } - - const seen = new Set(); - const queue: unknown[] = [value]; - while (queue.length > 0) { - const current = queue.shift(); - if (current == null || seen.has(current)) continue; - seen.add(current); - - const ref = decomposeAtMemberExpression(current); - if (ref && ref.namespace && ref.property) { - return `@${ref.namespace}.${ref.property}`; - } - - if (typeof current === 'string') { - const fromString = extractFrom(current); - if (fromString) return fromString; - continue; - } - if (Array.isArray(current)) { - for (const item of current) queue.push(item); - continue; - } - if (typeof current === 'object') { - for (const child of Object.values(current as Record)) { - queue.push(child); - } - } - } - - return ''; -} - -/** - * Extract a number from a parsed AST field value. - */ -export function extractNumber(value: unknown): number | undefined { - if (value === undefined || value === null) return undefined; - if (typeof value === 'number') return value; - if (typeof value === 'object' && value !== null) { - const v = value as Record; - if ('value' in v && typeof v.value === 'number') return v.value; - } - const n = Number(value); - return isNaN(n) ? undefined : n; -} - -/** - * Convert parsed AST/schema nodes into plain JSON-like values. - * Strips internal metadata (`__*`), functions, and non-serializable objects. - */ -export function toPlainData(value: unknown, seen?: WeakSet): unknown { - if (value === null || value === undefined) return value; - if ( - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' - ) { - return value; - } - if (typeof value === 'function') return undefined; - - if (Array.isArray(value)) { - return value - .map(v => toPlainData(v, seen)) - .filter((v): v is unknown => v !== undefined); - } - - if (value instanceof Map) { - const out: Record = {}; - for (const [k, v] of value.entries()) { - const plain = toPlainData(v, seen); - if (plain !== undefined) out[String(k)] = plain; - } - return out; - } - - if (typeof value === 'object') { - const obj = value as Record; - const tracker = seen ?? new WeakSet(); - if (tracker.has(obj)) return undefined; - tracker.add(obj); - - const kind = typeof obj.__kind === 'string' ? obj.__kind : undefined; - if (kind) { - if ( - kind === 'StringLiteral' || - kind === 'NumberLiteral' || - kind === 'BooleanLiteral' - ) { - return obj.value; - } - if (kind === 'NoneLiteral') return null; - if (kind === 'Identifier' && typeof obj.name === 'string') - return obj.name; - if ( - (kind === 'TemplateExpression' || - kind === 'MemberExpression' || - kind === 'CallExpression' || - kind === 'SpreadExpression') && - typeof obj.__emit === 'function' - ) { - return (obj.__emit as (ctx: { indent: number }) => string)({ - indent: 0, - }); - } - } - - // NamedMap/TypedMap: iterate declared entries in source order. - if (Symbol.iterator in obj) { - const out: Record = {}; - try { - for (const item of obj as Iterable) { - if ( - Array.isArray(item) && - item.length >= 2 && - typeof item[0] === 'string' - ) { - const plain = toPlainData(item[1], tracker); - if (plain !== undefined) out[item[0]] = plain; - } - } - if (Object.keys(out).length > 0) return out; - } catch { - // Fall back to own-enumerable traversal below. - } - } - - const out: Record = {}; - for (const [k, v] of Object.entries(obj)) { - if (k.startsWith('__')) continue; - const plain = toPlainData(v, tracker); - if (plain !== undefined) out[k] = plain; - } - return out; - } - - return undefined; -} diff --git a/dialect/agentfabric/src/index.ts b/dialect/agentfabric/src/index.ts index 03cd6f44..6fcc0aba 100644 --- a/dialect/agentfabric/src/index.ts +++ b/dialect/agentfabric/src/index.ts @@ -80,27 +80,6 @@ export type { export { defaultRules } from './lint/passes/index.js'; export { createLintEngine } from './lint/index.js'; -// ── Compiler re-exports ───────────────────────────────────────────── - -export { compile } from './compiler/index.js'; -export type { CompileResult, CompileOptions } from './compiler/index.js'; -export type { AgentGraph } from './compiler/agent-graph.js'; -export { ObjectTypes } from './compiler/unified-agent-specification.js'; -export type { - UnifiedAgentSpecification, - AgentNode, - ActionNode, - RouterNode, - HandoffAction, - ActionCallableReference, - ActionDefinition, - LLMRef, - StateVariable, - GraphConfig, - Node, -} from './compiler/unified-agent-specification.js'; -export type { LLMProvider, InvokableClient } from './compiler/service-types.js'; - // ── Graph re-exports ───────────────────────────────────────────────── export { extractGraph, getGraph } from './graph/index.js'; diff --git a/dialect/agentfabric/src/lint/passes/rules/action-binding-rules.ts b/dialect/agentfabric/src/lint/passes/rules/action-binding-rules.ts index 73460f9b..8c5dfd24 100644 --- a/dialect/agentfabric/src/lint/passes/rules/action-binding-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/action-binding-rules.ts @@ -15,11 +15,11 @@ import { decomposeAtMemberExpression, isNamedMap, } from '@agentscript/language'; -import { normalizeId } from '../../../compiler/utils.js'; import { + normalizeId, IMPLICIT_WITH_PARAMS, listActionDefInputNames, -} from '../../../compiler/build-nodes.js'; +} from '../../utils.js'; import { attachError, extractStringValue, type AstLike } from './shared.js'; function getActionDefName( diff --git a/dialect/agentfabric/src/lint/passes/rules/echo-rules.ts b/dialect/agentfabric/src/lint/passes/rules/echo-rules.ts index 1b8da6af..97e50a3c 100644 --- a/dialect/agentfabric/src/lint/passes/rules/echo-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/echo-rules.ts @@ -1,5 +1,5 @@ import { isNamedMap } from '@agentscript/language'; -import { normalizeId } from '../../../compiler/utils.js'; +import { normalizeId } from '../../utils.js'; import { attachError, hasOwnNonNull, type AstLike } from './shared.js'; export function checkEchoRules(root: Record): void { diff --git a/dialect/agentfabric/src/lint/passes/rules/execute-rules.ts b/dialect/agentfabric/src/lint/passes/rules/execute-rules.ts index 244ed738..60d6778f 100644 --- a/dialect/agentfabric/src/lint/passes/rules/execute-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/execute-rules.ts @@ -32,11 +32,11 @@ import { decomposeAtMemberExpression, } from '@agentscript/language'; import type { Expression, Statement } from '@agentscript/language'; -import { normalizeId } from '../../../compiler/utils.js'; import { + normalizeId, IMPLICIT_WITH_PARAMS, listActionDefInputNames, -} from '../../../compiler/build-nodes.js'; +} from '../../utils.js'; import { attachError, asStatements, diff --git a/dialect/agentfabric/src/lint/passes/rules/shared.ts b/dialect/agentfabric/src/lint/passes/rules/shared.ts index 42460544..9e13d572 100644 --- a/dialect/agentfabric/src/lint/passes/rules/shared.ts +++ b/dialect/agentfabric/src/lint/passes/rules/shared.ts @@ -8,8 +8,7 @@ import { TernaryExpression, UnaryExpression, } from '@agentscript/language'; -import { normalizeId } from '../../../compiler/utils.js'; - +import { normalizeId } from '../../utils.js'; export const AGENTFABRIC_LINT_SOURCE = 'agentfabric-lint'; const ERROR_SEVERITY = 1; const WARNING_SEVERITY = 2; diff --git a/dialect/agentfabric/src/lint/passes/rules/switch-rules.ts b/dialect/agentfabric/src/lint/passes/rules/switch-rules.ts index 329da26b..66196fd5 100644 --- a/dialect/agentfabric/src/lint/passes/rules/switch-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/switch-rules.ts @@ -1,5 +1,5 @@ import { isNamedMap } from '@agentscript/language'; -import { normalizeId } from '../../../compiler/utils.js'; +import { normalizeId } from '../../utils.js'; import type { PassStore } from '@agentscript/language'; import { asObjectList, diff --git a/dialect/agentfabric/src/lint/utils.ts b/dialect/agentfabric/src/lint/utils.ts new file mode 100644 index 00000000..a1d36bf8 --- /dev/null +++ b/dialect/agentfabric/src/lint/utils.ts @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + +/** Convert kebab-case identifiers to snake_case. */ +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/tests/compiler.test.ts b/dialect/agentfabric/src/tests/compiler.test.ts deleted file mode 100644 index cdb82cb9..00000000 --- a/dialect/agentfabric/src/tests/compiler.test.ts +++ /dev/null @@ -1,1755 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import YAML from 'yaml'; -import { parseDocument, toRecord } from './test-utils.js'; -import { compile } from '../compiler/compile.js'; -import { toPlainData } from '../compiler/utils.js'; -import { - ObjectTypes, - type Definition, -} from '../compiler/unified-agent-specification.js'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -describe('AgentFabric Compiler', () => { - it('compiles minimal strict syntax agent', () => { - const source = ` -config: - agent_name: "minimal" - -trigger t: - kind: "a2a" - target: "brokers://minimal/a2a" - on_message: -> transition to @echo.done - -echo done: - kind: "a2a:response" - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - expect(result.output.unifiedAgentSpec.graph.nodes.length).toBeGreaterThan( - 0 - ); - expect(result.output.trigger?.kind).toBe('a2a'); - }); - - it('compiles generator with prompt and outputs configuration', () => { - const source = ` -config: - agent_name: "gen" - -llm: - g: - target: "llm://openai" - kind: "OpenAI" - model: "gpt-4o-mini" - -trigger t: - kind: "a2a" - target: "brokers://gen/a2a" - on_message: -> transition to @generator.main - -generator main: - llm: @llm.g - system: - instructions: "You are helpful" - prompt: -> Summarize - outputs: - properties: - summary: - type: "string" - on_exit: -> transition to @echo.done - -echo done: - kind: "a2a:response" - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - const agentNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'main' - ); - expect(agentNode).toBeDefined(); - expect(result.output.outputStructures).toHaveProperty('os_main'); - }); - - it('compiles executor node with IdentityAction state updates', () => { - const source = ` -config: - agent_name: "exec" - -variables: - status: mutable string = "" - -trigger t: - kind: "a2a" - target: "brokers://exec/a2a" - on_message: -> transition to @executor.step - -executor step: - do: -> - set @variables.status = "done" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - const node = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'step' - ) as Record | undefined; - expect(node).toBeDefined(); - expect(node?.type).toBe(ObjectTypes.ACTION); - }); - - it('normalizes request headers and compiles case-insensitive header lookups', () => { - const source = ` -config: - agent_name: "request-headers" - -variables: - h1: mutable string = "" - h2: mutable string = "" - -trigger t: - kind: "a2a" - target: "brokers://request-headers/a2a" - on_message: -> transition to @executor.step - -executor step: - do: -> - set @variables.h1 = @request.headers.Authorization - set @variables.h2 = @request.headers["X-Request-Id"] -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const node = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'step' - ) as Record | undefined; - expect(node).toBeDefined(); - - const onInit = (node?.['on-init'] as Array>) ?? []; - const requestInitExpr = (((onInit[0]?.['state-updates'] as Array< - Record - >) ?? [])[0]?.request ?? '') as string; - expect(requestInitExpr).toBe("normalize_headers(variables['request'])"); - - const tools = (node?.tools as Array>) ?? []; - const stateUpdates = ((tools[0]?.['state-updates'] as Array< - Record - >) ?? []) as Array>; - const h1Expr = (stateUpdates.find(s => 'h1' in s)?.h1 ?? '') as string; - const h2Expr = (stateUpdates.find(s => 'h2' in s)?.h2 ?? '') as string; - - expect(h1Expr).toBe("state.request.headers['authorization']"); - expect(h2Expr).toBe('state.request.headers[lower("X-Request-Id")]'); - }); - - it('passes actions description and label into definitions and invokable clients', () => { - const source = ` -config: - agent_name: "labels" - -actions: - my_a2a: - label: "Billing tool" - description: "Calls the billing A2A agent" - target: "a2a://billing" - kind: "a2a:send_message" - my_mcp: - label: "Article lookup" - description: "Searches the knowledge base" - target: "mcp://kb" - kind: "mcp:tool" - tool_name: "search" - -trigger t: - kind: "a2a" - target: "brokers://labels/a2a" - on_message: -> transition to @echo.done - -echo done: - kind: "a2a:response" - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const a2aDef = result.output.unifiedAgentSpec.definitions?.find( - (d: Definition) => d.name === 'my_a2a-action' - ) as Record | undefined; - expect(a2aDef?.label).toBe('Billing tool'); - expect(a2aDef?.description).toBe('Calls the billing A2A agent'); - - const mcpDef = result.output.unifiedAgentSpec.definitions?.find( - (d: Definition) => d.name === 'my_mcp-action' - ) as Record | undefined; - expect(mcpDef?.label).toBe('Article lookup'); - expect(mcpDef?.description).toBe('Searches the knowledge base'); - - const mcpClient = result.output.invokableClients.find( - c => c.name === 'my_mcp-client' - ) as Record | undefined; - expect(mcpClient?.label).toBe('Article lookup'); - expect((mcpClient?.metadata as Record)?.description).toBe( - 'Searches the knowledge base' - ); - - const a2aClient = result.output.invokableClients.find( - c => c.name === 'my_a2a-client' - ) as Record | undefined; - expect(a2aClient?.label).toBe('Billing tool'); - expect((a2aClient?.metadata as Record)?.description).toBe( - 'Calls the billing A2A agent' - ); - }); - - it('compiles http_headers passed via with binding in subagent actions', () => { - const source = ` -config: - agent_name: "with-headers" - -llm: - g: - target: "llm://openai" - kind: "OpenAI" - model: "gpt-4o-mini" - -actions: - hr_agent: - target: "a2a://hr_agent_connection" - kind: "a2a:send_message" - send_slack: - target: "mcp://slack" - kind: "mcp:tool" - tool_name: "send_message" - -trigger t: - kind: "a2a" - target: "brokers://with-headers/a2a" - on_message: -> transition to @subagent.worker - -subagent worker: - description: "uses actions with http_headers via with binding" - llm: @llm.g - reasoning: - instructions: -> onboard new hires - actions: - my_hr: @actions.hr_agent - with http_headers = {"Authorization": "Bearer token123", "X-CorrelationId": "corr-456"} - slack: @actions.send_slack - with http_headers = {"X-Slack-Token": "slack-secret"} - on_exit: -> transition to @echo.done - -echo done: - kind: "a2a:response" - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const hrClient = result.output.invokableClients.find( - c => c.name === 'hr_agent-client' - ) as Record | undefined; - const slackClient = result.output.invokableClients.find( - c => c.name === 'send_slack-client' - ) as Record | undefined; - - expect(hrClient).toBeDefined(); - expect(slackClient).toBeDefined(); - - const workerNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'worker' - ) as Record | undefined; - expect(workerNode).toBeDefined(); - const tools = (workerNode?.tools as Array>) ?? []; - expect(tools.length).toBeGreaterThan(0); - - const hrTool = tools.find(t => t.ref === 'hr_agent-client'); - expect(hrTool).toBeDefined(); - const hrBindings = (hrTool?.['bound-inputs'] ?? {}) as Record< - string, - unknown - >; - const hrHeaders = hrBindings.http_headers as string; - expect(hrHeaders).toBeDefined(); - expect(hrHeaders).toContain('"authorization"'); - expect(hrHeaders).not.toMatch(/"Authorization"/); - expect(hrHeaders).toContain('"x-correlationid"'); - expect(hrHeaders).not.toMatch(/"X-CorrelationId"/); - - const slackTool = tools.find(t => t.ref === 'send_slack-client'); - expect(slackTool).toBeDefined(); - const slackBindings = (slackTool?.['bound-inputs'] ?? {}) as Record< - string, - unknown - >; - const slackHeaders = slackBindings.http_headers as string; - expect(slackHeaders).toBeDefined(); - expect(slackHeaders).toContain('"x-slack-token"'); - expect(slackHeaders).not.toMatch(/"X-Slack-Token"/); - }); - - it('compiles http_headers with embedded expression references', () => { - const source = ` -config: - agent_name: "expr-headers" - -llm: - g: - target: "llm://openai" - kind: "OpenAI" - model: "gpt-4o-mini" - -actions: - hr_agent: - target: "a2a://hr_agent_connection" - kind: "a2a:send_message" - send_slack: - target: "mcp://slack" - kind: "mcp:tool" - tool_name: "send_message" - -trigger t: - kind: "a2a" - target: "brokers://expr-headers/a2a" - on_message: -> transition to @subagent.worker - -subagent worker: - description: "uses actions with expression headers via with binding" - llm: @llm.g - reasoning: - instructions: -> onboard new hires - actions: - my_hr: @actions.hr_agent - with http_headers = {"Authorization": @request.headers.authorization, "X-CorrelationId": @variables.conversationId} - slack: @actions.send_slack - with http_headers = {"X-Static-Key": "static-value"} - on_exit: -> transition to @echo.done - -echo done: - kind: "a2a:response" - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - - expect(result.diagnostics).toHaveLength(0); - - const workerNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'worker' - ) as Record | undefined; - expect(workerNode).toBeDefined(); - const tools = (workerNode?.tools as Array>) ?? []; - expect(tools).toHaveLength(2); - - const hrTool = tools.find(t => t.ref === 'hr_agent-client'); - const slackTool = tools.find(t => t.ref === 'send_slack-client'); - expect(hrTool).toBeDefined(); - expect(slackTool).toBeDefined(); - - const hrBindings = (hrTool?.['bound-inputs'] ?? {}) as Record< - string, - unknown - >; - const hrHttpHeaders = hrBindings.http_headers as string; - expect(hrHttpHeaders).toBeDefined(); - expect(hrHttpHeaders).toContain('"authorization"'); - expect(hrHttpHeaders).not.toMatch(/"Authorization"/); - expect(hrHttpHeaders).toContain('"x-correlationid"'); - expect(hrHttpHeaders).not.toMatch(/"X-CorrelationId"/); - - const slackBindings = (slackTool?.['bound-inputs'] ?? {}) as Record< - string, - unknown - >; - const slackHttpHeaders = slackBindings.http_headers as string; - expect(slackHttpHeaders).toBeDefined(); - expect(slackHttpHeaders).toContain('"x-static-key"'); - expect(slackHttpHeaders).not.toMatch(/"X-Static-Key"/); - expect(slackHttpHeaders).toContain('static-value'); - }); - - it('slot-fills all declared action inputs by default without explicit ...', () => { - const source = ` -config: - agent_name: "slot-default" - -llm: - g: - target: "llm://openai" - kind: "OpenAI" - model: "gpt-4o-mini" - -actions: - slot_tool: - target: "mcp://conn" - kind: "mcp:tool" - tool_name: "tool" - inputs: - foo: {} - bar: {} - -trigger t: - kind: "a2a" - target: "brokers://slot-default/a2a" - on_message: -> transition to @subagent.worker - -subagent worker: - description: "slot-fill default" - llm: @llm.g - reasoning: - instructions: -> use tools - actions: - invoke: @actions.slot_tool - on_exit: -> transition to @echo.done - -echo done: - kind: "a2a:response" - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const workerNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'worker' - ) as Record | undefined; - expect(workerNode).toBeDefined(); - const tools = (workerNode?.tools as Array>) ?? []; - const tool = tools.find(t => t.ref === 'slot_tool-client'); - expect(tool).toBeDefined(); - expect(tool?.['bound-inputs']).toBeUndefined(); - expect(tool?.['llm-inputs']).toEqual(['foo', 'bar']); - }); - - it('respects bound action parameters and slot-fills only unbound declared inputs', () => { - const source = ` -config: - agent_name: "slot-mixed" - -llm: - g: - target: "llm://openai" - kind: "OpenAI" - model: "gpt-4o-mini" - -actions: - slot_tool: - target: "mcp://conn" - kind: "mcp:tool" - tool_name: "tool" - inputs: - foo: {} - bar: {} - -trigger t: - kind: "a2a" - target: "brokers://slot-mixed/a2a" - on_message: -> transition to @subagent.worker - -subagent worker: - description: "bound + slot-fill" - llm: @llm.g - reasoning: - instructions: -> use tools - actions: - invoke: @actions.slot_tool - with foo = "bound-val" - redundant: @actions.slot_tool - with foo = "x" - with bar = ... - on_exit: -> transition to @echo.done - -echo done: - kind: "a2a:response" - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const workerNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'worker' - ) as Record | undefined; - const tools = (workerNode?.tools as Array>) ?? []; - const slotTools = tools.filter(t => t.ref === 'slot_tool-client'); - expect(slotTools).toHaveLength(2); - - expect( - slotTools.some( - t => - (t['bound-inputs'] as Record | undefined)?.foo === - '"bound-val"' && - (t['llm-inputs'] as string[] | undefined)?.join() === 'bar' - ) - ).toBe(true); - expect( - slotTools.some( - t => - (t['bound-inputs'] as Record | undefined)?.foo === - '"x"' && (t['llm-inputs'] as string[] | undefined)?.join() === 'bar' - ) - ).toBe(true); - }); - - it('does not infer llm-inputs when actions omits inputs', () => { - const source = ` -config: - agent_name: "no-inputs-def" - -llm: - g: - target: "llm://openai" - kind: "OpenAI" - model: "gpt-4o-mini" - -actions: - bare_tool: - target: "mcp://conn" - kind: "mcp:tool" - tool_name: "tool" - -trigger t: - kind: "a2a" - target: "brokers://no-inputs-def/a2a" - on_message: -> transition to @subagent.worker - -subagent worker: - description: "no inputs block" - llm: @llm.g - reasoning: - instructions: -> use tools - actions: - invoke: @actions.bare_tool - on_exit: -> transition to @echo.done - -echo done: - kind: "a2a:response" - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const workerNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'worker' - ) as Record | undefined; - const tools = (workerNode?.tools as Array>) ?? []; - const tool = tools.find(t => t.ref === 'bare_tool-client'); - expect(tool).toBeDefined(); - expect(tool?.['llm-inputs']).toBeUndefined(); - expect(tool?.['bound-inputs']).toBeUndefined(); - }); - - it('compiles echo label and description onto the graph action node', () => { - const source = ` -config: - agent_name: "echo-meta" - -trigger t: - kind: "a2a" - target: "brokers://echo-meta/a2a" - on_message: -> transition to @echo.reply - -echo reply: - kind: "a2a:response" - label: "Reply node" - description: "Sends the final reply to the client." - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const echoNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'reply' - ) as Record | undefined; - expect(echoNode).toBeDefined(); - expect(echoNode?.label).toBe('Reply node'); - expect(echoNode?.description).toBe('Sends the final reply to the client.'); - }); - - it('accepts router syntax with routes and otherwise', () => { - const source = ` -config: - agent_name: "router" - -trigger t: - kind: "a2a" - target: "brokers://router/a2a" - on_message: -> transition to @router.main - -router main: - routes: - - target: @echo.a - when: @request.payload.kind == "a" - otherwise: - target: @echo.b - -echo a: - kind: "a2a:response" - message: "a" - -echo b: - kind: "a2a:response" - message: "b" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - expect(result.output.unifiedAgentSpec.graph.nodes.length).toBeGreaterThan( - 0 - ); - }); - - it('compiles echo task expression with a2a namespace functions to a2a_ underscore form', () => { - const source = ` -config: - agent_name: "echo-task" - -variables: - msg: mutable string = "" - -trigger t: - kind: "a2a" - target: "brokers://echo-task/a2a" - on_message: -> transition to @echo.reply - -echo reply: - kind: "a2a:response" - task: a2a.task({state:"completed",message:a2a.message({parts:[a2a.textPart("hello")]})}) -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const echoNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'reply' - ) as Record | undefined; - expect(echoNode).toBeDefined(); - - const tools = (echoNode?.tools as Array>) ?? []; - const stateUpdates = - (tools[0]?.['state-updates'] as Array>) ?? []; - const taskValue = stateUpdates.find(s => '__reply_value' in s) - ?.__reply_value as string; - expect(taskValue).toBeDefined(); - expect(taskValue).not.toContain('template::'); - expect(taskValue).toContain('a2a_task('); - expect(taskValue).toContain('a2a_message('); - expect(taskValue).toContain('a2a_textPart('); - expect(taskValue).toContain('state='); - expect(taskValue).toContain('message='); - expect(taskValue).toContain('parts='); - expect(taskValue).not.toContain('{state'); - }); - - it('compiles a2a.X() without @ prefix to a2a_X() in executor expressions', () => { - const source = ` -config: - agent_name: "a2a-no-at" - -variables: - result: mutable string = "" - -trigger t: - kind: "a2a" - target: "brokers://a2a-no-at/a2a" - on_message: -> transition to @executor.step - -executor step: - do: -> - set @variables.result = a2a.message(a2a.textPart("test")) -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - - const node = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'step' - ) as Record | undefined; - expect(node).toBeDefined(); - - const tools = (node?.tools as Array>) ?? []; - const stateUpdates = - (tools[0]?.['state-updates'] as Array>) ?? []; - const expr = stateUpdates.find(s => 'result' in s)?.result as string; - expect(expr).toContain('a2a_message('); - expect(expr).toContain('a2a_textPart('); - expect(expr).not.toContain('a2a.message'); - expect(expr).not.toContain('a2a.textPart'); - }); - - it('compiles executor with uuid() function call', () => { - const source = ` -config: - agent_name: "fn-call" - -variables: - id: mutable string = "" - -trigger t: - kind: "a2a" - target: "brokers://fn-call/a2a" - on_message: -> transition to @executor.step - -executor step: - do: -> - set @variables.id = uuid() -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - - const node = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'step' - ) as Record | undefined; - expect(node).toBeDefined(); - - const tools = (node?.tools as Array>) ?? []; - const stateUpdates = - (tools[0]?.['state-updates'] as Array>) ?? []; - const idExpr = stateUpdates.find(s => 'id' in s)?.id as string; - expect(idExpr).toBe('uuid()'); - }); - - it('matches compiled YAML for customer-support-netwrok example fixture', async () => { - const agentPath = resolve( - __dirname, - './resources/agentfabric-customer-support-netwrok.agent' - ); - const snapshotPath = resolve( - __dirname, - './resources/agentfabric-customer-support-netwrok.yaml' - ); - const source = readFileSync(agentPath, 'utf8'); - - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - await expect(YAML.stringify(result.output)).toMatchFileSnapshot( - snapshotPath - ); - }); - - it('compiles @echo..input to state._node_input in echo task expression', () => { - const source = ` -config: - agent_name: "input-ref" - -trigger t: - kind: "a2a" - target: "brokers://input-ref/a2a" - on_message: -> transition to @echo.reply - -echo reply: - kind: "a2a:response" - task: a2a.task({state:"completed",message:a2a.message({parts:[a2a.textPart(@echo.reply.input)]})}) -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const echoNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'reply' - ) as Record | undefined; - expect(echoNode).toBeDefined(); - - const tools = (echoNode?.tools as Array>) ?? []; - const stateUpdates = - (tools[0]?.['state-updates'] as Array>) ?? []; - const taskValue = stateUpdates.find(s => '__reply_value' in s) - ?.__reply_value as string; - expect(taskValue).toBeDefined(); - expect(taskValue).toContain('state._node_input'); - }); - - it('compiles @executor..input to state._node_input in executor do expression', () => { - const source = ` -config: - agent_name: "exec-input" - -variables: - result: mutable string = "" - -trigger t: - kind: "a2a" - target: "brokers://exec-input/a2a" - on_message: -> transition to @executor.step - -executor step: - do: -> - set @variables.result = @executor.step.input -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - - const node = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'step' - ) as Record | undefined; - expect(node).toBeDefined(); - - const tools = (node?.tools as Array>) ?? []; - const stateUpdates = - (tools[0]?.['state-updates'] as Array>) ?? []; - const expr = stateUpdates.find(s => 'result' in s)?.result as string; - expect(expr).toBe('state._node_input'); - }); - - it('parses and compiles echo node with spread expression in task field', () => { - const source = ` -config: - agent_name: "spread_test" - -trigger t: - kind: "a2a" - target: "brokers://spread_test/a2a" - on_message: -> transition to @echo.a2a_response - -echo a2a_response: - kind: "a2a:response" - task: a2a_parts(*@variables.artifacts) - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - // Compilation should succeed without errors - expect( - result.diagnostics.filter(d => d.severity === 1 /* error */) - ).toHaveLength(0); - - // Verify the parsed task expression round-trips through toPlainData - const echoEntries = ast.echo as unknown as Map< - string, - Record - >; - const responseEntry = echoEntries.get('a2a_response')!; - expect(responseEntry).toBeDefined(); - - const taskPlain = toPlainData(responseEntry.task); - expect(taskPlain).toBe('a2a_parts(*@variables.artifacts)'); - }); - - it('parses echo node with spread inside list literal in task field', () => { - const source = ` -config: - agent_name: "spread_list_test" - -trigger t: - kind: "a2a" - target: "brokers://spread_list_test/a2a" - on_message: -> transition to @echo.resp - -echo resp: - kind: "a2a:response" - task: make_list([*@variables.parts, "extra"]) - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect( - result.diagnostics.filter(d => d.severity === 1 /* error */) - ).toHaveLength(0); - - const echoEntries = ast.echo as unknown as Map< - string, - Record - >; - const responseEntry = echoEntries.get('resp')!; - expect(responseEntry).toBeDefined(); - - const taskPlain = toPlainData(responseEntry.task); - expect(taskPlain).toBe('make_list([*@variables.parts, "extra"])'); - }); - - it('emits ActionDefinition for mcp:tool actions so executor nodes can resolve refs', () => { - const source = ` -config: - agent_name: "mcp-executor" - -actions: - lookup: - label: "KB Lookup" - description: "Searches knowledge base articles" - target: "mcp://kb_connection" - kind: "mcp:tool" - tool_name: "search_articles" - -variables: - query: mutable string = "" - -trigger t: - kind: "a2a" - target: "brokers://mcp-executor/a2a" - on_message: -> transition to @executor.run_lookup - -executor run_lookup: - do: -> - run @actions.lookup - with query = @variables.query - on_exit: -> transition to @echo.done - -echo done: - kind: "a2a:response" - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const mcpDef = result.output.unifiedAgentSpec.definitions?.find( - (d: Definition) => d.name === 'lookup-action' - ) as Record | undefined; - expect(mcpDef).toBeDefined(); - expect(mcpDef?.type).toBe(ObjectTypes.ACTION); - expect(mcpDef?.client).toBe('lookup-client'); - expect(mcpDef?.label).toBe('KB Lookup'); - expect(mcpDef?.description).toBe('Searches knowledge base articles'); - expect(mcpDef?.['invocation-target-type']).toBe('mcp'); - expect(mcpDef?.['invocation-target-name']).toBe('search_articles'); - - const metadata = mcpDef?.metadata as Record | undefined; - expect(metadata?.protocol).toBe('mcp'); - expect(metadata?.connection).toBe('kb_connection'); - expect(metadata?.tool_name).toBe('search_articles'); - - const executorNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'run_lookup' - ) as Record | undefined; - expect(executorNode).toBeDefined(); - const tools = (executorNode?.tools as Array>) ?? []; - const toolRef = tools.find(t => t.ref === 'lookup-action'); - expect(toolRef).toBeDefined(); - expect(toolRef?.type).toBe(ObjectTypes.ACTION); - }); - - it('matches compiled YAML for it-help-investigation fixture', async () => { - const agentPath = resolve( - __dirname, - './resources/it-help-investigation.agent' - ); - const snapshotPath = resolve( - __dirname, - './resources/it-help-investigation.yaml' - ); - const source = readFileSync(agentPath, 'utf8'); - - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - await expect(YAML.stringify(result.output)).toMatchFileSnapshot( - snapshotPath - ); - }); - - it('emits ActionDefinition for mcp:tool with correct defaults when label/description are omitted', () => { - const source = ` -config: - agent_name: "mcp-defaults" - -actions: - my_tool: - target: "mcp://server" - kind: "mcp:tool" - tool_name: "do_thing" - -trigger t: - kind: "a2a" - target: "brokers://mcp-defaults/a2a" - on_message: -> transition to @echo.done - -echo done: - kind: "a2a:response" - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const mcpDef = result.output.unifiedAgentSpec.definitions?.find( - (d: Definition) => d.name === 'my_tool-action' - ) as Record | undefined; - expect(mcpDef).toBeDefined(); - expect(mcpDef?.label).toBe('my_tool-action'); - expect(mcpDef?.description).toBe('MCP tool: my_tool'); - expect(mcpDef?.['invocation-target-name']).toBe('do_thing'); - }); - - it('lowercases http_headers keys in executor run blocks', () => { - const source = ` -config: - agent_name: "exec-headers" - -llm: - g: - target: "llm://openai" - kind: "OpenAI" - model: "gpt-4o-mini" - -actions: - billing_agent: - target: "a2a://billing_connection" - kind: "a2a:send_message" - -trigger t: - kind: "a2a" - target: "brokers://exec-headers/a2a" - on_message: -> transition to @executor.run_billing - -executor run_billing: - do: - run @actions.billing_agent - with http_headers = {"X-API-Key": "key-123", "Authorization": "Bearer exec-token"} - on_exit: -> transition to @echo.done - -echo done: - kind: "a2a:response" - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const runNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'run_billing' - ) as Record | undefined; - expect(runNode).toBeDefined(); - const tools = (runNode?.tools as Array>) ?? []; - expect(tools.length).toBeGreaterThan(0); - - const billingTool = tools.find(t => - (t.ref as string)?.includes('billing_agent') - ); - expect(billingTool).toBeDefined(); - const bindings = (billingTool?.['bound-inputs'] ?? {}) as Record< - string, - unknown - >; - const headers = bindings.http_headers as string; - expect(headers).toBeDefined(); - expect(headers).toContain('"x-api-key"'); - expect(headers).not.toMatch(/"X-API-Key"/); - expect(headers).toContain('"authorization"'); - expect(headers).not.toMatch(/"Authorization"/); - }); - - it('does not warn when with params match declared inputs', () => { - const source = ` -config: - agent_name: "lint-ok" - -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: {} - bar: {} - -trigger t: - kind: "a2a" - target: "brokers://lint-ok/a2a" - on_message: -> transition to @subagent.worker - -subagent worker: - description: "lint ok" - llm: @llm.g - reasoning: - instructions: -> go - actions: - invoke: @actions.my_tool - with foo = "value_a" - with bar = "value_b" - on_exit: -> transition to @echo.done - -echo done: - kind: "a2a:response" - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - }); - - it('does not warn when http_headers is used without being declared in inputs', () => { - const source = ` -config: - agent_name: "lint-implicit" - -llm: - g: - target: "llm://openai" - kind: "OpenAI" - model: "gpt-4o-mini" - -actions: - my_tool: - target: "mcp://conn" - kind: "mcp:tool" - tool_name: "tool" - -trigger t: - kind: "a2a" - target: "brokers://lint-implicit/a2a" - on_message: -> transition to @subagent.worker - -subagent worker: - description: "implicit http_headers" - llm: @llm.g - reasoning: - instructions: -> go - actions: - invoke: @actions.my_tool - with http_headers = {"X-Token": "abc"} - on_exit: -> transition to @echo.done - -echo done: - kind: "a2a:response" - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - }); - - it('wraps system.node_outputs attribute access with parse_json in router enabled conditions', () => { - const source = ` -config: - agent_name: "routing" - -trigger t: - kind: "a2a" - target: "brokers://routing/a2a" - on_message: -> transition to @subagent.classify - -llm: - g: - target: "llm://openai" - kind: "OpenAI" - model: "gpt-4o-mini" - -subagent classify: - description: "classify" - llm: @llm.g - reasoning: - instructions: -> classify - on_exit: -> transition to @router.route - -router route: - routes: - - target: @echo.billing - when: @subagent.classify.output.category == "billing" - - target: @echo.tech - when: @subagent.classify.output.category == "technical" - otherwise: - target: @echo.general - -echo billing: - kind: "a2a:response" - message: "billing" - -echo tech: - kind: "a2a:response" - message: "tech" - -echo general: - kind: "a2a:response" - message: "general" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const routerNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'route' - ) as Record | undefined; - expect(routerNode).toBeDefined(); - - const onExit = routerNode?.['on-exit'] as - | Array> - | undefined; - expect(onExit).toBeDefined(); - expect(onExit!.length).toBeGreaterThanOrEqual(2); - - expect(onExit![0].enabled).toBe( - 'parse_json(system.node_outputs[\'classify\']).category == "billing"' - ); - expect(onExit![1].enabled).toBe( - 'parse_json(system.node_outputs[\'classify\']).category == "technical"' - ); - }); - - it('preserves hyphens in router when conditions using bracket access', () => { - const source = ` -config: - agent_name: "header-router" - -trigger t: - kind: "a2a" - target: "brokers://header-router/a2a" - on_message: -> transition to @router.check - -router check: - routes: - - target: @echo.slack - when: @request.headers["Slack-UUID"] != "" - otherwise: - target: @echo.fallback - -echo slack: - kind: "a2a:response" - message: "slack" - -echo fallback: - kind: "a2a:response" - message: "fallback" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const routerNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'check' - ) as Record | undefined; - expect(routerNode).toBeDefined(); - - const onExit = routerNode?.['on-exit'] as - | Array> - | undefined; - expect(onExit).toBeDefined(); - - expect(onExit![0].enabled).toBe( - 'state.request.headers[lower("Slack-UUID")] != ""' - ); - }); - - it('wraps system.node_outputs attribute access with parse_json in executor bound-inputs', () => { - const source = ` -config: - agent_name: "bound" - -trigger t: - kind: "a2a" - target: "brokers://bound/a2a" - on_message: -> transition to @subagent.analyze - -llm: - g: - target: "llm://openai" - kind: "OpenAI" - model: "gpt-4o-mini" - -subagent analyze: - description: "analyze" - llm: @llm.g - reasoning: - instructions: -> analyze - on_exit: -> transition to @executor.step - -actions: - my_tool: - kind: "mcp:tool" - connection: "conn" - tool_name: "do_something" - inputs: - ticket_id: - type: "string" - -executor step: - do: -> - run @actions.my_tool - with ticket_id = @subagent.analyze.output.ticket_id - on_exit: -> transition to @echo.done - -echo done: - kind: "a2a:response" - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const execNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'step' - ) as Record | undefined; - expect(execNode).toBeDefined(); - - const tools = (execNode?.tools as Array>) ?? []; - const actionTool = tools.find(t => t.ref === 'my_tool-action'); - expect(actionTool).toBeDefined(); - - const boundInputs = actionTool?.['bound-inputs'] as - | Record - | undefined; - expect(boundInputs).toBeDefined(); - expect(boundInputs!.ticket_id).toBe( - "parse_json(system.node_outputs['analyze']).ticket_id" - ); - }); - - it('does not wrap bare system.node_outputs references without attribute access', () => { - const source = ` -config: - agent_name: "bare" - -trigger t: - kind: "a2a" - target: "brokers://bare/a2a" - on_message: -> transition to @subagent.agent - -llm: - g: - target: "llm://openai" - kind: "OpenAI" - model: "gpt-4o-mini" - -subagent agent: - description: "agent" - llm: @llm.g - reasoning: - instructions: -> do it - on_exit: -> transition to @echo.reply - -echo reply: - kind: "a2a:response" - message: "{{@subagent.agent.output}}" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const echoNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'reply' - ) as Record | undefined; - expect(echoNode).toBeDefined(); - - const tools = (echoNode?.tools as Array>) ?? []; - const identityTool = tools.find(t => t.ref === 'IdentityAction'); - expect(identityTool).toBeDefined(); - - const stateUpdates = identityTool?.['state-updates'] as - | Array> - | undefined; - expect(stateUpdates).toBeDefined(); - - const valueUpdate = stateUpdates![0]; - const valueStr = Object.values(valueUpdate)[0]; - expect(valueStr).toContain("system.node_outputs['agent']"); - expect(valueStr).not.toContain('parse_json'); - }); - - it('injects _handoff_source breadcrumb on generator after-reasoning handoff', () => { - const source = ` -config: - agent_name: "gen-breadcrumb" - -llm: - default: - target: "llm://conn" - kind: "OpenAI" - model: "gpt-4" - -trigger t: - kind: "a2a" - target: "brokers://gen-breadcrumb/a2a" - on_message: -> transition to @generator.step - -generator step: - llm: @llm.default - prompt: -> | hello - on_exit: -> - transition to @echo.done - -echo done: - kind: "a2a:response" - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const genNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'step' - ) as Record | undefined; - expect(genNode).toBeDefined(); - expect(genNode!.type).toBe(ObjectTypes.AGENT); - - const afterReasoning = genNode!['after-reasoning'] as Array< - Record - >; - expect(afterReasoning).toBeDefined(); - expect(afterReasoning).toHaveLength(1); - - const handoff = afterReasoning[0]; - expect(handoff.type).toBe('handoff'); - expect(handoff.target).toBe('done'); - - const stateUpdates = handoff['state-updates'] as Array< - Record - >; - expect(stateUpdates).toBeDefined(); - expect(stateUpdates).toContainEqual({ _handoff_source: "'step'" }); - }); - - it('injects _handoff_source breadcrumb on executor on-exit handoff', () => { - const source = ` -config: - agent_name: "exec-breadcrumb" - -actions: - tool1: - target: "mcp://conn" - kind: "mcp:tool" - tool_name: "do_thing" - -trigger t: - kind: "a2a" - target: "brokers://exec-breadcrumb/a2a" - on_message: -> transition to @executor.step - -executor step: - do: -> - run @actions.tool1 - on_exit: -> - transition to @echo.done - -echo done: - kind: "a2a:response" - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const execNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'step' - ) as Record | undefined; - expect(execNode).toBeDefined(); - - const onExit = execNode!['on-exit'] as Array>; - expect(onExit).toBeDefined(); - expect(onExit).toHaveLength(1); - - const handoff = onExit[0]; - expect(handoff.type).toBe('handoff'); - const stateUpdates = handoff['state-updates'] as Array< - Record - >; - expect(stateUpdates).toContainEqual({ _handoff_source: "'step'" }); - }); - - it('does not inject _handoff_source on non-producing nodes', () => { - const source = ` -config: - agent_name: "no-breadcrumb" - -trigger t: - kind: "a2a" - target: "brokers://no-breadcrumb/a2a" - on_message: -> transition to @router.decide - -router decide: - routes: - - target: @echo.a - when: "true" - otherwise: - target: @echo.b - -echo a: - kind: "a2a:response" - message: "a" - -echo b: - kind: "a2a:response" - message: "b" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const routerNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'decide' - ) as Record | undefined; - expect(routerNode).toBeDefined(); - - const onExit = routerNode!['on-exit'] as Array>; - expect(onExit).toBeDefined(); - for (const handoff of onExit) { - expect(handoff['state-updates']).toBeUndefined(); - } - }); - - it('prepends on-init _node_input lookup when node references state._node_input', () => { - const source = ` -config: - agent_name: "on-init-lookup" - -llm: - default: - target: "llm://conn" - kind: "OpenAI" - model: "gpt-4" - -trigger t: - kind: "a2a" - target: "brokers://on-init-lookup/a2a" - on_message: -> transition to @generator.gen - -generator gen: - llm: @llm.default - prompt: -> | hello - on_exit: -> - transition to @echo.reply - -echo reply: - kind: "a2a:response" - task: a2a.task({state:"completed",message:a2a.message({parts:[a2a.textPart(@echo.reply.input)]})}) -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const echoNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'reply' - ) as Record | undefined; - expect(echoNode).toBeDefined(); - - const onInit = echoNode!['on-init'] as Array>; - expect(onInit).toBeDefined(); - expect(onInit.length).toBeGreaterThanOrEqual(1); - - const lookup = onInit[0]; - expect(lookup.type).toBe('action'); - expect(lookup.ref).toBe('IdentityAction'); - const updates = lookup['state-updates'] as Array>; - expect(updates).toContainEqual({ - _node_input: "get(system.node_outputs, state._handoff_source, '')", - }); - }); - - it('places _node_input lookup before normalize_headers on initial node', () => { - const source = ` -config: - agent_name: "init-order" - -llm: - default: - target: "llm://conn" - kind: "OpenAI" - model: "gpt-4" - -trigger t: - kind: "a2a" - target: "brokers://init-order/a2a" - on_message: -> - transition to @echo.first - -echo first: - kind: "a2a:response" - task: a2a.task({state:"completed",message:a2a.message({parts:[a2a.textPart(@echo.first.input)]})}) -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const node = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'first' - ) as Record | undefined; - expect(node).toBeDefined(); - - const onInit = node!['on-init'] as Array>; - expect(onInit).toBeDefined(); - expect(onInit.length).toBe(2); - - const lookupUpdates = onInit[0]['state-updates'] as Array< - Record - >; - expect(lookupUpdates[0]).toHaveProperty('_node_input'); - - const normalizeUpdates = onInit[1]['state-updates'] as Array< - Record - >; - expect(normalizeUpdates[0]).toHaveProperty('request'); - }); - - it('adds _handoff_source and _node_input state variables when tracking is injected', () => { - const source = ` -config: - agent_name: "state-vars" - -llm: - default: - target: "llm://conn" - kind: "OpenAI" - model: "gpt-4" - -trigger t: - kind: "a2a" - target: "brokers://state-vars/a2a" - on_message: -> - transition to @generator.gen - -generator gen: - llm: @llm.default - prompt: -> | hello - on_exit: -> - transition to @echo.done - -echo done: - kind: "a2a:response" - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const stateVars = result.output.unifiedAgentSpec.graph[ - 'state-variables' - ] as unknown as Array>; - const varNames = stateVars.map(v => v.name); - expect(varNames).toContain('_handoff_source'); - expect(varNames).toContain('_node_input'); - - const handoffVar = stateVars.find(v => v.name === '_handoff_source')!; - expect(handoffVar['data-type']).toBe('string'); - expect(handoffVar.default).toBeNull(); - }); - - it('omits tracking state variables when no producing nodes exist', () => { - const source = ` -config: - agent_name: "no-tracking" - -trigger t: - kind: "a2a" - target: "brokers://no-tracking/a2a" - on_message: -> transition to @echo.done - -echo done: - kind: "a2a:response" - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const stateVars = result.output.unifiedAgentSpec.graph[ - 'state-variables' - ] as unknown as Array>; - const varNames = stateVars.map(v => v.name); - expect(varNames).not.toContain('_handoff_source'); - expect(varNames).not.toContain('_node_input'); - }); - - it('emits result["content"] for mcp:tool and result["result"] for a2a:send_message', () => { - const source = ` -config: - agent_name: "result-field" - -actions: - mcp_action: - target: "mcp://conn" - kind: "mcp:tool" - tool_name: "search" - a2a_action: - target: "a2a://conn" - kind: "a2a:send_message" - -trigger t: - kind: "a2a" - target: "brokers://result-field/a2a" - on_message: -> - transition to @executor.mcp_step - -executor mcp_step: - do: -> - run @actions.mcp_action - on_exit: -> - transition to @executor.a2a_step - -executor a2a_step: - do: -> - run @actions.a2a_action - on_exit: -> - transition to @echo.done - -echo done: - kind: "a2a:response" - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const mcpNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'mcp_step' - ) as Record | undefined; - const a2aNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'a2a_step' - ) as Record | undefined; - expect(mcpNode).toBeDefined(); - expect(a2aNode).toBeDefined(); - - const mcpTools = mcpNode!.tools as Array>; - const mcpUpdates = mcpTools.flatMap( - t => (t['state-updates'] as Array>) ?? [] - ); - const mcpOutputExpr = mcpUpdates.find(s => 'outputs' in s) - ?.outputs as string; - expect(mcpOutputExpr).toContain('result["content"]'); - - const a2aTools = a2aNode!.tools as Array>; - const a2aUpdates = a2aTools.flatMap( - t => (t['state-updates'] as Array>) ?? [] - ); - const a2aOutputExpr = a2aUpdates.find(s => 'outputs' in s) - ?.outputs as string; - expect(a2aOutputExpr).toContain('result["result"]'); - }); - - it('buildOnInit emits type: action on the IdentityAction reference', () => { - const source = ` -config: - agent_name: "init-type" - -trigger t: - kind: "a2a" - target: "brokers://init-type/a2a" - on_message: -> transition to @echo.first - -echo first: - kind: "a2a:response" - message: "ok" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - expect(result.diagnostics).toHaveLength(0); - - const node = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'first' - ) as Record | undefined; - expect(node).toBeDefined(); - - const onInit = node!['on-init'] as Array>; - expect(onInit).toBeDefined(); - - const identityAction = onInit.find(a => a.ref === 'IdentityAction'); - expect(identityAction).toBeDefined(); - expect(identityAction!.type).toBe('action'); - }); -}); diff --git a/dialect/agentfabric/src/tests/llm-value-completions.test.ts b/dialect/agentfabric/src/tests/llm-value-completions.test.ts index 0967fa04..f9ee629d 100644 --- a/dialect/agentfabric/src/tests/llm-value-completions.test.ts +++ b/dialect/agentfabric/src/tests/llm-value-completions.test.ts @@ -2,7 +2,7 @@ * Regression tests: value-position completions for enum-typed fields inside * an LLM entry should include the enum members. * - * Bug (W-22415806): when the cursor is at value position (after `key: `) for + * 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) diff --git a/dialect/agentfabric/src/tests/schema-validation.test.ts b/dialect/agentfabric/src/tests/schema-validation.test.ts deleted file mode 100644 index 0b0bb1b8..00000000 --- a/dialect/agentfabric/src/tests/schema-validation.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { parseDocument, toRecord } from './test-utils.js'; -import { compile } from '../compiler/compile.js'; -import { AgentFabricSchemaInfo } from '../schema.js'; - -describe('AgentFabric Schema Validation', () => { - it('exposes request globalScopes and a2a namespacedFunctions on the schema info', () => { - const gs = AgentFabricSchemaInfo.globalScopes; - expect(gs).toBeDefined(); - expect(gs?.request?.has('payload')).toBe(true); - expect(gs?.request?.has('interface')).toBe(true); - expect(gs?.request?.has('headers')).toBe(true); - - const nf = AgentFabricSchemaInfo.namespacedFunctions; - expect(nf).toBeDefined(); - expect(nf?.a2a?.has('task')).toBe(true); - expect(nf?.a2a?.has('message')).toBe(true); - expect(nf?.a2a?.has('textPart')).toBe(true); - expect(nf?.a2a?.has('parts')).toBe(true); - }); - - it('compiled output has correct top-level structure', () => { - const source = ` -config: - agent_name: "schema-test" - label: "Schema Test" - -trigger t: - kind: "a2a" - target: "brokers://schema-test/a2a" - on_message: -> transition to @echo.done - -echo done: - kind: "a2a:response" - message: "OK" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - const spec = result.output.unifiedAgentSpec; - - // UnifiedAgentSpecification required fields - expect(spec).toHaveProperty('schema-version'); - expect(spec).toHaveProperty('id'); - expect(spec).toHaveProperty('label'); - expect(spec).toHaveProperty('graph'); - expect(spec.graph).toHaveProperty('initial-node'); - expect(spec.graph).toHaveProperty('nodes'); - expect(spec.graph).toHaveProperty('state-variables'); - }); - - it('AgentGraph has all required fields', () => { - const source = ` -config: - agent_name: "graph-test" - -trigger t: - kind: "a2a" - target: "brokers://graph-test/a2a" - on_message: -> transition to @echo.done - -echo done: - kind: "a2a:response" - message: "OK" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - - expect(result.output).toHaveProperty('unifiedAgentSpec'); - expect(result.output).toHaveProperty('llmProviders'); - expect(result.output).toHaveProperty('invokableClients'); - expect(result.output).toHaveProperty('responseNodeNames'); - expect(result.output).toHaveProperty('trigger'); - expect(result.output).toHaveProperty('outputStructures'); - expect(Array.isArray(result.output.llmProviders)).toBe(true); - expect(Array.isArray(result.output.invokableClients)).toBe(true); - expect(Array.isArray(result.output.responseNodeNames)).toBe(true); - expect(result.output.trigger).toEqual({ - id: 't', - kind: 'a2a', - namespace: 'brokers', - target_id: 'graph-test', - on_message: { transition_to: '@echo.done' }, - }); - expect(typeof result.output.outputStructures).toBe('object'); - }); - - it('agent nodes conform to AgentNode schema', () => { - const source = ` -config: - agent_name: "node-test" - -llm: - test_llm: - target: "llm://openai" - kind: "OpenAI" - model: "gpt-4" - -trigger t: - kind: "a2a" - target: "brokers://node-test/a2a" - on_message: -> transition to @orchestrator.main - -orchestrator main: - description: "Test node" - llm: @llm.test_llm - reasoning: - instructions: -> Do the task - on_exit: -> transition to @echo.done - -echo done: - kind: "a2a:response" - message: "OK" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - - const agentNode = result.output.unifiedAgentSpec.graph.nodes.find( - n => n.name === 'main' - ); - expect(agentNode).toBeDefined(); - expect(agentNode).toHaveProperty('type', 'agent'); - expect(agentNode).toHaveProperty('llm'); - expect(agentNode).toHaveProperty('system-prompt'); - }); - - it('definitions include IdentityAction', () => { - const source = ` -config: - agent_name: "def-test" - -trigger t: - kind: "a2a" - target: "brokers://def-test/a2a" - on_message: -> transition to @echo.done - -echo done: - kind: "a2a:response" - message: "OK" -`; - const ast = parseDocument(source); - const result = compile(toRecord(ast)); - - const defs = result.output.unifiedAgentSpec.definitions ?? []; - const identity = defs.find(d => d.name === 'IdentityAction'); - expect(identity).toBeDefined(); - if (identity && 'client' in identity) { - expect(identity.client).toBe('in-built'); - } - }); -}); diff --git a/dialect/agentfabric/src/tests/snippet-indentation.test.ts b/dialect/agentfabric/src/tests/snippet-indentation.test.ts index 7103a4ad..3510509d 100644 --- a/dialect/agentfabric/src/tests/snippet-indentation.test.ts +++ b/dialect/agentfabric/src/tests/snippet-indentation.test.ts @@ -1,7 +1,7 @@ /** * Indentation guardrails for completion snippets in AgentFabric `.agent` files. * - * Bug: W-22181425 — when a multi-line completion snippet is inserted at a + * 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. @@ -106,7 +106,7 @@ function build(...lines: string[]): string { // Scope 1: Action-level fields — primary bug repro // --------------------------------------------------------------------------- -describe('snippet indentation — action-level fields (W-22181425 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: diff --git a/dialect/agentfabric/src/tests/utils.test.ts b/dialect/agentfabric/src/tests/utils.test.ts deleted file mode 100644 index 0dfdbb81..00000000 --- a/dialect/agentfabric/src/tests/utils.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - normalizeId, - iterateCollection, - combineGlobalSystemInstructions, - normalizeTemplate, - toPlainData, -} from '../compiler/utils.js'; -import { - SpreadExpression, - CallExpression, - MemberExpression, - AtIdentifier, - Identifier, -} from '@agentscript/language'; - -describe('compiler utils', () => { - it('normalizeId converts kebab-case to snake_case', () => { - expect(normalizeId('my-node')).toBe('my_node'); - expect(normalizeId('a-b-c')).toBe('a_b_c'); - }); - - it('iterateCollection reads native Map', () => { - const m = new Map>([['a', { x: 1 }]]); - expect(iterateCollection(m)).toEqual([['a', { x: 1 }]]); - }); - - it('combineGlobalSystemInstructions prefers node text over global', () => { - expect(combineGlobalSystemInstructions('Global line.', 'Node line.')).toBe( - 'Node line.' - ); - }); - - it('combineGlobalSystemInstructions returns node-only when global empty', () => { - expect(combineGlobalSystemInstructions('', 'Only node')).toBe('Only node'); - expect(combineGlobalSystemInstructions(undefined, 'Only node')).toBe( - 'Only node' - ); - }); - - it('combineGlobalSystemInstructions returns global-only when node empty', () => { - expect(combineGlobalSystemInstructions('Global only', '')).toBe( - 'Global only' - ); - expect(combineGlobalSystemInstructions('Global only', ' ')).toBe( - 'Global only' - ); - }); - - it('normalizeTemplate rewrites {!@request.*} to {{state.request.*}}', () => { - expect( - normalizeTemplate('| {!@request.payload.message.parts[0].text}') - ).toBe('| {{state.request.payload.message.parts[0].text}}'); - }); - - it('normalizeTemplate rewrites {!@variables.*} to {{state.*}}', () => { - expect(normalizeTemplate('| {!@variables.customerMessage}')).toBe( - '| {{state.customerMessage}}' - ); - }); - - it('normalizeTemplate rewrites bare @generator..output without parse_json', () => { - expect( - normalizeTemplate('{{@generator.generateHrSlackUpdateMessage.output}}') - ).toBe("{{system.node_outputs['generateHrSlackUpdateMessage']}}"); - }); - - it('normalizeTemplate wraps @generator..output. with parse_json', () => { - expect(normalizeTemplate('{{@generator.myNode.output.summary}}')).toBe( - "{{parse_json(system.node_outputs['myNode']).summary}}" - ); - }); - - it('normalizeTemplate wraps @subagent..output with nested attrs via parse_json', () => { - expect( - normalizeTemplate('{{@subagent.analysis.output.field.nested}}') - ).toBe("{{parse_json(system.node_outputs['analysis']).field.nested}}"); - }); - - it('normalizeTemplate rewrites @executor..output to state.outputs', () => { - expect(normalizeTemplate('{{@executor.execStep.output}}')).toBe( - "{{state.outputs['execStep']}}" - ); - }); - - it('normalizeTemplate does not wrap @executor..output. with parse_json', () => { - expect(normalizeTemplate('{{@executor.execStep.output.field}}')).toBe( - "{{state.outputs['execStep'].field}}" - ); - }); - - it('normalizeTemplate marks deprecated @outputs alias as error marker', () => { - expect( - normalizeTemplate('{{@outputs.generateHrSlackUpdateMessage}}') - ).toContain('__ERROR__outputs_alias_not_supported__'); - }); - - it('normalizeTemplate rewrites @echo..input to state._node_input', () => { - expect(normalizeTemplate('{{@echo.send_response.input}}')).toBe( - '{{state._node_input}}' - ); - }); - - it('normalizeTemplate rewrites @executor..input to state._node_input', () => { - expect(normalizeTemplate('{{@executor.billing_handler.input}}')).toBe( - '{{state._node_input}}' - ); - }); - - it('normalizeTemplate rewrites @generator..input to state._node_input', () => { - expect(normalizeTemplate('{{@generator.responder.input}}')).toBe( - '{{state._node_input}}' - ); - }); - - it('normalizeTemplate rewrites @orchestrator..input to state._node_input', () => { - expect(normalizeTemplate('{{@orchestrator.main.input}}')).toBe( - '{{state._node_input}}' - ); - }); - - it('normalizeTemplate rewrites @router..input to state._node_input', () => { - expect(normalizeTemplate('{{@router.classify.input}}')).toBe( - '{{state._node_input}}' - ); - }); -}); - -describe('toPlainData', () => { - it('emits SpreadExpression via __emit', () => { - const spread = new SpreadExpression( - new MemberExpression(new AtIdentifier('variables'), 'artifacts') - ); - expect(toPlainData(spread)).toBe('*@variables.artifacts'); - }); - - it('emits CallExpression with spread arg via __emit', () => { - const call = new CallExpression(new Identifier('a2a_parts'), [ - new SpreadExpression( - new MemberExpression(new AtIdentifier('variables'), 'artifacts') - ), - ]); - expect(toPlainData(call)).toBe('a2a_parts(*@variables.artifacts)'); - }); - - it('emits plain CallExpression with named args via __emit', () => { - const call = new CallExpression(new Identifier('a2a_task'), [ - new Identifier('state'), - ]); - expect(toPlainData(call)).toBe('a2a_task(state)'); - }); -}); diff --git a/packages/language/src/core/analysis/completions.ts b/packages/language/src/core/analysis/completions.ts index b565ee6f..9fb47b09 100644 --- a/packages/language/src/core/analysis/completions.ts +++ b/packages/language/src/core/analysis/completions.ts @@ -584,7 +584,7 @@ export function getFieldCompletions( * * Shared between `getFieldCompletions` (via `inferBlockFromIndentation`) and * `getValueCompletions`. Walking parents twice with two slightly divergent - * implementations was the bug source for W-22415806; both callers now use + * implementations was the bug source; both callers now use * this single resolver. */ interface IndentSchemaContext { From 3622be1166ff30e8dd8793bb5c1f9e120d4071d5 Mon Sep 17 00:00:00 2001 From: "ashwin.venkataraman" Date: Mon, 8 Jun 2026 15:12:07 +0530 Subject: [PATCH 4/5] revert removal of license header --- dialect/agentfabric/src/index.ts | 7 +++++++ dialect/agentfabric/src/lint/index.ts | 7 +++++++ .../agentfabric/src/lint/passes/agentfabric-semantic.ts | 7 +++++++ dialect/agentfabric/src/lint/passes/index.ts | 7 +++++++ .../agentfabric/src/lint/passes/rules/agentic-llm-rules.ts | 7 +++++++ .../agentfabric/src/lint/passes/rules/connection-rules.ts | 7 +++++++ dialect/agentfabric/src/lint/passes/rules/echo-rules.ts | 7 +++++++ dialect/agentfabric/src/lint/passes/rules/on-exit-rules.ts | 7 +++++++ .../src/lint/passes/rules/output-structure-rules.ts | 7 +++++++ .../src/lint/passes/rules/reasoning-instructions-rules.ts | 7 +++++++ dialect/agentfabric/src/lint/passes/rules/shared.ts | 7 +++++++ dialect/agentfabric/src/lint/passes/rules/switch-rules.ts | 7 +++++++ dialect/agentfabric/src/lint/passes/rules/trigger-rules.ts | 7 +++++++ .../passes/suppress-tools-namespace-undefined-reference.ts | 7 +++++++ dialect/agentfabric/src/schema.ts | 7 +++++++ dialect/agentfabric/src/tests/dialect.test.ts | 7 +++++++ dialect/agentfabric/src/tests/lint.test.ts | 7 +++++++ dialect/agentfabric/src/tests/test-utils.ts | 7 +++++++ 18 files changed, 126 insertions(+) diff --git a/dialect/agentfabric/src/index.ts b/dialect/agentfabric/src/index.ts index 6fcc0aba..a13f3283 100644 --- a/dialect/agentfabric/src/index.ts +++ b/dialect/agentfabric/src/index.ts @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + import type { DialectConfig, InferFields, diff --git a/dialect/agentfabric/src/lint/index.ts b/dialect/agentfabric/src/lint/index.ts index 1e237dca..649865b1 100644 --- a/dialect/agentfabric/src/lint/index.ts +++ b/dialect/agentfabric/src/lint/index.ts @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + import { LintEngine } from '@agentscript/language'; import { defaultRules } from './passes/index.js'; import { AGENTFABRIC_LINT_SOURCE } from './passes/rules/shared.js'; diff --git a/dialect/agentfabric/src/lint/passes/agentfabric-semantic.ts b/dialect/agentfabric/src/lint/passes/agentfabric-semantic.ts index bf2f09ab..0ad9ab91 100644 --- a/dialect/agentfabric/src/lint/passes/agentfabric-semantic.ts +++ b/dialect/agentfabric/src/lint/passes/agentfabric-semantic.ts @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + import { storeKey } from '@agentscript/language'; import type { LintPass, PassStore } from '@agentscript/language'; import { checkActionBindingRules } from './rules/action-binding-rules.js'; diff --git a/dialect/agentfabric/src/lint/passes/index.ts b/dialect/agentfabric/src/lint/passes/index.ts index 64645fef..7a9e5621 100644 --- a/dialect/agentfabric/src/lint/passes/index.ts +++ b/dialect/agentfabric/src/lint/passes/index.ts @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + import type { LintPass } from '@agentscript/language'; import { symbolTableAnalyzer, diff --git a/dialect/agentfabric/src/lint/passes/rules/agentic-llm-rules.ts b/dialect/agentfabric/src/lint/passes/rules/agentic-llm-rules.ts index 5d88f32a..18e22cf0 100644 --- a/dialect/agentfabric/src/lint/passes/rules/agentic-llm-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/agentic-llm-rules.ts @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + import { isNamedMap } from '@agentscript/language'; import { attachError, diff --git a/dialect/agentfabric/src/lint/passes/rules/connection-rules.ts b/dialect/agentfabric/src/lint/passes/rules/connection-rules.ts index adca38d3..63cfd5bc 100644 --- a/dialect/agentfabric/src/lint/passes/rules/connection-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/connection-rules.ts @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + import { isNamedMap } from '@agentscript/language'; import { attachError, extractStringValue, type AstLike } from './shared.js'; diff --git a/dialect/agentfabric/src/lint/passes/rules/echo-rules.ts b/dialect/agentfabric/src/lint/passes/rules/echo-rules.ts index 97e50a3c..f96fa31b 100644 --- a/dialect/agentfabric/src/lint/passes/rules/echo-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/echo-rules.ts @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + import { isNamedMap } from '@agentscript/language'; import { normalizeId } from '../../utils.js'; import { attachError, hasOwnNonNull, type AstLike } from './shared.js'; diff --git a/dialect/agentfabric/src/lint/passes/rules/on-exit-rules.ts b/dialect/agentfabric/src/lint/passes/rules/on-exit-rules.ts index 3ab53d73..30934197 100644 --- a/dialect/agentfabric/src/lint/passes/rules/on-exit-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/on-exit-rules.ts @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + import { isNamedMap } from '@agentscript/language'; import { asStatements, attachError, type AstLike } from './shared.js'; diff --git a/dialect/agentfabric/src/lint/passes/rules/output-structure-rules.ts b/dialect/agentfabric/src/lint/passes/rules/output-structure-rules.ts index f67d079f..0866e4e1 100644 --- a/dialect/agentfabric/src/lint/passes/rules/output-structure-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/output-structure-rules.ts @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + import { isNamedMap } from '@agentscript/language'; import { attachError, diff --git a/dialect/agentfabric/src/lint/passes/rules/reasoning-instructions-rules.ts b/dialect/agentfabric/src/lint/passes/rules/reasoning-instructions-rules.ts index b04590b9..612bef4e 100644 --- a/dialect/agentfabric/src/lint/passes/rules/reasoning-instructions-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/reasoning-instructions-rules.ts @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + import { isNamedMap } from '@agentscript/language'; import { attachError, extractStringValue, type AstLike } from './shared.js'; diff --git a/dialect/agentfabric/src/lint/passes/rules/shared.ts b/dialect/agentfabric/src/lint/passes/rules/shared.ts index 9e13d572..1599d8bf 100644 --- a/dialect/agentfabric/src/lint/passes/rules/shared.ts +++ b/dialect/agentfabric/src/lint/passes/rules/shared.ts @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + import { attachDiagnostic, BinaryExpression, diff --git a/dialect/agentfabric/src/lint/passes/rules/switch-rules.ts b/dialect/agentfabric/src/lint/passes/rules/switch-rules.ts index 66196fd5..7c960014 100644 --- a/dialect/agentfabric/src/lint/passes/rules/switch-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/switch-rules.ts @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + import { isNamedMap } from '@agentscript/language'; import { normalizeId } from '../../utils.js'; import type { PassStore } from '@agentscript/language'; diff --git a/dialect/agentfabric/src/lint/passes/rules/trigger-rules.ts b/dialect/agentfabric/src/lint/passes/rules/trigger-rules.ts index 7581cccd..3fdbf334 100644 --- a/dialect/agentfabric/src/lint/passes/rules/trigger-rules.ts +++ b/dialect/agentfabric/src/lint/passes/rules/trigger-rules.ts @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + import { isNamedMap } from '@agentscript/language'; import { attachError, diff --git a/dialect/agentfabric/src/lint/passes/suppress-tools-namespace-undefined-reference.ts b/dialect/agentfabric/src/lint/passes/suppress-tools-namespace-undefined-reference.ts index 4e123ddc..e373bbac 100644 --- a/dialect/agentfabric/src/lint/passes/suppress-tools-namespace-undefined-reference.ts +++ b/dialect/agentfabric/src/lint/passes/suppress-tools-namespace-undefined-reference.ts @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + import { storeKey, recurseAstChildren } from '@agentscript/language'; import type { LintPass } from '@agentscript/language'; diff --git a/dialect/agentfabric/src/schema.ts b/dialect/agentfabric/src/schema.ts index 5e264e43..be901d62 100644 --- a/dialect/agentfabric/src/schema.ts +++ b/dialect/agentfabric/src/schema.ts @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + import { Block, NamedBlock, diff --git a/dialect/agentfabric/src/tests/dialect.test.ts b/dialect/agentfabric/src/tests/dialect.test.ts index ef8d2f65..b8229e4a 100644 --- a/dialect/agentfabric/src/tests/dialect.test.ts +++ b/dialect/agentfabric/src/tests/dialect.test.ts @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; diff --git a/dialect/agentfabric/src/tests/lint.test.ts b/dialect/agentfabric/src/tests/lint.test.ts index f0e59d2a..6f99ca42 100644 --- a/dialect/agentfabric/src/tests/lint.test.ts +++ b/dialect/agentfabric/src/tests/lint.test.ts @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; diff --git a/dialect/agentfabric/src/tests/test-utils.ts b/dialect/agentfabric/src/tests/test-utils.ts index 1553538b..07174fb3 100644 --- a/dialect/agentfabric/src/tests/test-utils.ts +++ b/dialect/agentfabric/src/tests/test-utils.ts @@ -1,3 +1,10 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * For full license text, see the LICENSE file in the repo root or https://www.apache.org/licenses/LICENSE-2.0 + */ + /** Test utilities for parsing AgentFabric dialect sources. */ import { parse } from '@agentscript/parser'; import { From f2d6c8c4ee9f51ad6b47dc317afb34143d967288 Mon Sep 17 00:00:00 2001 From: "ashwin.venkataraman" Date: Mon, 8 Jun 2026 15:18:07 +0530 Subject: [PATCH 5/5] restore README.md --- dialect/agentfabric/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dialect/agentfabric/README.md b/dialect/agentfabric/README.md index e7205cfa..a80f9d34 100644 --- a/dialect/agentfabric/README.md +++ b/dialect/agentfabric/README.md @@ -45,4 +45,4 @@ pnpm dev # Watch mode ## License -MIT \ No newline at end of file +MIT