From 3e7eff7ffcac405d14d24d6dafccdd056b713473 Mon Sep 17 00:00:00 2001 From: Andrii Bezzub Date: Thu, 9 Jul 2026 09:10:22 +0000 Subject: [PATCH] feat(copilot): add context and reasoning_effort config fields Add copilot.context and copilot.reasoning_effort to moat.yaml, with corresponding --context and --reasoning-effort CLI flags. CLI flags override config values, matching the existing --model precedence. This lets users pin context window tier (default/long_context) and reasoning effort level (none/minimal/low/medium/high/xhigh/max) declaratively so settings persist across moat runs. --- CHANGELOG.md | 2 +- docs/content/guides/17-copilot.md | 2 + docs/content/reference/01-cli.md | 2 + docs/content/reference/02-moat-yaml.md | 30 +++++++++ internal/config/config.go | 8 +++ internal/providers/copilot/cli.go | 20 ++++++ internal/providers/copilot/provider_test.go | 68 ++++++++++++++++++--- 7 files changed, 124 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61e259d1..9e97ffec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Adds HTTP request-body inspection to Keep policies. File- and pack-based `networ ### Added -- **GitHub Copilot CLI agent** — run GitHub Copilot CLI with `moat copilot`. Copilot uses the existing `github` grant: Moat injects that GitHub token for GitHub/Copilot API hosts plus HTTPS git, while the container receives only placeholders. `moat copilot` installs `@github/copilot`, stages Copilot config/context, passes `--allow-all` by default, and supports `copilot.model`, `copilot.experimental`, and `copilot.autopilot` in `moat.yaml`. See [Running GitHub Copilot CLI](https://majorcontext.com/moat/guides/copilot). ([#436](https://github.com/majorcontext/moat/pull/436)) +- **GitHub Copilot CLI agent** — run GitHub Copilot CLI with `moat copilot`. Copilot uses the existing `github` grant: Moat injects that GitHub token for GitHub/Copilot API hosts plus HTTPS git, while the container receives only placeholders. `moat copilot` installs `@github/copilot`, stages Copilot config/context, passes `--allow-all` by default, and supports `copilot.model`, `copilot.context`, `copilot.reasoning_effort`, `copilot.experimental`, and `copilot.autopilot` in `moat.yaml`. See [Running GitHub Copilot CLI](https://majorcontext.com/moat/guides/copilot). ([#436](https://github.com/majorcontext/moat/pull/436)) - **Pi packages & safe defaults** — declare Pi extensions/skills/themes in `pi.packages` (remote `npm:`/`git:`/`https:`/`ssh:` sources) and Moat installs them into the image at build time via `pi install`, baked into a reproducible cached layer. Every `moat pi` image also bakes a safe `~/.pi/agent/settings.json` — `defaultProjectTrust: never` (a checked-out repo's own `.pi/` extensions, which are arbitrary code, do not auto-load), telemetry off, quiet startup — that a workspace cannot override. Because Pi config can redirect model traffic to any host, `moat pi` now warns under a permissive network policy (only `network.policy: strict` truly constrains egress). See [Running Pi](https://majorcontext.com/moat/guides/pi). ([#434](https://github.com/majorcontext/moat/pull/434)) - **Pi coding agent** — run the [Pi coding agent](https://github.com/earendil-works/pi) with `moat pi`. Pi has no credential of its own; it runs against your existing `anthropic` or `openai` grant. When exactly one is configured it is used automatically; when both are, choose one with `--provider` or `pi.provider` in `moat.yaml`. Only the `anthropic` and `openai` backends are supported today — any other backend, or a missing/ambiguous grant, fails before a container is created. Configure with the `pi:` block (`provider`, `model`). See [Running Pi](https://majorcontext.com/moat/guides/pi) and `examples/agent-pi`. ([#433](https://github.com/majorcontext/moat/pull/433)) - **`opentofu` and `terragrunt` dependencies** — two new managed cloud tools. `opentofu` installs the OpenTofu CLI as the `tofu` command; `terragrunt` installs the Terragrunt orchestration wrapper. Both install as prebuilt release binaries with no image rebuild cost beyond their own layer. Terragrunt delegates to a Terraform or OpenTofu binary on `PATH`, so pair it with an engine — `dependencies: [terraform, terragrunt]`, or `dependencies: [opentofu, terragrunt]` with `env.TERRAGRUNT_TFPATH: tofu`. See [Dependencies](https://majorcontext.com/moat/reference/dependencies). ([#430](https://github.com/majorcontext/moat/pull/430)) diff --git a/docs/content/guides/17-copilot.md b/docs/content/guides/17-copilot.md index 6a897178..c569d062 100644 --- a/docs/content/guides/17-copilot.md +++ b/docs/content/guides/17-copilot.md @@ -65,6 +65,8 @@ grants: copilot: model: gpt-5.4 + context: long_context + reasoning_effort: high experimental: false autopilot: false ``` diff --git a/docs/content/reference/01-cli.md b/docs/content/reference/01-cli.md index f27614da..bea40e56 100644 --- a/docs/content/reference/01-cli.md +++ b/docs/content/reference/01-cli.md @@ -308,6 +308,8 @@ In addition to the command-specific flags below, `moat copilot` accepts all [com | `-p`, `--prompt TEXT` | Run non-interactive with prompt | | `--allow-all` | Allow all Copilot tools, paths, and URLs without prompting. Default: `true`. | | `--model MODEL` | Select the model to use. Overrides `copilot.model`. | +| `--context TIER` | Set context window tier (`default` or `long_context`). Overrides `copilot.context`. | +| `--reasoning-effort LEVEL` | Set reasoning effort level (`none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`). Overrides `copilot.reasoning_effort`. | | `--experimental` | Enable Copilot CLI experimental features. | | `--autopilot` | Start Copilot CLI in autopilot mode. | diff --git a/docs/content/reference/02-moat-yaml.md b/docs/content/reference/02-moat-yaml.md index 76875ad2..281851fd 100644 --- a/docs/content/reference/02-moat-yaml.md +++ b/docs/content/reference/02-moat-yaml.md @@ -129,6 +129,8 @@ codex: # GitHub Copilot CLI copilot: model: gpt-5.4 + context: long_context + reasoning_effort: high experimental: false autopilot: false @@ -1595,6 +1597,34 @@ copilot: - Default: Copilot CLI default - CLI override: `moat copilot --model` +### copilot.context + +Context window tier to pass to `copilot --context`. + +```yaml +copilot: + context: long_context +``` + +- Type: `string` +- Values: `default`, `long_context` +- Default: Copilot CLI default +- CLI override: `moat copilot --context` + +### copilot.reasoning_effort + +Reasoning effort level to pass to `copilot --reasoning-effort`. + +```yaml +copilot: + reasoning_effort: high +``` + +- Type: `string` +- Values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` +- Default: Copilot CLI default +- CLI override: `moat copilot --reasoning-effort` + ### copilot.experimental Start Copilot CLI with experimental features enabled. diff --git a/internal/config/config.go b/internal/config/config.go index 95fc0a8e..2072e162 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -339,6 +339,14 @@ type CopilotConfig struct { // Model optionally selects the model passed to `copilot --model`. Model string `yaml:"model,omitempty"` + // Context sets the context window tier passed to `copilot --context`. + // Valid values: "default", "long_context". + Context string `yaml:"context,omitempty"` + + // ReasoningEffort sets the reasoning effort level passed to `copilot --reasoning-effort`. + // Valid values: "none", "minimal", "low", "medium", "high", "xhigh", "max". + ReasoningEffort string `yaml:"reasoning_effort,omitempty"` + // Experimental starts Copilot CLI with --experimental. Experimental bool `yaml:"experimental,omitempty"` diff --git a/internal/providers/copilot/cli.go b/internal/providers/copilot/cli.go index 9a79620b..d66913b1 100644 --- a/internal/providers/copilot/cli.go +++ b/internal/providers/copilot/cli.go @@ -18,9 +18,13 @@ var ( copilotWtFlag string copilotAllowAll bool copilotModelFlag string + copilotContextFlag string + copilotReasoningEffortFlag string copilotExperimental bool copilotAutopilot bool copilotResolvedModel string + copilotResolvedContext string + copilotResolvedEffort string copilotCredentialConfigured = defaultCopilotCredentialConfigured ) @@ -65,6 +69,8 @@ Examples: copilotCmd.Flags().StringSliceVar(&copilotAllowedHosts, "allow-host", nil, "additional hosts to allow network access to") copilotCmd.Flags().BoolVar(&copilotAllowAll, "allow-all", true, "allow all Copilot tools, paths, and URLs without prompting") copilotCmd.Flags().StringVar(&copilotModelFlag, "model", "", "model to use (overrides copilot.model)") + copilotCmd.Flags().StringVar(&copilotContextFlag, "context", "", "context window tier: default or long_context (overrides copilot.context)") + copilotCmd.Flags().StringVar(&copilotReasoningEffortFlag, "reasoning-effort", "", "reasoning effort level (overrides copilot.reasoning_effort)") copilotCmd.Flags().BoolVar(&copilotExperimental, "experimental", false, "enable Copilot CLI experimental features") copilotCmd.Flags().BoolVar(&copilotAutopilot, "autopilot", false, "start Copilot CLI in autopilot mode") copilotCmd.Flags().StringVar(&copilotWtFlag, "worktree", "", "run in a git worktree for this branch") @@ -98,11 +104,19 @@ func runCopilot(cmd *cobra.Command, args []string) error { func resolveCopilotPreflight(cfg *config.Config) error { copilotResolvedModel = copilotModelFlag + copilotResolvedContext = copilotContextFlag + copilotResolvedEffort = copilotReasoningEffortFlag if cfg != nil { cfg.Grants = normalizeCopilotGrants(cfg.Grants, false) if copilotResolvedModel == "" { copilotResolvedModel = cfg.Copilot.Model } + if copilotResolvedContext == "" { + copilotResolvedContext = cfg.Copilot.Context + } + if copilotResolvedEffort == "" { + copilotResolvedEffort = cfg.Copilot.ReasoningEffort + } if cfg.Copilot.Experimental { copilotExperimental = true } @@ -167,6 +181,12 @@ func buildCopilotCommand(promptFlag, initialPrompt string) []string { if copilotResolvedModel != "" { cmd = append(cmd, "--model", copilotResolvedModel) } + if copilotResolvedContext != "" { + cmd = append(cmd, "--context", copilotResolvedContext) + } + if copilotResolvedEffort != "" { + cmd = append(cmd, "--reasoning-effort", copilotResolvedEffort) + } if copilotExperimental { cmd = append(cmd, "--experimental") } diff --git a/internal/providers/copilot/provider_test.go b/internal/providers/copilot/provider_test.go index fdc47f7a..419c3567 100644 --- a/internal/providers/copilot/provider_test.go +++ b/internal/providers/copilot/provider_test.go @@ -131,7 +131,7 @@ func TestRegisterCLI(t *testing.T) { if cmd == nil || cmd.Use != "copilot [workspace] [flags] [-- initial-prompt]" { t.Fatalf("registered command = %#v", cmd) } - for _, flag := range []string{"prompt", "allow-all", "model", "experimental", "autopilot", "worktree"} { + for _, flag := range []string{"prompt", "allow-all", "model", "context", "reasoning-effort", "experimental", "autopilot", "worktree"} { if cmd.Flags().Lookup(flag) == nil { t.Fatalf("copilot command missing --%s flag", flag) } @@ -140,6 +140,8 @@ func TestRegisterCLI(t *testing.T) { func TestResolveCopilotPreflight(t *testing.T) { origModelFlag, origResolvedModel := copilotModelFlag, copilotResolvedModel + origContextFlag, origResolvedContext := copilotContextFlag, copilotResolvedContext + origEffortFlag, origResolvedEffort := copilotReasoningEffortFlag, copilotResolvedEffort origExperimental, origAutopilot := copilotExperimental, copilotAutopilot origFlagGrants := copilotFlags.Grants origDryRun := cli.DryRun @@ -147,6 +149,10 @@ func TestResolveCopilotPreflight(t *testing.T) { t.Cleanup(func() { copilotModelFlag = origModelFlag copilotResolvedModel = origResolvedModel + copilotContextFlag = origContextFlag + copilotResolvedContext = origResolvedContext + copilotReasoningEffortFlag = origEffortFlag + copilotResolvedEffort = origResolvedEffort copilotExperimental = origExperimental copilotAutopilot = origAutopilot copilotFlags.Grants = origFlagGrants @@ -155,14 +161,22 @@ func TestResolveCopilotPreflight(t *testing.T) { }) copilotModelFlag = "" + copilotContextFlag = "" + copilotReasoningEffortFlag = "" copilotExperimental = false copilotAutopilot = false copilotFlags.Grants = []string{"copilot", "ssh:github.com"} cli.DryRun = false copilotCredentialConfigured = func() bool { return false } cfg := &config.Config{ - Grants: []string{"github", "copilot"}, - Copilot: config.CopilotConfig{Model: "gpt-5.4", Experimental: true, Autopilot: true}, + Grants: []string{"github", "copilot"}, + Copilot: config.CopilotConfig{ + Model: "gpt-5.4", + Context: "long_context", + ReasoningEffort: "high", + Experimental: true, + Autopilot: true, + }, } if err := resolveCopilotPreflight(cfg); err != nil { @@ -171,6 +185,12 @@ func TestResolveCopilotPreflight(t *testing.T) { if copilotResolvedModel != "gpt-5.4" || !copilotExperimental || !copilotAutopilot { t.Fatalf("resolved state = model:%q experimental:%v autopilot:%v", copilotResolvedModel, copilotExperimental, copilotAutopilot) } + if copilotResolvedContext != "long_context" { + t.Fatalf("resolved context = %q, want long_context", copilotResolvedContext) + } + if copilotResolvedEffort != "high" { + t.Fatalf("resolved effort = %q, want high", copilotResolvedEffort) + } if !slices.Equal(cfg.Grants, []string{"github"}) { t.Fatalf("config grants = %v, want [github]", cfg.Grants) } @@ -179,22 +199,34 @@ func TestResolveCopilotPreflight(t *testing.T) { } } -func TestResolveCopilotPreflightModelFlagOverridesConfig(t *testing.T) { +func TestResolveCopilotPreflightFlagOverridesConfig(t *testing.T) { origModelFlag, origResolvedModel := copilotModelFlag, copilotResolvedModel + origContextFlag, origResolvedContext := copilotContextFlag, copilotResolvedContext + origEffortFlag, origResolvedEffort := copilotReasoningEffortFlag, copilotResolvedEffort origDryRun := cli.DryRun origConfigured := copilotCredentialConfigured t.Cleanup(func() { copilotModelFlag = origModelFlag copilotResolvedModel = origResolvedModel + copilotContextFlag = origContextFlag + copilotResolvedContext = origResolvedContext + copilotReasoningEffortFlag = origEffortFlag + copilotResolvedEffort = origResolvedEffort cli.DryRun = origDryRun copilotCredentialConfigured = origConfigured }) copilotModelFlag = "gpt-5.5" + copilotContextFlag = "default" + copilotReasoningEffortFlag = "max" cli.DryRun = true copilotCredentialConfigured = func() bool { return true } cfg := &config.Config{ - Copilot: config.CopilotConfig{Model: "claude-sonnet-4"}, + Copilot: config.CopilotConfig{ + Model: "claude-sonnet-4", + Context: "long_context", + ReasoningEffort: "high", + }, } if err := resolveCopilotPreflight(cfg); err != nil { @@ -203,6 +235,12 @@ func TestResolveCopilotPreflightModelFlagOverridesConfig(t *testing.T) { if copilotResolvedModel != "gpt-5.5" { t.Fatalf("CLI --model flag should override config model, got %q", copilotResolvedModel) } + if copilotResolvedContext != "default" { + t.Fatalf("CLI --context flag should override config context, got %q", copilotResolvedContext) + } + if copilotResolvedEffort != "max" { + t.Fatalf("CLI --reasoning-effort flag should override config effort, got %q", copilotResolvedEffort) + } } func TestResolveCopilotPreflightAddsRequiredGrantWhenMissing(t *testing.T) { @@ -250,21 +288,26 @@ func TestResolveCopilotPreflightDryRunDoesNotAddMissingGrant(t *testing.T) { } func TestBuildCopilotCommand(t *testing.T) { - origModel, origExperimental, origAutopilot, origAllowAll := copilotResolvedModel, copilotExperimental, copilotAutopilot, copilotAllowAll + origModel, origContext, origEffort := copilotResolvedModel, copilotResolvedContext, copilotResolvedEffort + origExperimental, origAutopilot, origAllowAll := copilotExperimental, copilotAutopilot, copilotAllowAll t.Cleanup(func() { copilotResolvedModel = origModel + copilotResolvedContext = origContext + copilotResolvedEffort = origEffort copilotExperimental = origExperimental copilotAutopilot = origAutopilot copilotAllowAll = origAllowAll }) copilotResolvedModel = "gpt-5.4" + copilotResolvedContext = "long_context" + copilotResolvedEffort = "high" copilotExperimental = true copilotAutopilot = true copilotAllowAll = true got := buildCopilotCommand("fix it", "") - want := []string{"copilot", "--no-auto-update", "--model", "gpt-5.4", "--experimental", "--autopilot", "--allow-all", "-p", "fix it"} + want := []string{"copilot", "--no-auto-update", "--model", "gpt-5.4", "--context", "long_context", "--reasoning-effort", "high", "--experimental", "--autopilot", "--allow-all", "-p", "fix it"} if !slices.Equal(got, want) { t.Errorf("buildCopilotCommand = %v, want %v", got, want) } @@ -276,6 +319,17 @@ func TestBuildCopilotCommand(t *testing.T) { if !slices.Contains(got, "-i") || !slices.Contains(got, "hello") { t.Errorf("initial prompt not passed via -i: %v", got) } + + // Companion case: empty context/effort must not produce flags. + copilotResolvedContext = "" + copilotResolvedEffort = "" + got = buildCopilotCommand("fix it", "") + if slices.Contains(got, "--context") { + t.Errorf("empty context should not produce --context flag: %v", got) + } + if slices.Contains(got, "--reasoning-effort") { + t.Errorf("empty effort should not produce --reasoning-effort flag: %v", got) + } } func TestGetCredentialName(t *testing.T) {