@theia/ai-core 1.74.0-next.5 → 1.74.0-next.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1669,7 +1669,7 @@ export class DefaultPromptFragmentCustomizationService implements PromptFragment
1669
1669
 
1670
1670
  try {
1671
1671
  const fileContent = await this.fileService.read(customAgentYamlUri, { encoding: 'utf-8' });
1672
- const doc = load(fileContent.value);
1672
+ const doc = this.parseCustomAgentsYaml(fileContent.value);
1673
1673
 
1674
1674
  if (!Array.isArray(doc) || !doc.every(entry => CustomAgentDescription.is(entry))) {
1675
1675
  this.logger.debug(`Invalid customAgents.yml file content in ${directoryURI.toString()}`);
@@ -1736,6 +1736,47 @@ export class DefaultPromptFragmentCustomizationService implements PromptFragment
1736
1736
  return fileURI;
1737
1737
  }
1738
1738
 
1739
+ /**
1740
+ * Parse a `customAgents.yml` document, preserving newlines in folded-block-scalar prompts.
1741
+ *
1742
+ * `js-yaml` folds the single newlines of a folded scalar (`>` / `>-`) into spaces, which merges
1743
+ * a markdown heading (`## Task`) into the paragraph below it. Markdown is newline-sensitive, so
1744
+ * we re-interpret folded `prompt:` scalars as literal (`|`) before parsing. Only the block-scalar
1745
+ * header is rewritten; quoted, plain and already-literal values are left untouched.
1746
+ *
1747
+ * Known limitation: prompts stored as a plain or quoted multi-line scalar (rare) still fold.
1748
+ */
1749
+ protected parseCustomAgentsYaml(content: string): unknown {
1750
+ return load(this.unfoldPromptBlockScalars(content));
1751
+ }
1752
+
1753
+ /**
1754
+ * Rewrite a folded `prompt:` block-scalar header (`>`, `>-`, `>+`, `>2-`, ...) to its literal
1755
+ * equivalent (`|`...). The lookahead ensures only a block-scalar header is matched (indentation
1756
+ * and chomping indicators plus an optional trailing comment), so plain/quoted prompt values and
1757
+ * any `>` inside the prompt body are never touched.
1758
+ */
1759
+ protected unfoldPromptBlockScalars(content: string): string {
1760
+ return content.replace(/^([ \t]*prompt:[ \t]*)>(?=[-+0-9]*[ \t]*(?:#.*)?$)/mg, '$1|');
1761
+ }
1762
+
1763
+ /**
1764
+ * Returns `true` if migration would write anything: a scope still holds a legacy
1765
+ * `customAgents.yml`, or a previously migrated scope has `agent.md` files that can be corrected
1766
+ * (see {@link computeScopeCorrections}). Read-only; used to decide whether to prompt the user.
1767
+ */
1768
+ async hasPendingCustomAgentMigration(): Promise<boolean> {
1769
+ for (const scope of await this.getCustomAgentScopes()) {
1770
+ if (await this.fileService.exists(scope.resolve('customAgents.yml'))) {
1771
+ return true;
1772
+ }
1773
+ if ((await this.computeScopeCorrections(scope)).length > 0) {
1774
+ return true;
1775
+ }
1776
+ }
1777
+ return false;
1778
+ }
1779
+
1739
1780
  /**
1740
1781
  * Auto-migrate every legacy `customAgents.yml` reachable from the configured scopes to the new
1741
1782
  * `agents/<id>/agent.md` layout. The original content is never deleted:
@@ -1769,7 +1810,7 @@ export class DefaultPromptFragmentCustomizationService implements PromptFragment
1769
1810
  reports.push(report);
1770
1811
  }
1771
1812
  }
1772
- if (reports.some(r => r.migrated > 0)) {
1813
+ if (reports.some(r => r.migrated > 0 || r.corrected > 0)) {
1773
1814
  this.onDidChangeCustomAgentsEmitter.fire();
1774
1815
  }
1775
1816
  return reports;
@@ -1777,116 +1818,193 @@ export class DefaultPromptFragmentCustomizationService implements PromptFragment
1777
1818
 
1778
1819
  protected async migrateSingleScope(scopeDir: URI): Promise<MigrationReport | undefined> {
1779
1820
  const yamlURI = scopeDir.resolve('customAgents.yml');
1780
- if (!(await this.fileService.exists(yamlURI))) {
1781
- return undefined;
1782
- }
1783
- this.logger.info(`Migrating custom agents from ${yamlURI.toString()}`);
1784
- let entries: CustomAgentDescription[];
1785
- try {
1786
- const fileContent = await this.fileService.read(yamlURI, { encoding: 'utf-8' });
1787
- const doc = load(fileContent.value);
1788
- if (!Array.isArray(doc) || !doc.every(CustomAgentDescription.is)) {
1789
- this.logger.warn(`Skipping migration of ${yamlURI.toString()}: file content is not a valid CustomAgentDescription[]`);
1790
- return { scope: scopeDir, yamlURI, migrated: 0, alreadyPresent: 0, failed: 0, yamlBackedUp: false, promptOverridesMigrated: 0 };
1791
- }
1792
- entries = doc;
1793
- } catch (e) {
1794
- this.logger.warn(`Skipping migration of ${yamlURI.toString()}: ${e.message}`);
1795
- return { scope: scopeDir, yamlURI, migrated: 0, alreadyPresent: 0, failed: 0, yamlBackedUp: false, promptOverridesMigrated: 0 };
1796
- }
1821
+ const backupURI = scopeDir.resolve('customAgents.yml.bak');
1822
+ const yamlExists = await this.fileService.exists(yamlURI);
1797
1823
 
1798
1824
  let migrated = 0;
1799
1825
  let alreadyPresent = 0;
1800
1826
  let failed = 0;
1801
1827
  let promptOverridesMigrated = 0;
1828
+ let yamlBackedUp = false;
1802
1829
 
1803
- // A previous failed migration may have created `agents/<id>/` directories without an
1804
- // `agent.md` inside. Remove those empty placeholders so this run can recreate them cleanly.
1805
- await this.cleanupEmptyAgentFolders(scopeDir);
1830
+ const emptyReport = (): MigrationReport => ({
1831
+ scope: scopeDir, yamlURI, migrated: 0, alreadyPresent: 0, failed: 0,
1832
+ yamlBackedUp: false, promptOverridesMigrated: 0, corrected: 0
1833
+ });
1806
1834
 
1807
- // Snapshot scope-root template files once so we can match per agent without re-listing.
1808
- const scopeRootTemplateFiles = await this.listScopeRootPromptTemplates(scopeDir);
1835
+ if (yamlExists) {
1836
+ this.logger.info(`Migrating custom agents from ${yamlURI.toString()}`);
1837
+ let entries: CustomAgentDescription[];
1838
+ try {
1839
+ const fileContent = await this.fileService.read(yamlURI, { encoding: 'utf-8' });
1840
+ const doc = this.parseCustomAgentsYaml(fileContent.value);
1841
+ if (!Array.isArray(doc) || !doc.every(CustomAgentDescription.is)) {
1842
+ this.logger.warn(`Skipping migration of ${yamlURI.toString()}: file content is not a valid CustomAgentDescription[]`);
1843
+ return emptyReport();
1844
+ }
1845
+ entries = doc;
1846
+ } catch (e) {
1847
+ this.logger.warn(`Skipping migration of ${yamlURI.toString()}: ${e.message}`);
1848
+ return emptyReport();
1849
+ }
1809
1850
 
1810
- // Prompt-fragment files are matched by agent *name* (the fragment id is `<name>_prompt`)
1811
- // while folders are keyed by the unique *id*. When two entries share a name, only the first
1812
- // can own the name-based files, so track seen names and skip the move for the duplicates.
1813
- const seenNames = new Set<string>();
1851
+ // A previous failed migration may have created `agents/<id>/` directories without an
1852
+ // `agent.md` inside. Remove those empty placeholders so this run can recreate them cleanly.
1853
+ await this.cleanupEmptyAgentFolders(scopeDir);
1814
1854
 
1815
- for (const entry of entries) {
1816
- const agentFolderURI = scopeDir.resolve(CUSTOM_AGENTS_DIRECTORY).resolve(entry.id);
1817
- const targetURI = agentFolderURI.resolve(CUSTOM_AGENT_FILE_NAME);
1818
- if (await this.fileService.exists(targetURI)) {
1819
- alreadyPresent++;
1820
- } else {
1821
- try {
1822
- await this.fileService.createFile(
1823
- targetURI,
1824
- BinaryBuffer.fromString(serializeCustomAgentFile(entry)),
1825
- { overwrite: true }
1855
+ // Snapshot scope-root template files once so we can match per agent without re-listing.
1856
+ const scopeRootTemplateFiles = await this.listScopeRootPromptTemplates(scopeDir);
1857
+
1858
+ // Prompt-fragment files are matched by agent *name* (the fragment id is `<name>_prompt`)
1859
+ // while folders are keyed by the unique *id*. When two entries share a name, only the first
1860
+ // can own the name-based files, so track seen names and skip the move for the duplicates.
1861
+ const seenNames = new Set<string>();
1862
+
1863
+ for (const entry of entries) {
1864
+ const agentFolderURI = scopeDir.resolve(CUSTOM_AGENTS_DIRECTORY).resolve(entry.id);
1865
+ const targetURI = agentFolderURI.resolve(CUSTOM_AGENT_FILE_NAME);
1866
+ if (await this.fileService.exists(targetURI)) {
1867
+ alreadyPresent++;
1868
+ } else {
1869
+ try {
1870
+ await this.fileService.createFile(
1871
+ targetURI,
1872
+ BinaryBuffer.fromString(serializeCustomAgentFile(entry)),
1873
+ { overwrite: true }
1874
+ );
1875
+ migrated++;
1876
+ } catch (e) {
1877
+ this.logger.warn(`Failed to migrate agent '${entry.id}' from ${yamlURI.toString()}: ${e?.message ?? e}`, e);
1878
+ failed++;
1879
+ continue;
1880
+ }
1881
+ }
1882
+
1883
+ if (seenNames.has(entry.name)) {
1884
+ this.logger.warn(
1885
+ `Multiple custom agents in ${yamlURI.toString()} share the name '${entry.name}'; ` +
1886
+ 'prompt fragment files were migrated to the first matching agent only.'
1826
1887
  );
1827
- migrated++;
1828
- } catch (e) {
1829
- this.logger.warn(`Failed to migrate agent '${entry.id}' from ${yamlURI.toString()}: ${e?.message ?? e}`, e);
1830
- failed++;
1831
1888
  continue;
1832
1889
  }
1890
+ seenNames.add(entry.name);
1891
+
1892
+ // Move any scope-root .prompttemplate files that look like they belong to this agent
1893
+ // (filename stem starts with `<name>_prompt`, followed by EOF or a separator).
1894
+ // They become variants under `<scope>/agents/<id>/`.
1895
+ for (const file of scopeRootTemplateFiles) {
1896
+ if (!matchesAgentPromptPrefix(file.stem, entry.name)) {
1897
+ continue;
1898
+ }
1899
+ const destURI = agentFolderURI.resolve(file.uri.path.base);
1900
+ if (await this.fileService.exists(destURI)) {
1901
+ continue;
1902
+ }
1903
+ try {
1904
+ await this.fileService.move(file.uri, destURI);
1905
+ promptOverridesMigrated++;
1906
+ } catch (e) {
1907
+ this.logger.warn(`Failed to move prompt fragment ${file.uri.toString()} into ${destURI.toString()}: ${e?.message ?? e}`);
1908
+ }
1909
+ }
1833
1910
  }
1834
1911
 
1835
- if (seenNames.has(entry.name)) {
1836
- this.logger.warn(
1837
- `Multiple custom agents in ${yamlURI.toString()} share the name '${entry.name}'; ` +
1838
- 'prompt fragment files were migrated to the first matching agent only.'
1839
- );
1840
- continue;
1841
- }
1842
- seenNames.add(entry.name);
1843
-
1844
- // Move any scope-root .prompttemplate files that look like they belong to this agent
1845
- // (filename stem starts with `<name>_prompt`, followed by EOF or a separator).
1846
- // They become variants under `<scope>/agents/<id>/`.
1847
- for (const file of scopeRootTemplateFiles) {
1848
- if (!matchesAgentPromptPrefix(file.stem, entry.name)) {
1849
- continue;
1850
- }
1851
- const destURI = agentFolderURI.resolve(file.uri.path.base);
1852
- if (await this.fileService.exists(destURI)) {
1853
- continue;
1912
+ if (failed === 0) {
1913
+ try {
1914
+ await this.fileService.move(yamlURI, backupURI, { overwrite: true });
1915
+ yamlBackedUp = true;
1916
+ } catch (e) {
1917
+ this.logger.warn(`Migrated ${migrated} agents but failed to back up ${yamlURI.toString()}: ${e.message}`);
1854
1918
  }
1919
+ } else {
1855
1920
  try {
1856
- await this.fileService.move(file.uri, destURI);
1857
- promptOverridesMigrated++;
1921
+ if (!(await this.fileService.exists(backupURI))) {
1922
+ await this.fileService.move(yamlURI, backupURI);
1923
+ yamlBackedUp = true;
1924
+ }
1858
1925
  } catch (e) {
1859
- this.logger.warn(`Failed to move prompt fragment ${file.uri.toString()} into ${destURI.toString()}: ${e?.message ?? e}`);
1926
+ this.logger.warn(`Failed to back up ${yamlURI.toString()} to ${backupURI.toString()}: ${e.message}`);
1860
1927
  }
1861
1928
  }
1862
1929
  }
1863
1930
 
1864
- let yamlBackedUp = false;
1865
- const backupURI = scopeDir.resolve('customAgents.yml.bak');
1866
- if (failed === 0) {
1931
+ // Correct any `agent.md` left with merged headings by an earlier (v1.73.0) migration. Anything
1932
+ // just migrated above is already correct, so only stale files from a previous run are rewritten.
1933
+ let corrected = 0;
1934
+ for (const { uri, content } of await this.computeScopeCorrections(scopeDir)) {
1867
1935
  try {
1868
- await this.fileService.move(yamlURI, backupURI, { overwrite: true });
1869
- yamlBackedUp = true;
1936
+ await this.fileService.createFile(uri, BinaryBuffer.fromString(content), { overwrite: true });
1937
+ corrected++;
1870
1938
  } catch (e) {
1871
- this.logger.warn(`Migrated ${migrated} agents but failed to back up ${yamlURI.toString()}: ${e.message}`);
1872
- }
1873
- } else {
1874
- try {
1875
- if (!(await this.fileService.exists(backupURI))) {
1876
- await this.fileService.move(yamlURI, backupURI);
1877
- yamlBackedUp = true;
1878
- }
1879
- } catch (e) {
1880
- this.logger.warn(`Failed to back up ${yamlURI.toString()} to ${backupURI.toString()}: ${e.message}`);
1939
+ this.logger.warn(`Failed to correct migrated agent file ${uri.toString()}: ${e?.message ?? e}`);
1881
1940
  }
1882
1941
  }
1883
1942
 
1943
+ if (!yamlExists && corrected === 0) {
1944
+ return undefined; // nothing to migrate and nothing to correct in this scope
1945
+ }
1946
+
1884
1947
  this.logger.info(
1885
- `Migration done for ${yamlURI.toString()}: ` +
1886
- `migrated=${migrated}, alreadyPresent=${alreadyPresent}, failed=${failed}, ` +
1887
- `yamlBackedUp=${yamlBackedUp}, promptOverridesMigrated=${promptOverridesMigrated}`
1948
+ `Migration done for ${scopeDir.toString()}: migrated=${migrated}, alreadyPresent=${alreadyPresent}, ` +
1949
+ `failed=${failed}, yamlBackedUp=${yamlBackedUp}, promptOverridesMigrated=${promptOverridesMigrated}, corrected=${corrected}`
1888
1950
  );
1889
- return { scope: scopeDir, yamlURI, migrated, alreadyPresent, failed, yamlBackedUp, promptOverridesMigrated };
1951
+ return { scope: scopeDir, yamlURI, migrated, alreadyPresent, failed, yamlBackedUp, promptOverridesMigrated, corrected };
1952
+ }
1953
+
1954
+ /**
1955
+ * Compute the `agent.md` rewrites needed to recover headings folded by an earlier (v1.73.0)
1956
+ * migration. The backup (`customAgents.yml.bak`) is the source that migration used: parsing it
1957
+ * the old (folded) way reproduces the exact bytes it wrote, and parsing it the new (literal) way
1958
+ * produces the corrected content. Only files still byte-identical to the buggy output are eligible,
1959
+ * so files the user edited (and already-correct files) are left untouched. Read-only; used both to
1960
+ * detect pending work and to perform the rewrite.
1961
+ */
1962
+ protected async computeScopeCorrections(scopeDir: URI): Promise<Array<{ uri: URI; content: string }>> {
1963
+ const backupURI = scopeDir.resolve('customAgents.yml.bak');
1964
+ if (!(await this.fileService.exists(backupURI))) {
1965
+ return [];
1966
+ }
1967
+ let oldEntries: unknown;
1968
+ let newEntries: unknown;
1969
+ try {
1970
+ const raw = (await this.fileService.read(backupURI, { encoding: 'utf-8' })).value;
1971
+ oldEntries = load(raw); // reproduces the folded (buggy) output
1972
+ newEntries = this.parseCustomAgentsYaml(raw); // produces the heading-preserving fix
1973
+ } catch {
1974
+ return [];
1975
+ }
1976
+ if (!Array.isArray(oldEntries) || !oldEntries.every(CustomAgentDescription.is)
1977
+ || !Array.isArray(newEntries) || !newEntries.every(CustomAgentDescription.is)) {
1978
+ return [];
1979
+ }
1980
+ const fixedById = new Map((newEntries as CustomAgentDescription[]).map(e => [e.id, e] as const));
1981
+ const corrections: Array<{ uri: URI; content: string }> = [];
1982
+ for (const oldEntry of oldEntries as CustomAgentDescription[]) {
1983
+ const fixed = fixedById.get(oldEntry.id);
1984
+ if (!fixed) {
1985
+ continue;
1986
+ }
1987
+ const buggyContent = serializeCustomAgentFile(oldEntry);
1988
+ const fixedContent = serializeCustomAgentFile(fixed);
1989
+ if (buggyContent === fixedContent) {
1990
+ continue; // prompt had no folded headings to recover
1991
+ }
1992
+ const agentMdURI = scopeDir.resolve(CUSTOM_AGENTS_DIRECTORY).resolve(oldEntry.id).resolve(CUSTOM_AGENT_FILE_NAME);
1993
+ if (!(await this.fileService.exists(agentMdURI))) {
1994
+ continue;
1995
+ }
1996
+ let current: string;
1997
+ try {
1998
+ current = (await this.fileService.read(agentMdURI, { encoding: 'utf-8' })).value;
1999
+ } catch {
2000
+ continue;
2001
+ }
2002
+ // Only rewrite files untouched since migration; current bytes still matching the buggy output.
2003
+ if (current === buggyContent) {
2004
+ corrections.push({ uri: agentMdURI, content: fixedContent });
2005
+ }
2006
+ }
2007
+ return corrections;
1890
2008
  }
1891
2009
 
1892
2010
  /**
@@ -1923,6 +2041,8 @@ export interface MigrationReport {
1923
2041
  yamlBackedUp: boolean;
1924
2042
  /** Number of scope-root prompt customization files (`<name>_prompt.prompttemplate`) folded into agent.md and deleted. */
1925
2043
  promptOverridesMigrated: number;
2044
+ /** Number of already-migrated `agent.md` files rewritten to recover folded headings. */
2045
+ corrected: number;
1926
2046
  }
1927
2047
 
1928
2048
  /**
@@ -385,11 +385,21 @@ export interface PromptFragmentCustomizationService {
385
385
  createCustomAgentFile(parentDirectory: URI, agent: CustomAgentDescription): Promise<URI>;
386
386
 
387
387
  /**
388
- * Migrates every reachable `customAgents.yml` to the per-agent `agents/<id>/agent.md` layout.
388
+ * Returns `true` if migration would write anything: a scope still holds a legacy
389
+ * `customAgents.yml`, or a previously migrated scope has `agent.md` files that can be corrected
390
+ * (e.g. headings that were folded by an earlier migration). Read-only; performs no writes and is
391
+ * intended for deciding whether to prompt the user before migrating.
392
+ */
393
+ hasPendingCustomAgentMigration(): Promise<boolean>;
394
+
395
+ /**
396
+ * Migrates every reachable `customAgents.yml` to the per-agent `agents/<id>/agent.md` layout and
397
+ * corrects already-migrated `agent.md` files whose headings were folded by an earlier migration.
389
398
  * The user's original content is never deleted: on success (or on partial failure when no backup
390
399
  * exists yet) the YAML is renamed to `customAgents.yml.bak`; if a `.bak` already exists it is not
391
- * overwritten and the YAML is left in place.
392
- * Idempotent rerunning never overwrites an already-migrated agent file.
400
+ * overwritten and the YAML is left in place. Corrections only overwrite `agent.md` files the user
401
+ * has not edited since migration.
402
+ * Idempotent — rerunning never overwrites an already up-to-date agent file.
393
403
  */
394
404
  migrateCustomAgentsYaml(): Promise<Array<{
395
405
  scope: URI;
@@ -399,6 +409,7 @@ export interface PromptFragmentCustomizationService {
399
409
  failed: number;
400
410
  yamlBackedUp: boolean;
401
411
  promptOverridesMigrated: number;
412
+ corrected: number;
402
413
  }>>;
403
414
 
404
415
  /**