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
1 change: 1 addition & 0 deletions src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,4 @@ description: Initial standalone release notes for the CrestApps.Core repository.
- promotes the parameterized AI tool instances into a first-class opt-in feature registered on the AI suite builder with `AddToolInstances(toolInstances => toolInstances.AddSource<TSource>(...))` (with an `AddHttpApiRequestSource()` convenience for the built-in source), instead of being wired into the core AI services automatically; persistence is registered on the tool-instances builder via `AddYesSqlStores()`/`AddEntityCoreStores()` rather than on the AI suite; drops the redundant per-instance `DisplayText` in favor of the unique `Name`, localizes the source `DisplayName`/`Description`/`Category`, simplifies `IAIToolInstanceSource.CreateTool(AIToolInstance instance)` to take the instance directly, generalizes the completion-context handler to honor tool instances on any resource so both AI profiles and chat interactions can select them, renames the built-in HTTP source's basic/OAuth credentials to `Username`/`Password` and adds the OAuth 2.0 resource-owner password grant, hardens model-provided paths so they cannot redirect a request off the configured host, and updates the MVC and Blazor sample hosts to let users attach instances to both AI profiles and chat interactions
- namespaces every tool-instance function name with the `AIToolInstanceExtensions.FunctionNamePrefix` (`tool_instance_`) prefix so a user-chosen instance name can never collide with a tool registered in code via `AddCoreAITool` (which the model sees under its bare registered name); both kinds of tool now coexist safely in the single function namespace exposed to OpenAI, Azure OpenAI, and every other client
- adds a source dropdown to the AI tool instance create form in the MVC and Blazor sample hosts that reveals only the selected source's fields (the source is fixed and shown read-only on edit), and relocates the "AI Tool Instances" admin menu item next to "AI Profiles" in both samples
- makes the Copilot CLI acquisition work behind corporate proxies and artifact mirrors, and downloads it only once per machine: `CrestApps.Core.AI.Copilot` now resolves the effective npm registry from `NPM_CONFIG_REGISTRY` or `npm config get registry` before the `GitHub.Copilot.SDK` targets download the CLI tarball (the SDK hardcodes `https://registry.npmjs.org`, and MSBuild's `DownloadFile` task cannot read npm configuration), and redirects the SDK's per-project, per-configuration cache to a shared cache under the NuGet global packages folder so a multi-project solution, a fresh worktree, or a CI agent no longer re-downloads the same large tarball for every project; both behaviors are opt-out through `CopilotResolveNpmRegistry` and `CopilotUseSharedCliCache`, the cache location is configurable through `CopilotCliCacheDir` (point it at a pre-seeded directory to build offline), and an explicitly set `CopilotNpmRegistryUrl`, `CopilotCliBinaryPath`, or `CopilotSkipCliDownload` always takes precedence
31 changes: 31 additions & 0 deletions src/CrestApps.Core.Docs/docs/orchestration/copilot.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,37 @@ public class MyController(IOrchestratorResolver resolver)
}
```

## Copilot CLI Acquisition

The Copilot orchestrator runs on top of the GitHub Copilot CLI. Referencing `CrestApps.Core.AI.Copilot` is the only thing a host has to do — there is nothing to install, configure, or add to your project files. The package re-imports the `GitHub.Copilot.SDK` build targets, which download the CLI for the current runtime identifier during build and copy it to `runtimes/<rid>/native/` in the output folder.

The package layers two improvements on top of the SDK's default acquisition, both of which apply automatically.

**A machine-wide CLI cache.** The SDK caches the CLI under the intermediate output path, which is per project *and* per configuration, so a solution with several referencing projects downloads the same large tarball once per project, and again for every fresh clone, worktree, or CI agent. The package redirects that to a shared cache under the NuGet global packages folder, so the CLI is downloaded once and reused everywhere. That folder is already restored per machine and is commonly cached by CI pipelines, so build agents benefit as well.

**Automatic npm registry resolution.** The SDK downloads the CLI tarball from `https://registry.npmjs.org`, and MSBuild's `DownloadFile` task cannot read npm configuration. Machines and build agents behind a corporate proxy or an artifact mirror usually block that host, so the build fails with a connectivity error even though npm itself is configured correctly. The package resolves the effective registry from the `NPM_CONFIG_REGISTRY` environment variable, falling back to `npm config get registry`, and uses that instead. The lookup only runs when a download is actually about to happen, and silently falls back to the SDK default when npm is unavailable or reports something that is not a URL.

Both behaviors are on by default and require no configuration. The properties below exist only for hosts that need to override them, and none of them has to be set for a normal build.

| Property | Default | Description |
| --- | --- | --- |
| `CopilotUseSharedCliCache` | `true` | Set to `false` to keep the SDK's per-project cache. |
| `CopilotCliCacheDir` | `<nuget-packages>/.crestapps/copilot-cli/` | Location of the shared cache. Point this at a pre-seeded directory to build without network access. |
| `CopilotResolveNpmRegistry` | `true` | Set to `false` to always use the SDK default registry. |
| `CopilotNpmRegistryUrl` | resolved from npm | Pin a registry explicitly. An explicit value always wins over resolution. |

These are ordinary MSBuild properties. When you do want to override one across a solution, set it in `Directory.Build.props` — for example, to keep the cache inside the repository instead of the NuGet global packages folder:

```xml
<Project>
<PropertyGroup>
<CopilotCliCacheDir>$(MSBuildThisFileDirectory).copilot-cli\</CopilotCliCacheDir>
</PropertyGroup>
</Project>
```

The SDK's own `CopilotCliBinaryPath` (use an already-downloaded binary) and `CopilotSkipCliDownload` (skip acquisition entirely) properties continue to work and take precedence over everything above.

## Problem & Solution

The [default orchestrator](./index.md) manages the full agentic pipeline — tool calling, progressive scoping, RAG injection, and streaming — using `Microsoft.Extensions.AI`. This works well when you control the provider connection and model selection.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
<PropertyGroup>
<_CopilotTargetsTemplate>$([System.IO.File]::ReadAllText('$(MSBuildProjectDirectory)\$(CopilotTargetsTemplateFile)'))</_CopilotTargetsTemplate>
<_GeneratedCopilotTargets>$([System.String]::Copy('$(_CopilotTargetsTemplate)').Replace('__GitHubCopilotSdkVersion__', '$(GitHubCopilotSdkVersion)'))</_GeneratedCopilotTargets>
<!-- WriteLinesToFile treats Lines as an item list, so semicolons and percent signs in the
template would otherwise be split or interpreted instead of written verbatim. -->
<_GeneratedCopilotTargets>$([MSBuild]::Escape('$(_GeneratedCopilotTargets)'))</_GeneratedCopilotTargets>
</PropertyGroup>

<MakeDir Directories="$(GeneratedBuildTransitiveDir)" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
<_GitHubCopilotSdkVersion Condition="'$(_GitHubCopilotSdkVersion)' == ''">__GitHubCopilotSdkVersion__</_GitHubCopilotSdkVersion>
<_GitHubCopilotSdkPackageRoot>$([MSBuild]::EnsureTrailingSlash('$(NuGetPackageRoot)'))</_GitHubCopilotSdkPackageRoot>
<_GitHubCopilotSdkBuildDir>$(_GitHubCopilotSdkPackageRoot)github.copilot.sdk/$(_GitHubCopilotSdkVersion)/build</_GitHubCopilotSdkBuildDir>
<!--
The SDK assigns its own default to CopilotNpmRegistryUrl while it is being imported below,
so record whether the consumer supplied a value before that happens. Without this marker the
registry resolution further down cannot tell an explicit choice apart from the SDK default.
-->
<_CopilotNpmRegistryUrlWasSet Condition="'$(CopilotNpmRegistryUrl)' != ''">true</_CopilotNpmRegistryUrlWasSet>
</PropertyGroup>

<Import Project="$(_GitHubCopilotSdkBuildDir)/GitHub.Copilot.SDK.targets"
Expand All @@ -29,4 +35,101 @@
Text="GitHub.Copilot.SDK build targets not found. Expected at '$(_GitHubCopilotSdkBuildDir)'. Ensure the GitHub.Copilot.SDK NuGet package (version $(_GitHubCopilotSdkVersion)) is restored." />
</Target>

<!--
The SDK caches the downloaded Copilot CLI under the intermediate output path, which is
per project and per configuration. A solution with several projects that reference this
package therefore downloads the same ~100 MB tarball once per project, and again for every
fresh clone, worktree, configuration or CI agent.

Redirect that to a machine wide cache so the CLI is downloaded once and reused everywhere.
The default location lives under the NuGet global packages folder because that folder is
already restored per machine and is commonly cached by CI pipelines.

Set CopilotCliCacheDir to relocate the cache, or CopilotUseSharedCliCache=false to opt out
and keep the SDK default behavior.
-->
<PropertyGroup>
<CopilotUseSharedCliCache Condition="'$(CopilotUseSharedCliCache)' == ''">true</CopilotUseSharedCliCache>
<CopilotCliCacheDir Condition="'$(CopilotCliCacheDir)' == '' And '$(_GitHubCopilotSdkPackageRoot)' != ''">$(_GitHubCopilotSdkPackageRoot).crestapps/copilot-cli/</CopilotCliCacheDir>
</PropertyGroup>

<PropertyGroup Condition="'$(CopilotUseSharedCliCache)' == 'true' And '$(CopilotSkipCliDownload)' != 'true' And '$(CopilotCliBinaryPath)' == '' And '$(CopilotCliCacheDir)' != '' And '$(CopilotCliVersion)' != '' And '$(_CopilotPlatform)' != ''">
<_CopilotSharedCliDir>$([MSBuild]::EnsureTrailingSlash('$(CopilotCliCacheDir)'))$(CopilotCliVersion)/$(_CopilotPlatform)/</_CopilotSharedCliDir>
<_CopilotSharedCliPath>$(_CopilotSharedCliDir)$(_CopilotBinary)</_CopilotSharedCliPath>
<!--
When the shared copy already exists, hand it to the SDK so the download is skipped entirely.
Both the public and the private property are assigned because the SDK maps one onto the other
before this file is evaluated.
-->
<CopilotCliBinaryPath Condition="Exists('$(_CopilotSharedCliPath)')">$(_CopilotSharedCliPath)</CopilotCliBinaryPath>
<_CopilotCliBinaryPath Condition="Exists('$(_CopilotSharedCliPath)')">$(_CopilotSharedCliPath)</_CopilotCliBinaryPath>
</PropertyGroup>

<!--
The SDK downloads the CLI tarball from https://registry.npmjs.org unless CopilotNpmRegistryUrl
is set, and MSBuild's DownloadFile task cannot read npm configuration. Developer machines and
build agents behind a corporate proxy or an artifact mirror usually block that host, so the
build fails with a connectivity error even though npm itself is configured correctly.

Ask npm for its effective registry and use that. Resolution only runs when a download is about
to happen, and silently falls back to the SDK default when npm is unavailable or reports
something that is not a URL.

Set CopilotNpmRegistryUrl to pin a registry explicitly, or CopilotResolveNpmRegistry=false to
disable the lookup.
-->
<PropertyGroup>
<CopilotResolveNpmRegistry Condition="'$(CopilotResolveNpmRegistry)' == ''">true</CopilotResolveNpmRegistry>
<_CopilotDefaultNpmRegistryUrl>https://registry.npmjs.org</_CopilotDefaultNpmRegistryUrl>
</PropertyGroup>

<Target Name="_ResolveCopilotNpmRegistry" BeforeTargets="_DownloadCopilotCli"
Condition="'$(CopilotResolveNpmRegistry)' == 'true' And '$(_CopilotNpmRegistryUrlWasSet)' != 'true' And ('$(CopilotNpmRegistryUrl)' == '' Or '$(CopilotNpmRegistryUrl)' == '$(_CopilotDefaultNpmRegistryUrl)') And '$(CopilotCliBinaryPath)' == '' And '$(CopilotSkipCliDownload)' != 'true' And '$(_CopilotPlatform)' != ''">
<PropertyGroup>
<_CopilotResolvedNpmRegistry Condition="'$(NPM_CONFIG_REGISTRY)' != ''">$(NPM_CONFIG_REGISTRY)</_CopilotResolvedNpmRegistry>
</PropertyGroup>

<Exec Command="npm config get registry"
Condition="'$(_CopilotResolvedNpmRegistry)' == ''"
ConsoleToMSBuild="true"
IgnoreExitCode="true"
EchoOff="true"
StandardOutputImportance="low"
StandardErrorImportance="low">
<Output TaskParameter="ConsoleOutput" PropertyName="_CopilotNpmConfigOutput" />
<Output TaskParameter="ExitCode" PropertyName="_CopilotNpmConfigExitCode" />
</Exec>

<PropertyGroup Condition="'$(_CopilotResolvedNpmRegistry)' == '' And '$(_CopilotNpmConfigExitCode)' == '0' And '$(_CopilotNpmConfigOutput)' != ''">
<_CopilotNpmConfigOutput>$(_CopilotNpmConfigOutput.Trim())</_CopilotNpmConfigOutput>
<_CopilotResolvedNpmRegistry Condition="$(_CopilotNpmConfigOutput.StartsWith('http'))">$(_CopilotNpmConfigOutput)</_CopilotResolvedNpmRegistry>
</PropertyGroup>

<PropertyGroup Condition="'$(_CopilotResolvedNpmRegistry)' != ''">
<CopilotNpmRegistryUrl>$(_CopilotResolvedNpmRegistry)</CopilotNpmRegistryUrl>
</PropertyGroup>

<Message Importance="normal" Condition="'$(_CopilotResolvedNpmRegistry)' != ''"
Text="Using the npm registry '$(CopilotNpmRegistryUrl)' to download the Copilot CLI." />
</Target>

<!--
Promote a freshly downloaded CLI into the shared cache so later builds skip the download.
The binary is staged under a unique name first so that projects building in parallel cannot
observe a partially written file.
-->
<Target Name="_CacheCopilotCliForReuse" AfterTargets="_DownloadCopilotCli"
Condition="'$(_CopilotSharedCliPath)' != '' And '$(CopilotSkipCliDownload)' != 'true' And '$(_CopilotCliBinaryPath)' != '' And !Exists('$(_CopilotSharedCliPath)')">
<PropertyGroup>
<_CopilotSharedCliStagingPath>$(_CopilotSharedCliPath).$([System.Guid]::NewGuid().ToString('N')).tmp</_CopilotSharedCliStagingPath>
</PropertyGroup>

<MakeDir Directories="$(_CopilotSharedCliDir)" Condition="Exists('$(_CopilotCliBinaryPath)')" />
<Copy SourceFiles="$(_CopilotCliBinaryPath)" DestinationFiles="$(_CopilotSharedCliStagingPath)"
Condition="Exists('$(_CopilotCliBinaryPath)')" />
<Move SourceFiles="$(_CopilotSharedCliStagingPath)" DestinationFiles="$(_CopilotSharedCliPath)"
Condition="Exists('$(_CopilotSharedCliStagingPath)')" />
<Delete Files="$(_CopilotSharedCliStagingPath)" Condition="Exists('$(_CopilotSharedCliStagingPath)')" />
</Target>

</Project>
Loading