Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,6 @@ AI_READINESS_JUDGMENT.json

# gitleaks
gitleaks-report.json

# SCS upload manifests (CI build artifacts)
build-artifacts/
4 changes: 2 additions & 2 deletions dialect/agentfabric/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agentscript/agentfabric-dialect",
"version": "1.2.36",
"version": "1.3.3",
"description": "AgentFabric dialect — schema, lint rules, graph extractor, and dialect config",
"type": "module",
"main": "dist/index.js",
Expand Down Expand Up @@ -36,7 +36,7 @@
"@agentscript/parser": "workspace:*",
"tree-sitter": "^0.25.0",
"typescript": "^5.8.3",
"vitest": "^3.2.6"
"vitest": "^4.1.11"
},
"keywords": [
"agentscript",
Expand Down
13 changes: 13 additions & 0 deletions dialect/agentfabric/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,16 @@ export const OUTPUT_JSON_SCHEMA_TYPES = [
// Anchor-free identifier fragment for connection-target URIs; embed inside `^…$`.
export const AGENTFABRIC_IDENTIFIER_PATTERN =
'[a-zA-Z][a-zA-Z0-9_.-]*[a-zA-Z0-9]';

// Anchor-free path fragment for the connection-target portion after the scheme
// (e.g. the `…` in `mcp://…`). Accepts one, two, or three `/`-separated
// identifier segments so that all three connection-reference shapes resolve:
// - bare connection name — `myConnection`
// - same-group qualified reference — `<assetId>/<name>`
// (e.g. `projectA/myConnection`)
// - fully-qualified cross-project reference — `<groupId>/<assetId>/<name>`
// (e.g. `cocacola/projectA/myConnection`)
// Same-group deps accept both the two-segment and three-segment forms. Each
// segment is a standard identifier; `/` is only a separator, so the leading one
// or two qualifier segments are optional. Embed inside `^…$`.
export const AGENTFABRIC_TARGET_PATTERN = `(?:${AGENTFABRIC_IDENTIFIER_PATTERN}/){0,2}${AGENTFABRIC_IDENTIFIER_PATTERN}`;
2 changes: 2 additions & 0 deletions dialect/agentfabric/src/graph/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ export type {

export { getGraph } from './get-graph.js';
export type { Graph, ProtocolNode, ProtocolEdge } from './get-graph.js';

export { buildGraphAdjacency, reachableFrom } from './reachability.js';
61 changes: 61 additions & 0 deletions dialect/agentfabric/src/graph/reachability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* 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 { extractGraph } from './extractor.js';
import { AgentFabricSchemaInfo } from '../schema.js';
import { Namespace } from '../constants.js';

/**
* Adjacency-list view of the agent graph for reachability queries, plus
* the trigger node ids used as BFS seeds.
*/
export function buildGraphAdjacency(root: Record<string, unknown>): {
adjacency: Map<string, string[]>;
triggerIds: string[];
} {
const { nodes, edges } = extractGraph(root, AgentFabricSchemaInfo);
const adjacency = new Map<string, string[]>();
for (const node of nodes) adjacency.set(node.id, []);
for (const edge of edges) {
if (!adjacency.has(edge.from)) continue;
adjacency.get(edge.from)!.push(edge.to);
}

const triggerIds = nodes
.filter(node => node.namespace === Namespace.Trigger)
.map(node => node.id);

return { adjacency, triggerIds };
}

/**
* Breadth-first forward reachability from `seeds`. Nodes in `removed` are
* skipped both as seeds and as traversal targets (used to test reachability
* with selected nodes deleted from the graph).
*/
export function reachableFrom(
seeds: string[],
adjacency: Map<string, string[]>,
removed: Set<string> = new Set()
): Set<string> {
const visited = new Set<string>();
const queue: string[] = [];
for (const seed of seeds) {
if (removed.has(seed) || visited.has(seed)) continue;
visited.add(seed);
queue.push(seed);
}
while (queue.length > 0) {
const current = queue.shift()!;
for (const next of adjacency.get(current) ?? []) {
if (removed.has(next) || visited.has(next)) continue;
visited.add(next);
queue.push(next);
}
}
return visited;
}
2 changes: 2 additions & 0 deletions dialect/agentfabric/src/lint/passes/agentfabric-semantic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ 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 { checkArtifactAfterTerminalRules } from './rules/artifact-after-terminal-rules.js';
import { checkFirstArtifactAppendRules } from './rules/first-artifact-append-rules.js';
import { checkOnExitRules } from './rules/on-exit-rules.js';
import { checkOutputStructureRules } from './rules/output-structure-rules.js';
Expand Down Expand Up @@ -46,6 +47,7 @@ class AgentFabricSemanticPass implements LintPass {
checkSwitchRules(store, root);
checkEchoRules(root);
checkFirstArtifactAppendRules(root);
checkArtifactAfterTerminalRules(root);
checkAgenticLlmRules(root);
checkExecuteRules(root);
checkActionBindingRules(root);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* 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 { A2A_TERMINAL_STATES } from '../../../schema.js';
import { Namespace } from '../../../constants.js';
import { attachWarning, extractStringValue, type AstLike } from './shared.js';
import {
buildGraphAdjacency,
reachableFrom,
} from '../../../graph/reachability.js';

const ARTIFACT_KIND = 'a2a:artifact_update_event';
const STATUS_KIND = 'a2a:status_update_event';
const DIAGNOSTIC_CODE = 'artifact-after-terminal-status';

/**
* Artifact updates that run after a terminal A2A status echo are not
* included in the A2A response — the task is already closed. Warn when
* an `a2a:artifact_update_event` echo is reachable from a terminal
* `a2a:status_update_event` echo on a live execution path.
*
* Cleanup nodes after a terminal status echo are still allowed; only
* artifact echoes are flagged.
*/
export function checkArtifactAfterTerminalRules(
root: Record<string, unknown>
): void {
const echos = root.echo;
if (!isNamedMap(echos)) return;

const { terminalStatusIds, artifactIds, echoEntries } = classifyEchos(echos);
if (terminalStatusIds.size === 0 || artifactIds.size === 0) return;

const { adjacency, triggerIds } = buildGraphAdjacency(root);
if (adjacency.size === 0) return;

const live = reachableFrom(triggerIds, adjacency);
const warned = new Set<string>();

for (const terminalId of terminalStatusIds) {
if (!live.has(terminalId)) continue;

const afterTerminal = reachableFrom([terminalId], adjacency);
for (const artifactId of artifactIds) {
if (warned.has(artifactId)) continue;
if (!afterTerminal.has(artifactId)) continue;
if (!live.has(artifactId)) continue;

const entry = echoEntries.get(artifactId);
if (!entry) continue;

warned.add(artifactId);
attachWarning(
entry,
artifactAfterTerminalMessage(
shortName(artifactId),
shortName(terminalId)
),
DIAGNOSTIC_CODE
);
}
}
}

function artifactAfterTerminalMessage(
artifactName: string,
terminalName: string
): string {
return (
`echo '${artifactName}' emits an artifact update after terminal status ` +
`echo '${terminalName}'. Artifacts must be sent before the task reaches ` +
`a terminal state, or they will not be included in the A2A response.`
);
}

function shortName(nodeId: string): string {
return nodeId.split('.').pop() ?? nodeId;
}

function classifyEchos(echos: Iterable<[string, unknown]>): {
terminalStatusIds: Set<string>;
artifactIds: Set<string>;
echoEntries: Map<string, AstLike>;
} {
const terminalStatusIds = new Set<string>();
const artifactIds = new Set<string>();
const echoEntries = new Map<string, AstLike>();

for (const [name, entry] of echos) {
if (entry == null || typeof entry !== 'object') continue;
const echoEntry = entry as Record<string, unknown>;
const nodeId = `${Namespace.Echo}.${name}`;
echoEntries.set(nodeId, echoEntry as AstLike);

const kind = extractStringValue(echoEntry.kind);
if (kind === ARTIFACT_KIND) {
artifactIds.add(nodeId);
} else if (kind === STATUS_KIND) {
const state = extractStringValue(echoEntry.state);
if (state !== undefined && A2A_TERMINAL_STATES.has(state)) {
terminalStatusIds.add(nodeId);
}
}
}

return { terminalStatusIds, artifactIds, echoEntries };
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
*/

import { isNamedMap } from '@agentscript/language';
import { extractGraph } from '../../../graph/extractor.js';
import { AgentFabricSchemaInfo } from '../../../schema.js';
import { Namespace } from '../../../constants.js';
import { attachError, extractStringValue, type AstLike } from './shared.js';
import {
buildGraphAdjacency,
reachableFrom,
} from '../../../graph/reachability.js';

const ARTIFACT_KIND = 'a2a:artifact_update_event';
const DIAGNOSTIC_CODE = 'echo-first-artifact-append';
Expand Down Expand Up @@ -130,34 +131,6 @@ function resolveArtifactIdentity(entry: Record<string, unknown>): Identity {
return classifyArtifactIdValue(value);
}

/**
* Breadth-first forward reachability from `seeds`, skipping any node in
* `removed` (both as a seed and as a traversal target). Used to test whether
* an echo can be reached without first passing through a same-artifact emit.
*/
function reachableFrom(
seeds: string[],
adjacency: Map<string, string[]>,
removed: Set<string>
): Set<string> {
const visited = new Set<string>();
const queue: string[] = [];
for (const seed of seeds) {
if (removed.has(seed) || visited.has(seed)) continue;
visited.add(seed);
queue.push(seed);
}
while (queue.length > 0) {
const current = queue.shift()!;
for (const next of adjacency.get(current) ?? []) {
if (removed.has(next) || visited.has(next)) continue;
visited.add(next);
queue.push(next);
}
}
return visited;
}

/**
* Prefer squiggling the `append` field itself (precise range); fall back to
* the echo block instance when the field node can't carry diagnostics.
Expand Down Expand Up @@ -188,29 +161,6 @@ function dynamicMessage(name: string): string {
);
}

/**
* Build an adjacency-list representation of the agent graph for reachability
* queries. Returns the adjacency map and the list of trigger node ids.
*/
function buildGraphAdjacency(root: Record<string, unknown>): {
adjacency: Map<string, string[]>;
triggerIds: string[];
} {
const { nodes, edges } = extractGraph(root, AgentFabricSchemaInfo);
const adjacency = new Map<string, string[]>();
for (const node of nodes) adjacency.set(node.id, []);
for (const edge of edges) {
if (!adjacency.has(edge.from)) continue;
adjacency.get(edge.from)!.push(edge.to);
}

const triggerIds = nodes
.filter(node => node.namespace === Namespace.Trigger)
.map(node => node.id);

return { adjacency, triggerIds };
}

/**
* Resolve every artifact echo's identity and build indexes:
* - `identityByNodeId`: maps node id → Identity
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import { Namespace } from '../../../constants.js';
* All terminal branches in a graph MUST contain an echo node with
* kind "a2a:status_update_event" that sets a terminal A2A state
* ("completed", "failed", or "canceled"). The echo need not be the
* leaf node — the graph may continue after it (e.g. for cleanup).
* leaf node — the graph may continue after it for cleanup. Artifact
* updates after a terminal status echo are warned separately
* (`artifact-after-terminal-status`); they are dropped from the A2A
* response once the task is closed.
*/
export function checkTerminalStatusRules(root: Record<string, unknown>): void {
const { nodes, edges } = extractGraph(root, AgentFabricSchemaInfo);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,6 @@ function formatResolutionDiagnostic(
range,
`'${property}' is not defined in ${namespace}`,
referenceName,
property,
suggestion,
result.members
);
Expand All @@ -583,7 +582,6 @@ function formatResolutionDiagnostic(
range,
`'@${namespace}' is not a recognized namespace`,
referenceName,
namespace,
suggestion,
result.knownNamespaces
);
Expand All @@ -594,8 +592,7 @@ function formatResolutionDiagnostic(
range,
`'@${namespace}' cannot be used as a reference. ` +
`This namespace is scoped to its parent block and is not directly referenceable`,
referenceName,
namespace
referenceName
);

case 'colinear-miss': {
Expand All @@ -604,7 +601,6 @@ function formatResolutionDiagnostic(
range,
`'${property}' is not defined in ${namespace}`,
referenceName,
property,
suggestion,
result.members
);
Expand All @@ -616,7 +612,6 @@ function formatResolutionDiagnostic(
range,
`'${property}' is not defined in ${namespace}`,
referenceName,
property,
suggestion,
result.candidates
);
Expand Down
Loading
Loading