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
  •  
  •  
  •  
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ tree-sitter-agentscript/bindings
tree-sitter-agentscript/build
tree-sitter-agentscript/src
apps/docs/docs/api/typedoc-sidebar.cjs
CODEOWNERS
5 changes: 2 additions & 3 deletions apps/ui/src/components/MonacoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,15 +101,14 @@ export function MonacoEditor({
useEffect(() => {
try {
registerAgentScriptLanguage();
setLanguageInitialized(true);
} catch (error) {
console.error(
'[MonacoEditor] Failed to initialize AgentScript language:',
error
);
// Mark as initialized even if it fails so editor can still load
setLanguageInitialized(true);
}
// eslint-disable-next-line react-hooks/set-state-in-effect
setLanguageInitialized(true);
}, []);

// Create editor
Expand Down
10 changes: 6 additions & 4 deletions apps/ui/src/components/builder/fields/TemplateEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,23 +321,25 @@ export function TemplateEditor({
}, []);

// ── Pill edit handlers ──
/* eslint-disable react-hooks/immutability */
const commitPillEdit = useCallback(
(newValue: string) => {
if (!pillEdit || !editorRef.current) return;
const { element } = pillEdit;
const el = pillEdit.element;

if (!newValue.trim()) {
element.remove();
el.remove();
} else {
element.dataset.expr = newValue;
element.textContent = newValue;
el.dataset.expr = newValue;
el.textContent = newValue;
}

setPillEdit(null);
commitContent(editorRef.current, lastRendered, render, onChange);
},
[pillEdit, onChange, render]
);
/* eslint-enable react-hooks/immutability */

const cancelPillEdit = useCallback(() => {
setPillEdit(null);
Expand Down
2 changes: 1 addition & 1 deletion apps/ui/src/components/explorer/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ function TreeNodeItem({
const isGroup = blockType === 'group';
const selected = selectedNodeId === node.id;

// Update isExpanded when defaultExpanded changes
React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsExpanded(defaultExpanded);
}, [defaultExpanded]);

Expand Down
4 changes: 3 additions & 1 deletion apps/ui/src/pages/Component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,9 @@ export function Component() {
const editorContainerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const selectedKindRef = useRef(selectedKind);
selectedKindRef.current = selectedKind;
useEffect(() => {
selectedKindRef.current = selectedKind;
}, [selectedKind]);
const [languageInitialized, setLanguageInitialized] = useState(false);

useEffect(() => {
Expand Down
2 changes: 2 additions & 0 deletions dialect/agentfabric/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
"prebuild": "node ../../scripts/sync-pkg-meta.mjs",
"build": "tsc",
"dev": "tsc --watch",
"pretest": "node ../../scripts/sync-pkg-meta.mjs",
"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"
},
Expand Down
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 @@ -17,6 +17,7 @@ import { checkOnExitRules } from './rules/on-exit-rules.js';
import { checkOutputStructureRules } from './rules/output-structure-rules.js';
import { checkReasoningInstructionsRules } from './rules/reasoning-instructions-rules.js';
import { checkSwitchRules } from './rules/switch-rules.js';
import { checkTerminalStatusRules } from './rules/terminal-status-rules.js';
import { checkTriggerRules } from './rules/trigger-rules.js';

class AgentFabricSemanticPass implements LintPass {
Expand All @@ -35,6 +36,7 @@ class AgentFabricSemanticPass implements LintPass {
checkExecuteRules(root);
checkActionBindingRules(root);
checkCycleRules(root);
checkTerminalStatusRules(root);
}
}

Expand Down
47 changes: 37 additions & 10 deletions dialect/agentfabric/src/lint/passes/rules/echo-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@

import { isNamedMap } from '@agentscript/language';
import { normalizeId } from '../../utils.js';
import { attachError, hasOwnNonNull, type AstLike } from './shared.js';
import { A2A_TASK_STATES, A2A_TERMINAL_STATES } from '../../../schema.js';
import { attachError, extractStringValue, type AstLike } from './shared.js';

const VALID_STATES = new Set<string>(A2A_TASK_STATES);

export { A2A_TERMINAL_STATES as TERMINAL_STATES };

export function checkEchoRules(root: Record<string, unknown>): void {
const echos = root.echo;
Expand All @@ -17,15 +22,37 @@ export function checkEchoRules(root: Record<string, unknown>): void {
if (entry == null || typeof entry !== 'object') continue;
const echoEntry = entry as Record<string, unknown>;
const normalizedName = normalizeId(name);
const hasTask = hasOwnNonNull(echoEntry, 'task');
const hasMessage = hasOwnNonNull(echoEntry, 'message');

if (!hasTask && !hasMessage) {
attachError(
echoEntry as AstLike,
`echo '${normalizedName}' must define either 'task' or 'message'.`,
'echo-task-or-message-required'
);
const kind = extractStringValue(echoEntry.kind);

if (kind === 'a2a:status_update_event') {
validateStatusUpdateEvent(echoEntry, normalizedName);
} else if (kind === 'a2a:artifact_update_event') {
validateArtifactUpdateEvent(echoEntry, normalizedName);
}
}
}

function validateStatusUpdateEvent(
entry: Record<string, unknown>,
name: string
): void {
const state = extractStringValue(entry.state);
if (state !== undefined && !VALID_STATES.has(state)) {
attachError(
entry as AstLike,
`echo '${name}' has invalid state '${state}'. Valid states: ${[...VALID_STATES].join(', ')}.`,
'echo-invalid-state'
);
}
}

function validateArtifactUpdateEvent(
entry: Record<string, unknown>,
_name: string
): void {
// artifact is marked as required in the schema, so missing-field
// validation is handled by the schema layer. No additional custom
// rules needed here at this time.
void _name;
void entry;
}
141 changes: 141 additions & 0 deletions dialect/agentfabric/src/lint/passes/rules/terminal-status-rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { isNamedMap } from '@agentscript/language';
import { extractGraph } from '../../../graph/extractor.js';
import { AgentFabricSchemaInfo, A2A_TERMINAL_STATES } from '../../../schema.js';
import { attachError, extractStringValue, type AstLike } from './shared.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).
*/
export function checkTerminalStatusRules(root: Record<string, unknown>): void {
const { nodes, edges } = extractGraph(root, AgentFabricSchemaInfo);
if (nodes.length === 0) return;

const triggerIds = new Set<string>(
nodes.filter(n => n.namespace === 'trigger').map(n => n.id)
);

const nonTriggerNodes = nodes.filter(n => !triggerIds.has(n.id));
if (nonTriggerNodes.length === 0) return;

// Build forward adjacency and find terminal (leaf) nodes.
const outgoingCount = new Map<string, number>();
for (const node of nonTriggerNodes) outgoingCount.set(node.id, 0);
for (const edge of edges) {
if (!outgoingCount.has(edge.from)) continue;
outgoingCount.set(edge.from, (outgoingCount.get(edge.from) ?? 0) + 1);
}

const terminalNodeIds = nonTriggerNodes
.filter(n => (outgoingCount.get(n.id) ?? 0) === 0)
.map(n => n.id);

if (terminalNodeIds.length === 0) return;

// Collect the set of echo nodes that emit a terminal status update.
const terminalStatusEchoIds = collectTerminalStatusEchoIds(root);

// If the graph already has at least one terminal status echo, check
// that every terminal node can be reached FROM one (i.e., a terminal
// status echo is an ancestor of every leaf node).
// Build reverse adjacency: for each node, which nodes point to it.
const reverseAdj = new Map<string, string[]>();
for (const node of nonTriggerNodes) reverseAdj.set(node.id, []);
for (const edge of edges) {
if (!reverseAdj.has(edge.to)) continue;
reverseAdj.get(edge.to)!.push(edge.from);
}

for (const terminalId of terminalNodeIds) {
if (terminalStatusEchoIds.has(terminalId)) continue;

if (hasAncestorInSet(terminalId, terminalStatusEchoIds, reverseAdj)) {
continue;
}

const astNode = findAstNode(root, terminalId);
if (astNode) {
// TODO: post-GA, improve this diagnostic to show the full branch path
// and highlight which execution paths lack a terminal status update.
const shortName = terminalId.split('.').pop() ?? terminalId;
attachError(
astNode,
`Every execution path must set a terminal task state before ending. ` +
`The branch ending at '${shortName}' has no echo with kind "a2a:status_update_event" ` +
`and a terminal state (TASK_STATE_COMPLETED, TASK_STATE_FAILED, or TASK_STATE_CANCELED).`,
'terminal-requires-status-update'
);
}
}
}

/**
* Collect IDs of echo nodes whose kind is "a2a:status_update_event"
* and whose state is a terminal value.
*/
function collectTerminalStatusEchoIds(
root: Record<string, unknown>
): Set<string> {
const ids = new Set<string>();
const echoEntries = root.echo;
if (!isNamedMap(echoEntries)) return ids;

for (const [name, entry] of echoEntries) {
if (entry == null || typeof entry !== 'object') continue;
const echoEntry = entry as Record<string, unknown>;
const kind = extractStringValue(echoEntry.kind);
if (kind !== 'a2a:status_update_event') continue;
const state = extractStringValue(echoEntry.state);
if (state !== undefined && A2A_TERMINAL_STATES.has(state)) {
ids.add(`echo.${name}`);
}
}
return ids;
}

/**
* BFS backwards from `startId` through reverse edges to check if any
* node in `targetSet` is an ancestor.
*/
function hasAncestorInSet(
startId: string,
targetSet: Set<string>,
reverseAdj: Map<string, string[]>
): boolean {
const visited = new Set<string>();
const queue = [startId];
visited.add(startId);

while (queue.length > 0) {
const current = queue.shift()!;
const predecessors = reverseAdj.get(current) ?? [];
for (const pred of predecessors) {
if (targetSet.has(pred)) return true;
if (!visited.has(pred)) {
visited.add(pred);
queue.push(pred);
}
}
}
return false;
}

function findAstNode(
root: Record<string, unknown>,
nodeId: string
): AstLike | null {
const dotIndex = nodeId.indexOf('.');
if (dotIndex < 0) return null;
const namespace = nodeId.slice(0, dotIndex);
const name = nodeId.slice(dotIndex + 1);
const group = root[namespace];
if (!isNamedMap(group)) return null;
for (const [key, entry] of group as Iterable<[string, unknown]>) {
if (key === name && entry != null && typeof entry === 'object') {
return entry as AstLike;
}
}
return null;
}
Loading
Loading