From 60464e9cfb3e73611be7ee7de62f0b705227bc97 Mon Sep 17 00:00:00 2001 From: Graham McMynn Date: Mon, 21 Sep 2026 09:22:49 -0700 Subject: [PATCH 1/3] Add ess-ca-to-da migration tool for CA-to-DA customization migration Introduces the essmig CLI under tools/ess-ca-to-da. It discovers a customer's ESS Custom Engine Agent (CA) customizations from Dataverse, merges them onto the current ESS Declarative Agent (DA) template, and emits an importable ALM package plus human-facing reports. Highlights: - Auto-detects installed ESS agents (core/hr/it); --vertical is optional. - inspect writes customizations.md tagging each component migratable now / needs you / not supported yet, joined from the same merge the migration report uses. - Normalizes Copilot Studio's lenient YAML (@odata keys, missing key-space, CRLF) so one malformed component fails alone instead of aborting the run. - Interactive conflict resolution with hand-merge support; offline snapshot mode for reproducible re-runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/ess-ca-to-da/.gitattributes | 4 + tools/ess-ca-to-da/.gitignore | 6 + tools/ess-ca-to-da/README.md | 401 + tools/ess-ca-to-da/docs/DEV_DESIGN.md | 292 + tools/ess-ca-to-da/pyproject.toml | 39 + tools/ess-ca-to-da/reference/ca-baseline.json | 1989 +++ tools/ess-ca-to-da/reference/core/agent.yml | 431 + .../reference/core/app.config.dev.json | 11 + .../ess-ca-to-da/reference/core/package.json | 7 + .../reference/core/reference.json | 10 + tools/ess-ca-to-da/reference/hr/agent.yml | 12085 ++++++++++++++++ .../reference/hr/app.config.dev.json | 19 + tools/ess-ca-to-da/reference/hr/package.json | 7 + .../ess-ca-to-da/reference/hr/reference.json | 10 + tools/ess-ca-to-da/reference/it/agent.yml | 11846 +++++++++++++++ .../reference/it/app.config.dev.json | 23 + tools/ess-ca-to-da/reference/it/package.json | 7 + .../ess-ca-to-da/reference/it/reference.json | 10 + tools/ess-ca-to-da/src/essmig/__init__.py | 3 + tools/ess-ca-to-da/src/essmig/__main__.py | 4 + tools/ess-ca-to-da/src/essmig/assessment.py | 169 + tools/ess-ca-to-da/src/essmig/auth.py | 227 + tools/ess-ca-to-da/src/essmig/cli.py | 416 + .../ess-ca-to-da/src/essmig/customizations.py | 286 + tools/ess-ca-to-da/src/essmig/dataverse.py | 208 + tools/ess-ca-to-da/src/essmig/deliver.py | 180 + tools/ess-ca-to-da/src/essmig/discovery.py | 457 + tools/ess-ca-to-da/src/essmig/ess.py | 204 + tools/ess-ca-to-da/src/essmig/instructions.py | 78 + tools/ess-ca-to-da/src/essmig/llm.py | 109 + tools/ess-ca-to-da/src/essmig/merge.py | 818 ++ tools/ess-ca-to-da/src/essmig/packaging.py | 184 + tools/ess-ca-to-da/src/essmig/projection.py | 405 + tools/ess-ca-to-da/src/essmig/reference.py | 323 + tools/ess-ca-to-da/src/essmig/report.py | 326 + tools/ess-ca-to-da/src/essmig/resolve.py | 335 + tools/ess-ca-to-da/src/essmig/rules.py | 269 + tools/ess-ca-to-da/tests/conftest.py | 93 + tools/ess-ca-to-da/tests/test_assessment.py | 114 + tools/ess-ca-to-da/tests/test_auth.py | 87 + .../ess-ca-to-da/tests/test_customizations.py | 158 + tools/ess-ca-to-da/tests/test_deliver.py | 194 + tools/ess-ca-to-da/tests/test_discovery.py | 251 + tools/ess-ca-to-da/tests/test_end_to_end.py | 143 + tools/ess-ca-to-da/tests/test_ess.py | 86 + tools/ess-ca-to-da/tests/test_instructions.py | 38 + tools/ess-ca-to-da/tests/test_merge.py | 590 + tools/ess-ca-to-da/tests/test_packaging.py | 84 + tools/ess-ca-to-da/tests/test_projection.py | 206 + tools/ess-ca-to-da/tests/test_resolve.py | 167 + tools/ess-ca-to-da/tests/test_rules.py | 109 + 51 files changed, 34518 insertions(+) create mode 100644 tools/ess-ca-to-da/.gitattributes create mode 100644 tools/ess-ca-to-da/.gitignore create mode 100644 tools/ess-ca-to-da/README.md create mode 100644 tools/ess-ca-to-da/docs/DEV_DESIGN.md create mode 100644 tools/ess-ca-to-da/pyproject.toml create mode 100644 tools/ess-ca-to-da/reference/ca-baseline.json create mode 100644 tools/ess-ca-to-da/reference/core/agent.yml create mode 100644 tools/ess-ca-to-da/reference/core/app.config.dev.json create mode 100644 tools/ess-ca-to-da/reference/core/package.json create mode 100644 tools/ess-ca-to-da/reference/core/reference.json create mode 100644 tools/ess-ca-to-da/reference/hr/agent.yml create mode 100644 tools/ess-ca-to-da/reference/hr/app.config.dev.json create mode 100644 tools/ess-ca-to-da/reference/hr/package.json create mode 100644 tools/ess-ca-to-da/reference/hr/reference.json create mode 100644 tools/ess-ca-to-da/reference/it/agent.yml create mode 100644 tools/ess-ca-to-da/reference/it/app.config.dev.json create mode 100644 tools/ess-ca-to-da/reference/it/package.json create mode 100644 tools/ess-ca-to-da/reference/it/reference.json create mode 100644 tools/ess-ca-to-da/src/essmig/__init__.py create mode 100644 tools/ess-ca-to-da/src/essmig/__main__.py create mode 100644 tools/ess-ca-to-da/src/essmig/assessment.py create mode 100644 tools/ess-ca-to-da/src/essmig/auth.py create mode 100644 tools/ess-ca-to-da/src/essmig/cli.py create mode 100644 tools/ess-ca-to-da/src/essmig/customizations.py create mode 100644 tools/ess-ca-to-da/src/essmig/dataverse.py create mode 100644 tools/ess-ca-to-da/src/essmig/deliver.py create mode 100644 tools/ess-ca-to-da/src/essmig/discovery.py create mode 100644 tools/ess-ca-to-da/src/essmig/ess.py create mode 100644 tools/ess-ca-to-da/src/essmig/instructions.py create mode 100644 tools/ess-ca-to-da/src/essmig/llm.py create mode 100644 tools/ess-ca-to-da/src/essmig/merge.py create mode 100644 tools/ess-ca-to-da/src/essmig/packaging.py create mode 100644 tools/ess-ca-to-da/src/essmig/projection.py create mode 100644 tools/ess-ca-to-da/src/essmig/reference.py create mode 100644 tools/ess-ca-to-da/src/essmig/report.py create mode 100644 tools/ess-ca-to-da/src/essmig/resolve.py create mode 100644 tools/ess-ca-to-da/src/essmig/rules.py create mode 100644 tools/ess-ca-to-da/tests/conftest.py create mode 100644 tools/ess-ca-to-da/tests/test_assessment.py create mode 100644 tools/ess-ca-to-da/tests/test_auth.py create mode 100644 tools/ess-ca-to-da/tests/test_customizations.py create mode 100644 tools/ess-ca-to-da/tests/test_deliver.py create mode 100644 tools/ess-ca-to-da/tests/test_discovery.py create mode 100644 tools/ess-ca-to-da/tests/test_end_to_end.py create mode 100644 tools/ess-ca-to-da/tests/test_ess.py create mode 100644 tools/ess-ca-to-da/tests/test_instructions.py create mode 100644 tools/ess-ca-to-da/tests/test_merge.py create mode 100644 tools/ess-ca-to-da/tests/test_packaging.py create mode 100644 tools/ess-ca-to-da/tests/test_projection.py create mode 100644 tools/ess-ca-to-da/tests/test_resolve.py create mode 100644 tools/ess-ca-to-da/tests/test_rules.py diff --git a/tools/ess-ca-to-da/.gitattributes b/tools/ess-ca-to-da/.gitattributes new file mode 100644 index 000000000..64cd70931 --- /dev/null +++ b/tools/ess-ca-to-da/.gitattributes @@ -0,0 +1,4 @@ +# Deterministic line endings for this tool, independent of a contributor's +# core.autocrlf setting. Text is stored and checked out as LF; binaries are +# auto-detected and left untouched. +* text=auto eol=lf diff --git a/tools/ess-ca-to-da/.gitignore b/tools/ess-ca-to-da/.gitignore new file mode 100644 index 000000000..32fee9523 --- /dev/null +++ b/tools/ess-ca-to-da/.gitignore @@ -0,0 +1,6 @@ +out/ +*.egg-info/ +__pycache__/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ diff --git a/tools/ess-ca-to-da/README.md b/tools/ess-ca-to-da/README.md new file mode 100644 index 000000000..6ac6e65e4 --- /dev/null +++ b/tools/ess-ca-to-da/README.md @@ -0,0 +1,401 @@ +# ESS Custom Engine Agent → Declarative Agent migration + +A standalone tool that takes what a customer changed in the **ESS Custom Engine +Agent (CA)** and carries it onto the **ESS Declarative Agent (DA)**. + +It reads the customer's Dataverse environment, works out what they customized, +merges those customizations onto the current ESS DA template, and emits an ALM +package plus a report. It **never writes anything** — not to Dataverse, not to the +customer's tenant. The customer's CA keeps running, untouched, and the customer +imports the package themselves when they are ready. + +### One agent at a time + +ESS is not a single agent. It is a **hub-and-spoke** of three first-class agents, +each shipped as its own CA solution and its own DA template: + +| `--vertical` | agent | role | +| --- | --- | --- | +| `core` | Core | the standalone **hub / router** — greets the employee and delegates to a domain agent | +| `hr` | HR | the HR domain agent | +| `it` | IT | the IT domain agent | + +You run the tool **once per agent** the customer has customized, choosing the +matching `--vertical` — or **omit `--vertical` entirely** and the tool detects +every ESS agent installed in the environment and migrates each into its own +subfolder of `--out` (`out/core/`, `out/hr/`, `out/it/`). Core is not shared +plumbing folded into HR/IT — it is a separate agent with its own instructions, +topics and package. The domain integrations (ServiceNow, Workday, …) are *not* +separate agents: they layer into HR/IT, and the ones ESS has ported to a DA are +carried inline. A customization that belongs to an integration ESS has **not** yet +ported to a DA is reported as `blocked`, named, with its configuration reproduced, +so nothing is lost silently. + +--- + +## Why this is a merge, not a copy + +The two agents are not the same content in different wrappers. + +| | Custom Engine Agent | Declarative Agent | +| --- | --- | --- | +| Stored in | Dataverse (`botcomponent` rows in a managed solution) | Cosmos, via the Agent Builder Service | +| Shipped as | a managed solution | an ALM *templated package* (`agent.yml` + `app.config..json`) | +| Schema prefix | `msdyn_…` | `gptagent_…` | +| Customer edits live as | unmanaged solution layers | overlays governed by `Overlays.json` | + +ESS did not re-wrap the CA to build the DA; much of the content was rewritten. +Measured across the shipping HR template, of the components that have a CA +ancestor only a minority are identical after normalisation, some are 50–90% +similar, and a few were rebuilt outright. Around half the DA's components have no +CA ancestor at all. + +That rules out both naive strategies: + +- **Copy the customer's topics over the DA** — destroys every ESS improvement. +- **Keep the DA and discard customer edits** — destroys the customer's work. + +So the tool does what the platform's own *Upgrade* does for templated agents: a +**three-way merge**. The platform can't run it for a CA customer — they have no +Git Repository Service repo, no base commit and no `templateBaseVersion`, so +there's no merge base — but all three inputs can be reconstructed from sources ESS +owns: + +| input | meaning | where it comes from | +| --- | --- | --- | +| **base** | the CA component as ESS shipped it | ESSVivaCopilot `sources/dev/solutions/**/botcomponents/*/data` | +| **ours** | the customer's edit of it | the customer's Dataverse environment | +| **theirs** | the DA component ESS ships today | ESSVivaCopilot `sources/dev/AgentTemplates/**/agent.yml` | + +`base` and `theirs` are **vendored** into `reference/`, so a migration run needs +nothing but the customer's environment. + +Everything is joined on the **schema-name suffix** — the part after the agent +prefix. `msdyn_…hr.topic.ConversationStart` and `gptagent_…hr.topic.ConversationStart` +both reduce to `topic.ConversationStart`. + +### What the merge guarantees + +- A change only the customer made is **kept**. +- A change only ESS made is **taken**. +- A change both made, differently, is a **conflict**. By default at a terminal, + `migrate` **asks you** to resolve each one on the spot — it shows *what you + changed* (a plain diff against the version ESS originally shipped, so you see + just your edit) and ESS's current version, then you pick **keep ESS's** (press + Enter), **keep mine**, or **open an editor to merge by hand** (both versions are + placed in a text file you edit, save and close). An "apply to the rest of this + topic" shortcut avoids answering a repetitive rename twenty times. Your choice is + baked straight into the package. Pass `--non-interactive` (the default in CI / + when output isn't a terminal) to skip the prompts: conflicts then keep the ESS + version and are listed in the report for a human. Nothing is ever silently + picked or thrown away. + +Given how much ESS rewrote, expect a real number of conflicts on edited +out-of-box topics. That is the honest answer, and the report — a precise worklist +of what needs a human — is as much the deliverable as the package is. + +--- + +## Install + +```powershell +cd tools\ess-ca-to-da +python -m pip install -e ".[dev]" +``` + +## Use + +### 1. Vendor the reference data (maintainers, once per ESS release) + +```powershell +python -m essmig vendor --ess-root C:\ESSVivaCopilot +``` + +Extracts the CA baseline and the DA templates into `reference/`, stamped with the +template version they came from. Re-run whenever ESS ships a new template, and +commit the result — customers never need an ESSVivaCopilot clone. + +### 2. See what a customer customized (read-only) + +```powershell +python -m essmig inspect --environment-url https://contoso.crm.dynamics.com --vertical hr +``` + +Prints each customization, gives an **eligibility verdict**, and writes +`out/customizations.json`, `out/customizations.md`, and `out/assessment.json`. The +`.md` is a per-component diff of what you changed from the ESS baseline, with each +component tagged **✅ migratable now**, **⚠️ migratable but needs you**, or +**⛔ not supported yet**. This is the fast way to answer "is this customer ready to +migrate, and if not, what is in the way?" and it touches nothing. + +The verdict is one of: + +| verdict | meaning | +| --- | --- | +| `ready` | everything carried across; test and publish | +| `needs-work` | migratable, but conflicts to resolve or topics to rebuild first | +| `blocked` | something the customer relies on cannot be migrated at all | + +It is deliberately conservative. `ready` means the tool found nothing needing a +human — only the customer's own testing establishes that the agent is correct. + +Sign-in opens a browser once. The tenant is taken from the environment's own +`WWW-Authenticate` challenge, so signing in to a customer environment as a guest +works without extra flags. To pin it by hand, set `ESSMIG_MSAL_AUTHORITY`, +`ESSMIG_MSAL_SCOPE` or `ESSMIG_MSAL_CLIENT_ID`. + +You need a Dataverse role that can read `solutions`, `solutioncomponents` and +`msdyn_componentlayers` and call `RetrieveDependenciesForUninstallWithMetadata` — +System Customizer or System Administrator. + +### 3. Produce the package and the report + +```powershell +python -m essmig migrate --environment-url https://contoso.crm.dynamics.com --vertical hr --out out +``` + +Produces: + +``` +out/ + Plugin/ + package.json + Agents/gptagent_copilotforemployeeselfservicehr/ + agent.yml + app.config.dev.json + gptagent_copilotforemployeeselfservicehr.zip + migration-report.md + migration-report.json +``` + +**Omit `--vertical`** to migrate every ESS agent the environment actually has — +the tool detects which of Core/HR/IT are installed and writes each into its own +subfolder (`out/core/`, `out/hr/`, `out/it/`), each with the layout above: + +```powershell +python -m essmig migrate --environment-url https://contoso.crm.dynamics.com --out out +``` + +(The same applies to `inspect`.) With `--import`, auto mode delivers each detected +agent under its own default schema name; pass `--vertical` to override a single +agent's `--target-schema-name`. + +Add `--preferred-solution ` to scope the run to one unmanaged solution +(the ALM path — run once per preferred solution). Add +`--snapshot out/customizations.json` to re-run the merge offline against an earlier +`inspect`, which is also how the end-to-end tests work. + +### 4. Deliver it into a target Declarative Agent + +By default `migrate` stops at the package and the customer imports it themselves +(step 5). To have the tool import it for you, add `--import` and name the target: + +```powershell +python -m essmig migrate --environment-url https://contoso.crm.dynamics.com --vertical hr ` + --import --target-environment-id +``` + +This builds the package exactly as before, then POSTs it to the target's +`minimalBots/alm/import` endpoint — a **clean replace into that environment's Dev +ring only**; Test and Production are untouched. The delivery outcome (success, or +the failure reason) is written into the report, and a failed import exits non-zero +without having changed the target. + +Target addressing: + +| flag | meaning | default | +| --- | --- | --- | +| `--target-environment-id` | the Power Platform environment GUID to import into | required with `--import` | +| `--target-tenant-id` | target tenant GUID | the source environment's tenant (source and target share a tenant) | +| `--target-schema-name` | schema name of the target agent | the template's (`gptagent_copilotforemployeeselfservice`); override only for a renamed install | +| `--target-api-base` | base URL of the Copilot Studio ALM import API | `https://api.powerplatform.com` (or `ESSMIG_TARGET_API_BASE`) | + +The import token audience defaults to the Power Platform API; override it with +`ESSMIG_TARGET_SCOPE` (and the client id with `ESSMIG_MSAL_CLIENT_ID`) if a live +environment requires it. The import contract is from a draft design — see *Open +questions* — so the endpoint and scope are deliberately overridable. + +Knowledge sources **do** ride in the package: a SharePoint (or other) source is +carried as a `KnowledgeSourceComponent`, marked customer-owned, and the GPT is +pointed at it (`knowledgeSources: SearchAllKnowledgeSources`) so it is actually +searched after import. Custom metrics and other true agent-settings customizations +still cannot ride in the package; the report lists them under *Re-create these in +the agent's settings*, to be applied by hand in the target after import. + +Agent **instructions** are migrated with a language model. The CA and DA ship the +*same* instructions worded differently, so a structural merge of the `instructions` +text can only ever conflict. Instead, when the customer has edited their CA +instructions, the tool extracts that delta (customer CA vs the shipped CA baseline) +and re-applies it onto the DA's wording via the GitHub Copilot API — reusing +`gh auth token` exactly as the ESS Maker Kit's evaluation judge does, so no API +keys or endpoints are needed. Pass `--keep-instructions` to skip the model (keep the +DA's shipped instructions and report the edit as a conflict) when `gh` Copilot +access is unavailable or deterministic output is required; if the model can't be +reached at run time, the tool falls back to that behaviour automatically. + +### 5. Or the customer imports the package + +``` +POST .../copilotstudio/tenants/{tid}/environments/{eid}/minimalBots/alm/import +``` + +with the zip as the payload and `schemaName` set to the existing agent (without it +the API returns 409 when the agent already exists). Import is a **clean replace +into the development environment only** — Test and Production are untouched until +the customer promotes. + +Connection ids and secrets are deliberately **not** in the package: they belong to +the destination environment and are bound there. A connection is left *Unbound* by +setting its `connectionId` to `null` — the ESS template ships exactly this shape and +the import binding step relies on the key being present; removing it entirely makes +the package fail to bind. Secret literals are dropped (only `kv://` key-vault +references may travel). + +--- + +## How it decides + +Every customization ends up in exactly one bucket, and every bucket appears in the +report. + +| outcome | meaning | +| --- | --- | +| `merged` | edited out-of-box component, merged cleanly onto the new template | +| `carried-new` | customer-authored component with no template counterpart; carried wholesale and marked customer-owned | +| `conflicted` | the customer and ESS changed the same thing differently — needs a human | +| `locked` | `Overlays.json` marks it read-only; the edit could not be carried | +| `unchanged` | identical to what ESS shipped; nothing to carry | +| `no-target` | the component type has no DA equivalent the tool recognises | +| `blocked` | belongs to a domain integration ESS has not ported to a DA; there is no agent for it to attach to. Named and reproduced in the report, never carried | +| `failed` | could not be projected or merged; the reason is in the report | + +Independently of the above, any carried topic that uses a construct the DA does +not support is **disabled but preserved**: all of its logic is carried across +intact, then marked `Inactive` with a `[DEPRECATED]` title. Nothing is ever +deleted. Each gap is labelled with who has to act on it: + +| owner | meaning | +| --- | --- | +| *Handled for you* | carried or reconfigured automatically; just verify it | +| *You need to rebuild this* | real work, with the ADK command that does it | +| *Cannot be preserved* | no equivalent exists and none is planned; the capability is lost | + +Only the last kind makes a migration `blocked`. The rest is a worklist. + +### What the report will not tell you + +The report covers *content*. Three things change for employees that no amount of +merging fixes, and all three are called out in the report so they are not +discovered after cutover: + +- **Conversation history does not move.** The DA starts empty. +- **Usage analytics start from zero.** Reporting is per-agent, so the DA does not + continue the CA's adoption trend line. Export what leaders need first. +- **This tool does not move employees between agents.** It prepares the DA's + content; cutover and distribution are decided elsewhere. + +### Merge policy — `Overlays.json` + +The ALM package format carries `Overlays.json`, which classifies each path as +author-locked, template-default-but-customizable, or customer-owned. The tool +consumes it as its merge policy, so the same classification drives this one-time +migration *and* every future platform upgrade. + +ESS has not authored one yet. Until it does, every path defaults to a three-way +merge — the most common real classification, and the safest: it can produce a +conflict for a human to resolve, but it never silently discards either side. + +--- + +## Layout + +``` +src/essmig/ + ess.py ESS constants: solution names, schema prefixes, component types + auth.py MSAL public-client auth (in-memory cache only) + dataverse.py read-only Dataverse Web API client + discovery.py what did the customer change? + reference.py vendored base + theirs + projection.py CA botcomponent → agent.yml component shape + merge.py three-way merge + Overlays policy + instructions.py model-backed reconciliation of edited agent instructions + llm.py minimal GitHub Copilot API client (gh auth token) + rules.py constructs the DA does not support, and who must act on each + assessment.py the eligibility verdict: blockers, worklist, employee impact + packaging.py emit the ALM package + deliver.py import the package into a target DA (the one write path; opt-in) + report.py the report + cli.py vendor / inspect / migrate +reference/ vendored reference data (committed) +tests/ +``` + +## Develop + +```powershell +python -m pytest tests -q +python -m ruff check . +python -m mypy +``` + +--- + +## Things worth knowing + +These are the details that are easy to get wrong; they are also commented at the +point of use. + +- **`msdyn_componentlayers` is a virtual table.** Its filter must pair + `msdyn_componentid` with `msdyn_solutioncomponentname`, and it will *not* honour + an `OR` over several component ids — OR-ing silently returns a couple of rows. + One query per component is mandatory. +- **`msdyn_overwritetime` is not a customization signal.** A net-new unmanaged + topic reads `1900-01-01`. Classification is by solution layer instead. +- **Solution unique names ≠ folder names.** `EssHRWorkdayHCM` is + `msdyn_EssHRWorkday`; `EssHRADPHCM` is `msdyn_EssHRADP`. Getting this wrong makes + the tool read untouched out-of-box content as a customization. Always read + `` from the manifest. +- **HR, IT and core are separate baselines.** The same suffix — `gpt.default`, say + — exists under all three with materially different content. Pooling them would + diff an HR customer's edit against IT's shipped version. +- **Prefix rewrites must be longest-first.** `msdyn_copilotforemployeeselfservice` + is a proper prefix of `…servicehr`. +- **Preferred-solution membership can't be read from layers** — every unmanaged + solution shares the one `Active` layer. It comes from `solutioncomponents`. + +## Against the migration spec + +Measured against *ESS CA to DA Migration* (`specs/ess/ca-da-migration/spec.md`, +`ideas-exp` PR 5631640): + +| | requirement | status | +| --- | --- | --- | +| R1 | Outcomes explicit for every scenario; nothing dropped silently | met | +| R2 | Actionable guidance for gaps, including ADK configuration | met | +| R3 | Prepare and validate without affecting production | partial — read-only against the source, and delivery targets the Dev ring only; it does not create the isolated draft or run evals | +| R4 | Publish directly; ALM optional | partial — `migrate --import` now delivers directly by importing the ALM package into the target; ALM is not yet *optional*, so there is no non-ALM publish path | +| R5 | Employees experience no required action or visible disruption | not addressed — out of reach for a package-generating CLI; the report names the impact instead | +| R6 | Hub migration with incremental domain migration | partial — Core (the hub) and each domain agent are first-class `--vertical` targets you migrate independently; there is no single command that orchestrates the hub plus its spokes in one pass | + +R4 and R5 are scope decisions, not defects. Take them up before this is +offered to a customer. + +## Open questions + +- The ALM design this is built against (*ALM for Declarative Agents — Technical + Design*, 2026-06-28) is a draft and is still changing. The package contract and + the import endpoint should be re-confirmed before a customer-facing release. +- Who authors `Overlays.json` — ESS or the platform team? +- The unsupported-construct catalog in `rules.py` was compiled against the DA + *preview*. Some entries may no longer be restrictions; re-validate it on each + template refresh, because a stale entry needlessly disables a working topic. +- Nothing here has been run against a live customer CA environment yet. Discovery + is covered by tests against synthetic Dataverse responses, not by a real run. + +## Relationship to `tools/ess-nextgen-migration-toolkit` + +That is the earlier attempt. It targeted the DA *preview*, which was still a +Dataverse solution, so it worked by PATCHing `botcomponent` rows in place — a +write path that cannot reach the current Cosmos-backed DA at all. Its discovery +algorithm was sound and is carried forward here; the rest does not apply. It is +left untouched. diff --git a/tools/ess-ca-to-da/docs/DEV_DESIGN.md b/tools/ess-ca-to-da/docs/DEV_DESIGN.md new file mode 100644 index 000000000..6e9985be9 --- /dev/null +++ b/tools/ess-ca-to-da/docs/DEV_DESIGN.md @@ -0,0 +1,292 @@ +# Dev Design — ESS Custom Engine Agent → Declarative Agent migration (`ess-ca-to-da`) + +**Status:** Working draft, reflects the implemented tool. +**Audience:** Engineers and reviewers who need to understand how the migration works, the decisions behind it, and where the risks are. +**Scope:** The `essmig` CLI under `tools/ess-ca-to-da`. It migrates a customer's **ESS Custom Engine Agent (CA)** customizations onto the current **ESS Declarative Agent (DA)** and delivers an importable ALM package plus a report. + +--- + +## 1. Problem statement + +A customer has deployed and customized the ESS **Custom Engine Agent** — a Dataverse-backed managed solution. ESS has since shipped a **Declarative Agent** as the go-forward product. We need to move the customer's *customizations* — not the whole agent — onto the DA, without a human hand-porting every topic. + +The two agents are **not the same content in a different wrapper**: + +| | Custom Engine Agent (CA) | Declarative Agent (DA) | +| --- | --- | --- | +| Stored in | Dataverse (`botcomponent` rows in a managed solution) | Cosmos, via the Agent Builder Service | +| Shipped as | a managed solution | an ALM *templated package* (`agent.yml` + `app.config..json`) | +| Schema prefix | `msdyn_…` | `gptagent_…` | +| Customer edits live as | unmanaged solution layers | overlays governed by `Overlays.json` | + +ESS did **not** re-wrap the CA to build the DA; much of the content was rewritten. Across the shipping HR template, of the components that have a CA ancestor only a minority are byte-identical after normalisation, some are 50–90% similar, and a few were rebuilt outright. Roughly half the DA's components have no CA ancestor at all. + +That rules out both naive strategies: + +- **Copy the customer's topics over the DA** → destroys every ESS improvement. +- **Keep the DA, discard the customer's edits** → destroys the customer's work. + +### 1.1 ESS is three agents, not one + +ESS ships a **hub-and-spoke** topology, and the tool models it directly: + +| `--vertical` (`TARGETS`) | agent | schema (CA → DA) | role | +| --- | --- | --- | --- | +| `core` | Core | `msdyn_copilotforemployeeselfservicecore` → `gptagent_…core` | standalone **hub / router**; delegates to a domain agent | +| `hr` | HR | `…hr` → `gptagent_…hr` | HR domain agent | +| `it` | IT | `…it` → `gptagent_…it` | IT domain agent | + +Each target is a **first-class, independent migration**: its own CA source +solution, its own DA template, its own package. The tool is run once per agent — +or with `--vertical` omitted it **auto-detects** which of Core/HR/IT are installed +(the three base solutions have fixed unique names, so their presence in `solutions` +is the signal; see `discovery.installed_targets`) and migrates each into its own +`out//` subfolder. Core is **not** shared plumbing folded into HR/IT — it +is a separate agent whose +instructions route to the domain agents, so its baseline is read on its own and +its references repoint to the Core DA agent, never to HR/IT. (`msdyn_copilotforemployeeselfservice` +with no suffix is a *legacy monolith* bot, distinct from the Core hub; it is not +a DA target.) + +The **domain integrations** (ServiceNow, Workday, SuccessFactors, ADP, …) are a +different animal: they are *not* separate agents. They layer into HR/IT, and where +ESS has shipped a DA equivalent (`PORTED_EXTENSION_SOLUTIONS`) their content is +baked into the HR/IT `agent.yml`, so a customization to one merges normally. +Integrations ESS has **not** yet ported (`NON_PORTED_EXTENSION_SOLUTIONS`) have no +DA home; a customization to one is reported **`blocked`** (§7), named, with its +configuration reproduced — never silently dropped or misattached. + +## 2. Core decision: a three-way merge + +The tool does what the platform's own *Upgrade* does for templated agents — a **three-way merge** — because that is the only strategy that preserves *both* the ESS rewrite *and* the customer's intent. + +A three-way merge needs three inputs: + +- **base** — the CA component *exactly as ESS shipped it* (the common ancestor). +- **ours** — the customer's live CA component (base + the customer's edits). +- **theirs** — the DA component *as ESS ships it today* (base, rewritten for the DA). + +The platform can't run this merge for a CA customer — they have no Git Repository Service repo, no base commit, no `templateBaseVersion`, so there is no merge base. **But all three inputs can be reconstructed from sources ESS owns**, which is the key enabling insight of this design. + +``` + base (CA as shipped) theirs (DA as shipped today) + reference/ca-baseline.json reference//agent.yml + \ / + \ / + ours (customer's live CA) ----> THREE-WAY MERGE ----> merged agent.yml + Dataverse discovery | + v + ALM package (.zip) + report.md +``` + +The tool **never writes to Dataverse or the customer's tenant** (the one optional exception is `migrate --import`, §9). The customer's CA keeps running untouched; the deliverable is a package the customer imports themselves. + +--- + +## 3. The three inputs — how each is obtained + +### 3.1 `base` and `theirs` — vendored, offline (`reference.py`, `ess.py`) + +Two of the three inputs are ESS's own product artifacts, so they are **vendored into the tool** and used offline. A customer run needs no access to the ESS product sources. + +- **base** comes from the CA solution sources (`sources/dev/solutions//Solution/botcomponents//data`) and is stored as `reference/ca-baseline.json`, keyed by vertical (`core`/`hr`/`it`) and by schema-name **suffix**. +- **theirs** comes from the DA template (`sources/dev/AgentTemplates/