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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/docs/docs/api/typedoc-sidebar.cjs
Original file line number Diff line number Diff line change
@@ -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
*/

// @ts-check
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
const typedocSidebar = { items: [{"type":"category","label":"Classes","items":[{"type":"doc","id":"api/classes/WorkerParserManager","label":"WorkerParserManager"}]},{"type":"category","label":"Interfaces","items":[{"type":"doc","id":"api/interfaces/ErrorResult","label":"ErrorResult"},{"type":"doc","id":"api/interfaces/ExtensionManifest","label":"ExtensionManifest"},{"type":"doc","id":"api/interfaces/HighlightCapture","label":"HighlightCapture"},{"type":"doc","id":"api/interfaces/HighlightResult","label":"HighlightResult"},{"type":"doc","id":"api/interfaces/LspExtensionConfig","label":"LspExtensionConfig"},{"type":"doc","id":"api/interfaces/ParseError","label":"ParseError"},{"type":"doc","id":"api/interfaces/ParseResult","label":"ParseResult"},{"type":"doc","id":"api/interfaces/SchemaFieldInfo","label":"SchemaFieldInfo"},{"type":"doc","id":"api/interfaces/SerializedNode","label":"SerializedNode"},{"type":"doc","id":"api/interfaces/ThemeColors","label":"ThemeColors"},{"type":"doc","id":"api/interfaces/TokenStyle","label":"TokenStyle"},{"type":"doc","id":"api/interfaces/VscodeWrapper","label":"VscodeWrapper"}]},{"type":"category","label":"Type Aliases","items":[{"type":"doc","id":"api/type-aliases/HoverInfo","label":"HoverInfo"},{"type":"doc","id":"api/type-aliases/KeywordHoverInfo","label":"KeywordHoverInfo"},{"type":"doc","id":"api/type-aliases/SchemaHoverInfo","label":"SchemaHoverInfo"}]},{"type":"category","label":"Functions","items":[{"type":"doc","id":"api/functions/buildMonacoRules","label":"buildMonacoRules"},{"type":"doc","id":"api/functions/buildVscodeRules","label":"buildVscodeRules"},{"type":"doc","id":"api/functions/clearCrashCache","label":"clearCrashCache"},{"type":"doc","id":"api/functions/createDiagnosticMarkers","label":"createDiagnosticMarkers"},{"type":"doc","id":"api/functions/createHoverProvider","label":"createHoverProvider"},{"type":"doc","id":"api/functions/createLspExtension","label":"createLspExtension"},{"type":"doc","id":"api/functions/disableParser","label":"disableParser"},{"type":"doc","id":"api/functions/enableParser","label":"enableParser"},{"type":"doc","id":"api/functions/getHighlightCaptures","label":"getHighlightCaptures"},{"type":"doc","id":"api/functions/initializeParser","label":"initializeParser"},{"type":"doc","id":"api/functions/initializeTreeSitter","label":"initializeTreeSitter"},{"type":"doc","id":"api/functions/isParserDisabled","label":"isParserDisabled"},{"type":"doc","id":"api/functions/isParserReady","label":"isParserReady"},{"type":"doc","id":"api/functions/parseAgentScript","label":"parseAgentScript"},{"type":"doc","id":"api/functions/parseAndGetErrors","label":"parseAndGetErrors"},{"type":"doc","id":"api/functions/registerAgentScriptLanguage","label":"registerAgentScriptLanguage"},{"type":"doc","id":"api/functions/resetParser","label":"resetParser"},{"type":"doc","id":"api/functions/resolveHoverInfo","label":"resolveHoverInfo"},{"type":"doc","id":"api/functions/terminateParser","label":"terminateParser"}]},{"type":"category","label":"Variables","items":[{"type":"doc","id":"api/variables/darkTheme","label":"darkTheme"},{"type":"doc","id":"api/variables/darkThemeColors","label":"darkThemeColors"},{"type":"doc","id":"api/variables/languageConfiguration","label":"languageConfiguration"},{"type":"doc","id":"api/variables/lightTheme","label":"lightTheme"},{"type":"doc","id":"api/variables/lightThemeColors","label":"lightThemeColors"},{"type":"doc","id":"api/variables/theme","label":"theme"},{"type":"doc","id":"api/variables/workerParser","label":"workerParser"}]}]};
Expand Down
2 changes: 1 addition & 1 deletion dialect/agentforce/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agentscript/agentforce-dialect",
"version": "2.37.0",
"version": "2.38.1",
"description": "Agentforce dialect — extends AgentScript with Salesforce-specific blocks, fields, and lint rules",
"type": "module",
"main": "dist/index.js",
Expand Down
8 changes: 6 additions & 2 deletions dialect/agentforce/src/lint/passes/connection-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,20 @@ const KNOWN_SYSTEM_TARGET_NAMES: readonly string[] = [
'MessagingChoicesWithImages',
'MessagingTimePicker',
'ESTypeMessage',
'MessagingAudio',
'ClientAction',
];

/**
* formats/targets that do not require an `inputs` block:
*
* - `ESTypeMessage`: accepts a dynamic schema native to the agent response;
* it can't be expressed as a static structured schema.
* - `MessagingAudio`: carries no structured payload, so it takes no input schema.
*/
const SYSTEM_TARGETS_WITHOUT_INPUTS: ReadonlySet<string> = new Set([
'ESTypeMessage',
'MessagingAudio',
]);

/** Allowed types for connection inputs (linting restriction) */
Expand Down Expand Up @@ -144,8 +148,8 @@ function missingFieldsError(node: AstNodeLike, connectionType: string): void {
node,
lintDiagnostic(
cst.range,
`${connectionType} connections require configuration fields ` +
`(e.g. escalation_message, outbound_route_type, outbound_route_name).`,
`${connectionType} connections require at least one configuration field ` +
`(e.g. description).`,
DiagnosticSeverity.Error,
'connection-missing-required-fields'
)
Expand Down
121 changes: 121 additions & 0 deletions dialect/agentforce/src/lint/passes/empty-block.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* 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
*/

/**
* Lint pass that flags blocks written with no content.
*
* Several Agentforce blocks type all of their fields as optional, so the schema
* accepts an empty block even though it carries no configuration and is
* behaviourally identical to omitting the block. An empty block is almost
* always an authoring leftover — and for integration-style blocks (`connection`,
* `knowledge`, `modality`) it is a genuine trap: it reads as "something is wired
* up" while doing nothing. We surface all of them as errors.
*
* This covers a block/collection written with no entries (e.g. a bare
* `connection:` or `modality:`). It also covers empty *named entries* whose
* block requires no single specific field but must carry at least one — a
* `modality voice:` with no properties identifies no voice and does nothing.
* (Entries whose block has a genuinely required field — `subagent`'s
* `description`, `connection`'s configuration fields — are already rejected by
* those required-field checks, so they are not listed here.)
*
* Diagnostic: empty-block.
*/

import type { AstNodeLike, AstRoot } from '@agentscript/language';
import type { LintPass, PassStore } from '@agentscript/language';
import {
storeKey,
attachDiagnostic,
lintDiagnostic,
isNamedMap,
} from '@agentscript/language';
import { DiagnosticSeverity } from '@agentscript/types';
import { getBlockRange } from '../utils.js';

/** Top-level blocks whose fields are all optional but must not be empty. */
const MUST_NOT_BE_EMPTY = [
'system',
'variables',
'model_config',
'knowledge',
'connection',
'modality',
'access',
'context',
] as const;

/**
* Named collections whose *entries* have no single required field but must
* carry at least one property (an empty entry is meaningless). Each entry is
* checked with the same emptiness test as a top-level block.
*/
const ENTRIES_MUST_NOT_BE_EMPTY = ['modality'] as const;

/**
* A block is empty when it holds no authored content. Collection blocks expose
* a numeric `size`; plain blocks carry one own key per authored field alongside
* internal `__`-prefixed bookkeeping keys.
*/
function isEmptyBlock(block: AstNodeLike): boolean {
const size = (block as { size?: unknown }).size;
if (typeof size === 'number') return size === 0;
return !Object.keys(block).some(key => !key.startsWith('__'));
}

/** Whether the block already carries a diagnostic with the given code. */
function hasDiagnostic(block: AstNodeLike, code: string): boolean {
const diagnostics = (block as { __diagnostics?: { code?: string }[] })
.__diagnostics;
return Array.isArray(diagnostics) && diagnostics.some(d => d.code === code);
}

function reportEmpty(block: AstNodeLike, label: string): void {
attachDiagnostic(
block,
lintDiagnostic(
getBlockRange(block),
`Empty '${label}' block: it has no content and will have no effect. Add configuration or remove the block.`,
DiagnosticSeverity.Error,
'empty-block'
)
);
}

class EmptyBlockPass implements LintPass {
readonly id = storeKey('empty-block-agentforce');
readonly description =
'Flags blocks (connection, knowledge, modality, …) written with no content';

run(_store: PassStore, root: AstRoot): void {
for (const key of MUST_NOT_BE_EMPTY) {
const block = (root as Record<string, unknown>)[key];
if (!block || typeof block !== 'object') continue;
if (isEmptyBlock(block as AstNodeLike))
reportEmpty(block as AstNodeLike, key);
}

// An empty *entry* of these collections (e.g. `modality voice:` with no
// properties) is just as meaningless as an empty collection.
for (const key of ENTRIES_MUST_NOT_BE_EMPTY) {
const collection = (root as Record<string, unknown>)[key];
if (!isNamedMap(collection)) continue;
for (const [name, entry] of collection.entries()) {
if (!entry || typeof entry !== 'object') continue;
// An unknown-variant entry is empty only because field parsing was
// skipped; the unknown-variant diagnostic already covers it.
if (hasDiagnostic(entry as AstNodeLike, 'unknown-variant')) continue;
if (isEmptyBlock(entry as AstNodeLike))
reportEmpty(entry as AstNodeLike, `${key} ${name}`);
}
}
}
}

export function emptyBlockRule(): LintPass {
return new EmptyBlockPass();
}
3 changes: 3 additions & 0 deletions dialect/agentforce/src/lint/passes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export {
voiceLanguageValidationRule,
voiceVersionMixingRule,
} from './voice-language-validation.js';
export { emptyBlockRule } from './empty-block.js';

import { actionTargetSchemeRule } from './action-target.js';
import { skillTargetSchemeRule } from './skill-target.js';
Expand All @@ -86,6 +87,7 @@ import {
voiceLanguageValidationRule,
voiceVersionMixingRule,
} from './voice-language-validation.js';
import { emptyBlockRule } from './empty-block.js';

/** All Agentforce lint rules — extends AgentScript rules with security checks. */
export function defaultRules(): LintPass[] {
Expand Down Expand Up @@ -120,5 +122,6 @@ export function defaultRules(): LintPass[] {
disabledAdditionalParametersRule(),
voiceLanguageValidationRule(),
voiceVersionMixingRule(),
emptyBlockRule(),
];
}
152 changes: 99 additions & 53 deletions dialect/agentforce/src/lint/passes/voice-language-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@
*/

/**
* Lint pass that validates `modality voice: languages:` keys against declared locales.
* Lint pass that validates `modality voice: language:` keys against declared languages.
*
* Voice language overrides in `languages:` must be a subset of the locales
* declared in the `language` block (`default_locale` + `additional_locales`).
* Voice language overrides in `modality voice: language:` must be a subset of the text languages
* declared in the top-level `language` block (`default_locale` + `additional_locales`).
*
* In addition, voice `language_settings` keys must be declared in `modality voice: language` block (`default_locale` + `additional_locales`).
*
* Diagnostics:
* - voice-language-not-declared: A language key is not in the declared locales
* - voice-language-not-declared: A voice language is not in the declared locales of the text languages.
* - voice-language-missing-language-block: Voice languages defined but no language block exists
* - voice-language-settings-not-declared: A voice language setting is not in the declared voice languages.
*/

import type { AstNodeLike, AstRoot } from '@agentscript/language';
Expand Down Expand Up @@ -57,6 +60,17 @@ function parseLocales(value: string | undefined): Set<string> {
);
}

/**
* Build a set of locales declared on a language block from its `default_locale`
* and comma-separated `additional_locales` fields.
*/
function collectLocales(language: AstNodeLike): Set<string> {
const locales = parseLocales(extractStringValue(language.additional_locales));
const defaultLocale = extractStringValue(language.default_locale);
if (defaultLocale) locales.add(defaultLocale);
return locales;
}

class VoiceLanguageValidationPass implements LintPass {
readonly id = storeKey('voice-language-validation');
readonly description =
Expand All @@ -67,61 +81,92 @@ class VoiceLanguageValidationPass implements LintPass {
if (!isNamedMap(modality) || !modality.has('voice')) return;

const voice = modality.get('voice') as AstNodeLike;
const languages = voice.languages;
if (!isNamedMap(languages) || languages.size === 0) return;

const language = root.language as AstNodeLike | undefined;

// If no language block exists but voice languages are defined, warn
if (!language || typeof language !== 'object') {
attachDiagnostic(
voice,
lintDiagnostic(
getBlockRange(languages as unknown as AstNodeLike),
"Voice languages are defined but no 'language' block exists. Define 'default_locale' and/or 'additional_locales'.",
DiagnosticSeverity.Warning,
'voice-language-missing-language-block'
)
);
return;
}

// If adaptive language is enabled, skip validation (adaptive-language rule handles voice conflict)
const adaptive = extractBooleanValue(language.adaptive);
if (adaptive === true) return;
const voiceLanguage = voice.language as AstNodeLike | undefined;
const voiceSettings = voice.language_settings;

// If all_additional_locales is True, all locales are valid
const allAdditionalLocales = extractBooleanValue(
language.all_additional_locales
);
if (allAdditionalLocales === true) return;
const hasVoiceLanguage =
!!voiceLanguage && typeof voiceLanguage === 'object';
const hasVoiceSettings =
isNamedMap(voiceSettings) && voiceSettings.size > 0;
if (!hasVoiceLanguage && !hasVoiceSettings) return;

// Build set of allowed locales from default_locale and additional_locales
const allowedLocales = new Set<string>();
// Check A: the voice languages must be a subset of the top-level text languages.
if (hasVoiceLanguage) {
const textLanguage = root.language as AstNodeLike | undefined;

const defaultLocale = extractStringValue(language.default_locale);
if (defaultLocale) {
allowedLocales.add(defaultLocale);
}

const additionalLocales = extractStringValue(language.additional_locales);
const additionalSet = parseLocales(additionalLocales);
for (const locale of additionalSet) {
allowedLocales.add(locale);
}

// Validate each voice language key
for (const [langKey, decl] of languages) {
if (!allowedLocales.has(langKey)) {
if (!textLanguage || typeof textLanguage !== 'object') {
// Voice languages are defined but no text 'language' block exists.
attachDiagnostic(
decl as AstNodeLike,
voice,
lintDiagnostic(
getBlockRange(decl as AstNodeLike),
`Voice language '${langKey}' is not declared in the language block. Add it to 'default_locale' or 'additional_locales'.`,
DiagnosticSeverity.Error,
'voice-language-not-declared'
getBlockRange(voiceLanguage),
"Voice languages are defined but no 'language' block exists. Define 'default_locale' and/or 'additional_locales'.",
DiagnosticSeverity.Warning,
'voice-language-missing-language-block'
)
);
} else {
const textLocales = collectLocales(textLanguage);

const defaultLocale = extractStringValue(voiceLanguage.default_locale);
if (defaultLocale && !textLocales.has(defaultLocale)) {
attachDiagnostic(
voiceLanguage,
lintDiagnostic(
getBlockRange(voiceLanguage.default_locale),
`Voice language '${defaultLocale}' is not declared in the language block. Add it to 'default_locale' or 'additional_locales'.`,
DiagnosticSeverity.Error,
'voice-language-not-declared'
)
);
}

const additionalLocales = parseLocales(
extractStringValue(voiceLanguage.additional_locales)
);
for (const locale of additionalLocales) {
if (!textLocales.has(locale)) {
attachDiagnostic(
voiceLanguage,
lintDiagnostic(
getBlockRange(voiceLanguage.additional_locales),
`Voice language '${locale}' is not declared in the language block. Add it to 'default_locale' or 'additional_locales'.`,
DiagnosticSeverity.Error,
'voice-language-not-declared'
)
);
}
}
}
}

// Check B: each language_settings key must be a declared voice language.
if (hasVoiceSettings) {
// A voice 'all_additional_locales: True' accepts every language_settings key.
const allAdditionalLocales = hasVoiceLanguage
? extractBooleanValue(voiceLanguage.all_additional_locales)
: undefined;

if (allAdditionalLocales !== true) {
// Locales declared on the voice language block. Empty when there is no
// voice language block, in which case every settings key is undeclared.
const voiceLocales = hasVoiceLanguage
? collectLocales(voiceLanguage)
: new Set<string>();

for (const [langKey, decl] of voiceSettings) {
if (!voiceLocales.has(langKey)) {
attachDiagnostic(
decl as AstNodeLike,
lintDiagnostic(
getBlockRange(decl as AstNodeLike),
`Voice language setting '${langKey}' is not a declared voice language. Add it to the voice 'language' block's 'default_locale' or 'additional_locales'.`,
DiagnosticSeverity.Error,
'voice-language-settings-not-declared'
)
);
}
}
}
}
}
Expand All @@ -137,7 +182,8 @@ const V2_VOICE_PROPERTIES = [
'inbound',
'outbound',
'session_language_switching',
'languages',
'language',
'language_settings',
] as const;

const V1_VOICE_PROPERTIES = [
Expand Down
Loading
Loading