diff --git a/changelog.d/added-agent-github-cap-controls.md b/changelog.d/added-agent-github-cap-controls.md new file mode 100644 index 0000000000..f143378cff --- /dev/null +++ b/changelog.d/added-agent-github-cap-controls.md @@ -0,0 +1 @@ +- The API proxy's per-agent GitHub API hourly cap can now be changed without editing `hive.yaml`, so an operator on a hub-managed spoke can give one agent more room. Settings → Forge App sets the fleet default (`agents.github_api_hourly_cap`: a number, `0` for no cap, blank for the default of 300) next to the agent reserve floor, and each agent's General tab sets its own value (`agents..github_api_hourly_cap`: a number, `0` for no cap, blank to inherit). The admin MCP gets the same two writes, `agent.github_api_hourly_cap` and `github.agent_api_hourly_cap`, whose preview discloses a raised or removed cap as widening. A save applies to the proxy at once, is saved to the dashboard overlay and audited; replicas and review heads follow their base agent, and the agent reserve floor still applies. Debug → GitHub API budget now lists each agent's cap and its calls in the current hour, and `config_effective_read` shows both values and each agent's effective cap. See [Changing the agent caps](src/docs/operator-reference/runtime-and-quota.md#changing-the-agent-caps). diff --git a/src/cmd/hive/main.go b/src/cmd/hive/main.go index 21d1a9cc53..0cd267d0cc 100644 --- a/src/cmd/hive/main.go +++ b/src/cmd/hive/main.go @@ -4337,6 +4337,8 @@ func (b *boot) bootProxyWith(deps bootProxyDeps) { b.githubProxy.SetAgentGitHubBudgetNudgeFunc(b.agentMgr.SendGitHubAPIBudgetNudge) } dashboard.SetProxyViolationsProvider(b.githubProxy.Violations) + // Per-agent calls in the cap window, for the GitHub API budget panel. + dashboard.SetAgentGitHubCallsProvider(b.githubProxy.AgentGitHubCallsLastHour) // Lets the dashboard narrow the LiteLLM model dropdown to the set the // configured key is entitled to, learned by the proxy from a key-info // probe or a "team not allowed" 403. diff --git a/src/crux/proxy/agent_github_budget_view.go b/src/crux/proxy/agent_github_budget_view.go new file mode 100644 index 0000000000..c3faf640c4 --- /dev/null +++ b/src/crux/proxy/agent_github_budget_view.go @@ -0,0 +1,46 @@ +package proxy + +import "time" + +// AgentGitHubCallsLastHour reports, per agent, how many GitHub API responses +// the proxy has counted against that agent's hourly cap in the current +// sliding hour: the same window the cap check reads. It is read-only (stale +// entries are skipped, not pruned), so calling it never changes what the next +// cap check sees. +// +// The window only grows while an agent has a cap: the proxy records a call +// only when the agent's cap is above 0, so an uncapped agent reads 0 here +// whatever it has spent, and a cap switched on starts from an empty window. +func (p *GitHubProxy) AgentGitHubCallsLastHour() map[string]int { + out := map[string]int{} + if p == nil || p.githubBudget == nil { + return out + } + return p.githubBudget.callsInWindow() +} + +func (b *agentGitHubBudget) callsInWindow() map[string]int { + out := map[string]int{} + if b == nil { + return out + } + now := time.Now() + if b.now != nil { + now = b.now() + } + cutoff := now.Add(-agentGitHubBudgetWindow) + b.mu.Lock() + defer b.mu.Unlock() + for agent, events := range b.events { + n := 0 + for _, at := range events { + if at.After(cutoff) { + n++ + } + } + if n > 0 { + out[agent] = n + } + } + return out +} diff --git a/src/crux/proxy/agent_github_budget_view_test.go b/src/crux/proxy/agent_github_budget_view_test.go new file mode 100644 index 0000000000..356f6daba2 --- /dev/null +++ b/src/crux/proxy/agent_github_budget_view_test.go @@ -0,0 +1,42 @@ +package proxy + +import ( + "net/http" + "testing" + "time" +) + +// AgentGitHubCallsLastHour reports the same sliding-hour count the cap check +// reads, per agent, and reading it changes nothing the next check sees. +func TestAgentGitHubCallsLastHourMatchesTheCapWindow(t *testing.T) { + now := time.Date(2026, 11, 9, 12, 0, 0, 0, time.UTC) + b := newAgentGitHubBudget(func() time.Time { return now }) + p := &GitHubProxy{githubBudget: b} + + b.record("reviewer", http.StatusOK, 300) + now = now.Add(30 * time.Minute) + b.record("reviewer", http.StatusOK, 300) + b.record("reviewer", http.StatusNotModified, 300) // a 304 is not charged + b.record("scanner", http.StatusOK, 300) + b.record("quality", http.StatusOK, 0) // uncapped: no window kept + + got := p.AgentGitHubCallsLastHour() + if got["reviewer"] != 2 || got["scanner"] != 1 || len(got) != 2 { + t.Fatalf("calls = %v, want reviewer 2, scanner 1, nothing else", got) + } + + // 31 minutes on, the first reviewer call has left the window. + now = now.Add(31 * time.Minute) + got = p.AgentGitHubCallsLastHour() + if got["reviewer"] != 1 || got["scanner"] != 1 { + t.Fatalf("calls after the window slid = %v, want reviewer 1, scanner 1", got) + } + if count, _, _ := b.check("reviewer", 300); count != got["reviewer"] { + t.Fatalf("cap check counts %d, view counts %d", count, got["reviewer"]) + } + + var nilProxy *GitHubProxy + if got := nilProxy.AgentGitHubCallsLastHour(); len(got) != 0 { + t.Fatalf("nil proxy = %v", got) + } +} diff --git a/src/docs/admin-mcp.md b/src/docs/admin-mcp.md index fcf2205b33..d530b70eed 100644 --- a/src/docs/admin-mcp.md +++ b/src/docs/admin-mcp.md @@ -554,10 +554,16 @@ The keys and names whose values are shown: `governor.logging.max_backups`, `governor.logging.compress`, `governor.logging.kick_log_source`, `governor.logging.read_scan_max_mb`, `agents.*.backend`, `agents.*.model`, `agents.*.enabled`, `agents.*.paused`, - and the per-repository merge policy: `auto_merge.squash_only[*]`, + the API proxy's GitHub API hourly cap `agents.github_api_hourly_cap` and + `agents.*.github_api_hourly_cap`, and the per-repository merge policy: `auto_merge.squash_only[*]`, `auto_merge.require_current_base[*]` and `auto_merge.human_merge_paths.*[*]` (the path globs listed under each `owner/repo`). A `*` segment is one map key and `[*]` any list index. +- **Per-agent GitHub API caps:** the result also carries + `agent_github_api_hourly_caps`, the API proxy's cap as this configuration + resolves it: `fleet` and `fleet_set` for `agents.github_api_hourly_cap`, + `agents` mapping each declared agent to the cap the proxy applies to it + (`0` = no cap), and `overrides` naming the agents with their own value. - **Environment names:** `HIVE_CONFIG`, `HIVE_ADMIN_MCP_ENABLE_WRITES`, `HIVE_ADMIN_MCP_PENDING_FILE`, `HOME`, `HOSTNAME`, `LANG`, `PATH`, `TZ`, `KUBERNETES_SERVICE_HOST`, `KUBERNETES_SERVICE_PORT` @@ -674,18 +680,38 @@ The audit log cannot tell an assistant-mediated write from a dashboard action. ### Registered operations -| Area | Operations | -| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Agents | `agent.pause`, `agent.resume`, `agent.nudge`, `agent.restart`, `agent.add`, `agent.remove`, `agent.model`, `agent.backend`, `agent.effort`, `agent.interaction_tier` | -| Fleet | `fleet.autonomy_level` | -| Plans | `plan.propose`, `plan.approve`, `plan.reject` | -| Governor | `governor.feature_settings`, `governor.thresholds`, `governor.threshold_scaling` | -| Repositories | `repository.pause`, `repository.resume`, `repository.item_hold` | -| Activity monitor row actions | see below | -| Budget | `budget.update`, `budget.reset`, `budget.ignore` | -| Contributors | `contributor.trust`, `contributor.agent_role`, `contributor.agent_role_grants`, `contributor.revoke`, `contributor.requeue`, `contributor.delete` | -| Backup | `backup.create` (the archive itself is not returned through MCP) | -| Circuit breaker | `circuit_breaker.engage`, `circuit_breaker.release` | +| Area | Operations | +| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Agents | `agent.pause`, `agent.resume`, `agent.nudge`, `agent.restart`, `agent.add`, `agent.remove`, `agent.model`, `agent.backend`, `agent.effort`, `agent.interaction_tier`, `agent.github_api_hourly_cap` | +| GitHub | `github.agent_api_hourly_cap` | +| Fleet | `fleet.autonomy_level` | +| Plans | `plan.propose`, `plan.approve`, `plan.reject` | +| Governor | `governor.feature_settings`, `governor.thresholds`, `governor.threshold_scaling` | +| Repositories | `repository.pause`, `repository.resume`, `repository.item_hold` | +| Activity monitor row actions | see below | +| Budget | `budget.update`, `budget.reset`, `budget.ignore` | +| Contributors | `contributor.trust`, `contributor.agent_role`, `contributor.agent_role_grants`, `contributor.revoke`, `contributor.requeue`, `contributor.delete` | +| Backup | `backup.create` (the archive itself is not returned through MCP) | +| Circuit breaker | `circuit_breaker.engage`, `circuit_breaker.release` | + +The two GitHub API cap operations change the API proxy's per-agent hourly +cap, the same settings as the dashboard's Forge App and agent General +controls: + +- `agent.github_api_hourly_cap` takes `agent` and `cap`: a whole number, `0` + for no cap, or `inherit` to follow the fleet default. Set it on a base + agent; a replica or review head is refused, because it takes its base + agent's cap. +- `github.agent_api_hourly_cap` takes `cap`: a whole number, `0` for no cap, + or `default` to remove `agents.github_api_hourly_cap` so the default of 300 + applies. + +The preview reads the current value first. Raising a cap, removing one, or +inheriting a higher fleet value is disclosed as widening, naming the App +installation's shared hourly bucket and the agent reserve floor +(`github.agent_reserve_floor`); lowering is not. When the current value cannot +be read, the preview says the change may be widening. See +[Changing the agent caps](operator-reference/runtime-and-quota.md#changing-the-agent-caps). The Activity monitor's row actions: diff --git a/src/docs/api-reference.md b/src/docs/api-reference.md index 87decaa9b2..0dc774e54c 100644 --- a/src/docs/api-reference.md +++ b/src/docs/api-reference.md @@ -641,7 +641,7 @@ always resolved server-side from the validated token. | `GET` | `/api/diag/runs/{key}/detail` | Dashboard auth/session (pane tails need read-write, as in the wrapped endpoint) | Diagnostic read (#11943): wraps `GET /api/runs/{key}/detail`, scrubbed; `raw_log` keeps its newest bytes and events/stages/prs/notes their newest entries to stay under 128 KiB, reported in `trimmed`. Wrapped by `run_detail_read`; see [admin-mcp.md](admin-mcp.md#diagnostic-reads) | `pkg/dashboard/api_diag.go:45` | | `GET` | `/api/diag/health` | Public (as `/api/health/deep`) | Diagnostic read (#11943): wraps `GET /api/health/deep`, scrubbed and returned whole. Wrapped by `health_deep_read`; see [admin-mcp.md](admin-mcp.md#diagnostic-reads) | `pkg/dashboard/api_diag.go:46` | | `GET` | `/api/diag/prompt-history` | Read-write role (checked by the wrapped endpoint) | Diagnostic read (#11943): wraps `GET /api/prompt-history` (`?agent=&windowHours=` passed through); every entry scrubbed, each prompt cut at 8 KiB (`cut`, `full_bytes`), newest entries kept within 128 KiB with `truncated` and `entries_total`. Wrapped by `prompt_history_read`; see [admin-mcp.md](admin-mcp.md#diagnostic-reads) | `pkg/dashboard/api_diag.go:47` | -| `GET` | `/api/diag/config` | Owner only, verified by this hive (dashboard token or verified owner session) | Diagnostic read (#11943): the effective configuration as `GET /api/config/provenance` computes it — every key and environment name with the layer that set it (`also_set_by` for overridden layers, rejected overlay and last-good fallback in `provenance`); values only for keys and names on the documented `value_list`, all others set/not set; shown values scrubbed; the file itself never returned; `?offset=` pages keys. Wrapped by `config_effective_read`; see [admin-mcp.md](admin-mcp.md#diagnostic-reads) | `pkg/dashboard/api_diag.go:48` | +| `GET` | `/api/diag/config` | Owner only, verified by this hive (dashboard token or verified owner session) | Diagnostic read (#11943): the effective configuration as `GET /api/config/provenance` computes it — every key and environment name with the layer that set it (`also_set_by` for overridden layers, rejected overlay and last-good fallback in `provenance`); values only for keys and names on the documented `value_list`, all others set/not set; shown values scrubbed; the file itself never returned; `?offset=` pages keys. `agent_github_api_hourly_caps` gives the API proxy's GitHub API hourly cap as the configuration resolves it: `fleet`, `fleet_set`, `agents` (declared agent → effective cap; 0 = no cap) and `overrides` (agents with their own value). Wrapped by `config_effective_read`; see [admin-mcp.md](admin-mcp.md#diagnostic-reads) | `pkg/dashboard/api_diag.go:48` | | `GET` | `/api/leaderboard/battle-log` | Public | Scrubbed public Battle Log activity feed for leaderboard widgets. | `pkg/dashboard/battle_log.go:108` | | `GET` | `/api/leaderboard/hive-of-week` | Public | Featured weekly project metadata, configured render URLs (empty when none), gource source URL, and embed snippets. | `pkg/dashboard/battle_log.go:109` | | `GET` | `/api/leaderboard/gource-log` | Public | Gource custom log source for a project/week, derived from public hive event data. | `pkg/dashboard/battle_log.go:110` | diff --git a/src/docs/api-reference/configuration.md b/src/docs/api-reference/configuration.md index b78d651735..81c9ce8b64 100644 --- a/src/docs/api-reference/configuration.md +++ b/src/docs/api-reference/configuration.md @@ -22,8 +22,8 @@ Part of the [Dashboard REST API reference](../api-reference.md); see that page f | `PUT` | `/api/config/variables/{name}` | Owner only | Variable Upsert | `pkg/dashboard/api.go:61` | | `DELETE` | `/api/config/variables/{name}` | Owner only | Variable Delete | `pkg/dashboard/api.go:62` | -| `GET` | `/api/config/agent/{name}` | Dashboard auth/session | Agent Config Get | `pkg/dashboard/api.go:207` | -| `PUT` | `/api/config/agent/{name}/general` | Owner only | Agent Config General | `pkg/dashboard/api.go:208` | +| `GET` | `/api/config/agent/{name}` | Dashboard auth/session | Agent Config Get; `general` carries the API proxy's GitHub API hourly cap: `githubApiHourlyCap` (this agent's `agents..github_api_hourly_cap`, `null` = inherit), `githubApiHourlyCapEffective` (the cap the proxy applies now; 0 = no cap), `githubApiHourlyCapFleet` (the fleet default) and `githubApiHourlyCapBase` (the base agent of a replica or review head, else empty) | `pkg/dashboard/api.go:207` | +| `PUT` | `/api/config/agent/{name}/general` | Owner only | Agent Config General; `githubApiHourlyCap` sets `agents..github_api_hourly_cap` (a whole number, 0 = no cap, `null` = inherit the fleet default), applies to the proxy without a restart and to the agent's replicas and review heads; refused (400) on a replica or review head. Audited as `config_agent_general` with the old and new cap | `pkg/dashboard/api.go:208` | | `PUT` | `/api/config/agent/{name}/cadences` | Owner only | Agent Config Cadences | `pkg/dashboard/api.go:209` | | `PUT` | `/api/config/agent/{name}/models` | Owner only | Agent Config Models | `pkg/dashboard/api.go:210` | | `PUT` | `/api/config/agent/{name}/pipeline` | Owner only | Agent Config Pipeline | `pkg/dashboard/api.go:211` | @@ -73,8 +73,8 @@ Part of the [Dashboard REST API reference](../api-reference.md); see that page f | `DELETE` | `/api/config/governor/agents/{name}` | Owner only | Governor Remove Agent | `pkg/dashboard/api.go:326` | | `PUT` | `/api/config/governor/repos` | Owner only | Governor Repos | `pkg/dashboard/api.go:327` | | `POST` | `/api/config/governor/repos/check-access` | Dashboard auth/session | Governor Repo Check Access | `pkg/dashboard/api.go:331` | -| `PUT` | `/api/config/github` | Owner only | Config GitHub | `pkg/dashboard/api.go:332` | -| `GET` | `/api/config/github/forge-apps` | Dashboard auth/session | Config GitHub Forge Apps | `pkg/dashboard/api.go:335` | +| `PUT` | `/api/config/github` | Owner only | Config GitHub; `agent_github_api_hourly_cap` sets the fleet default `agents.github_api_hourly_cap` (a whole number, 0 = no cap, `null` = remove the key so the default 300 applies), applies to the proxy without a restart, audited as `config_github` with the old and new cap; the answer echoes `agent_github_api_hourly_cap` and `agent_github_api_hourly_cap_set` | `pkg/dashboard/api.go:332` | +| `GET` | `/api/config/github/forge-apps` | Dashboard auth/session | Config GitHub Forge Apps; `settings.agent_github_api_hourly_cap` (the fleet default as applied) and `settings.agent_github_api_hourly_cap_set` (whether the key is written), with `settings.defaults.agent_github_api_hourly_cap` (300) | `pkg/dashboard/api.go:335` | | `GET` | `/api/config/sidebar` | Dashboard auth/session | Sidebar Get | `pkg/dashboard/api.go:394` | | `PUT` | `/api/config/sidebar` | Dashboard auth/session | Sidebar Set | `pkg/dashboard/api.go:395` | | `GET` | `/api/config/backends` | Dashboard auth/session | Backends | `pkg/dashboard/api.go:396` | diff --git a/src/docs/api-reference/health-status-events.md b/src/docs/api-reference/health-status-events.md index db31850517..43dee31e8b 100644 --- a/src/docs/api-reference/health-status-events.md +++ b/src/docs/api-reference/health-status-events.md @@ -28,7 +28,7 @@ Part of the [Dashboard REST API reference](../api-reference.md); see that page f | `GET` | `/api/health/deep` | Public | Deep health probe; the `webhooks` check (`status`, `detail`, `mode`, `mode_reason_code`, `rejected_1h`) warns when deliveries arrive but cannot be used — no `GITHUB_WEBHOOK_SECRET`, signature mismatch, undecodable payload — and never changes the overall verdict | `pkg/dashboard/server.go:1824` | | `GET` | `/api/livez` | Public | Kubernetes liveness probe | `pkg/dashboard/server.go:1825` | | `GET` | `/metrics` | Registered only when `HIVE_METRICS_ENABLED`; requires `Authorization: ****** (403 if the token is unset) | Prometheus metrics | `pkg/dashboard/server.go:1831` | -| `GET` | `/api/status` | Dashboard auth/session | Dashboard aggregate status; `labelPresets[]` (`{key,label,description,labels[],states[]}`) lists the built-in label-filter presets for the Projects cards, led by `needs-human` whose `labels[]` are the effective hard-suppress labels plus `needs-signal`, the hold labels, `stage/071-needs-author` and the reporter-trust awaiting label (#12172); accepts `?fields=a,b` for top-level selection and `?omit=repos` to drop heavy sections, and honors `Accept-Encoding: gzip`; `agents[]` includes busy-pane visibility fields (`kicksUndeliverable`, `busySince`, `lastTranscriptActivity`, `condition`, `conditionMessage`) when a Working pane cannot accept kicks; a working agent also carries `agents[].run` — the live per-run line (#14576): `{since, elapsedS, costUSD, tokens, priced, tests, filesChanged}`, the estimated spend/tokens since the kick was delivered (`lastKickAt`, falling back to `busySince`) (delta of the token collector's per-agent cumulative figure; `priced:false` when the agent has none yet), the last visible test-runner result (`pass`/`fail`/empty) and the last `N files changed` git summary (`-1` when none) parsed from the captured pane; top-level `fleetRun` `{running, hourUSD, hourPriced, dayUSD, weekUSD, monthUSD, windowsPriced}` is the governor strip's rotating `running N · $X this hour / today / this week / this month` figure (#15614) — the hour is the rise since the top of the clock hour, the wider windows are the daily cost ledger summed from 00:00 UTC today, Monday 00:00 UTC (ISO week) and the 1st 00:00 UTC plus the rise since the ledger's last snapshot, `windowsPriced:false` when there is no ledger yet; `sla` (fleet) and `repos[].sla` (per repo) carry the Delivery SLA block derived from the lifecycle timeline with no extra GitHub calls: `windows.{7d,30d}.{open_to_triage,triage_to_start,start_to_pr,pr_to_merge,open_to_merge}` as `{p50_h,p95_h,n}` (hours, nearest-rank), `history[]` of up to 30 daily buckets `{day,start_to_pr_p50,pr_to_merge_p50,open_to_merge_p50,n_started,n_pr,n_merged}` persisted under `/data/sla-history.json`, `stale_in_progress[]` (`{number,repo,started_at,age_h,agent}` — issues carrying `hive/in-progress` longer than the agent claim TTL with no linked PR), `stale_count`, `merged_7d`, `claim_ttl_s` and, on the fleet block, the effective `targets` (`start_to_pr_h`, `pr_to_merge_h`, `open_to_merge_h`); `staleSince` and `repos[].countsStale` / `countsStaleSince` mark totals served from last-good figures — the previous enumeration (#13382) or the ForgeState cache's last-good counts (#13376); `ci_failure_signals` `{count, last, kicks, unmatched}` is the webhook CI-failure signal store — distinct red (repo, head, check) signals retained, the most recent one `{repo, number, head_sha, check, conclusion, details_url, agent, at}`, webhook-triggered kicks/nudges since boot, and failure deliveries whose head matched no open PR; `workflow_approvals` `{approved, denied, permission_errors, last}` counts the hive's approvals of GitHub-gated workflow runs since boot (#16603) — runs approved, runs refused by the trust policy (once per head), `403`/`404` refusals for want of `actions: write`, and the most recent decision `{repo, number, head, run_id, outcome, at}`; `code_scanning` `{enabled, repos[], notices[]}` is the code scanning sweep's picture, absent while the sweep is off everywhere — per repo `{repo, security_url, checked_at, open_alerts, by_severity{critical,high,medium,low,note}, qualifying, below_threshold, excluded, tracked, tracked_closed, untracked, open_issues, filed, updated, closed, truncated, notice, notice_kind, error}`, where `tracked` is open alerts listed on an open hive issue and `notice_kind` is `missing_permission` (the App lacks Code scanning alerts: Read) or `not_enabled` (see `docs/code-scanning.md`); `hotspots` `{count, actionable, filed}` is the merge-hotspot advisor's one-line summary (files at/above the thresholds, those with a split recommendation, those with a modularize issue filed), absent when the advisor is not wired — details on `GET /api/hotspots`; `writeback.dedupe` `{enabled, dedupe_same_item, dedupe_burst, rate_capped, hourly_cap, burst_items, burst_window_s, same_item_ttl_s}` is the write-back gate's content layer — the effective `writeback.dedupe.*` settings and the last-hour counts of writes held as `duplicate`, `burst` and `rate-capped` (each also listed under `GET /api/writeback/suppressed`); `config.gate_regressions[]` `{gate, previous, effective, source}` is the boot-time safety-gate regression list (#16438) — gates a previous runtime overlay on the PVC carried that the running config lacks or loosens (`review.require_labels`, `project.issue_filter.require_labels`, `project.paused_repos[]`, `writeback.per_repo[].mode`), empty on a healthy boot | `pkg/dashboard/server.go:1836` | +| `GET` | `/api/status` | Dashboard auth/session | Dashboard aggregate status; `labelPresets[]` (`{key,label,description,labels[],states[]}`) lists the built-in label-filter presets for the Projects cards, led by `needs-human` whose `labels[]` are the effective hard-suppress labels plus `needs-signal`, the hold labels, `stage/071-needs-author` and the reporter-trust awaiting label (#12172); accepts `?fields=a,b` for top-level selection and `?omit=repos` to drop heavy sections, and honors `Accept-Encoding: gzip`; `agents[]` includes busy-pane visibility fields (`kicksUndeliverable`, `busySince`, `lastTranscriptActivity`, `condition`, `conditionMessage`) when a Working pane cannot accept kicks; a working agent also carries `agents[].run` — the live per-run line (#14576): `{since, elapsedS, costUSD, tokens, priced, tests, filesChanged}`, the estimated spend/tokens since the kick was delivered (`lastKickAt`, falling back to `busySince`) (delta of the token collector's per-agent cumulative figure; `priced:false` when the agent has none yet), the last visible test-runner result (`pass`/`fail`/empty) and the last `N files changed` git summary (`-1` when none) parsed from the captured pane; top-level `fleetRun` `{running, hourUSD, hourPriced, dayUSD, weekUSD, monthUSD, windowsPriced}` is the governor strip's rotating `running N · $X this hour / today / this week / this month` figure (#15614) — the hour is the rise since the top of the clock hour, the wider windows are the daily cost ledger summed from 00:00 UTC today, Monday 00:00 UTC (ISO week) and the 1st 00:00 UTC plus the rise since the ledger's last snapshot, `windowsPriced:false` when there is no ledger yet; `sla` (fleet) and `repos[].sla` (per repo) carry the Delivery SLA block derived from the lifecycle timeline with no extra GitHub calls: `windows.{7d,30d}.{open_to_triage,triage_to_start,start_to_pr,pr_to_merge,open_to_merge}` as `{p50_h,p95_h,n}` (hours, nearest-rank), `history[]` of up to 30 daily buckets `{day,start_to_pr_p50,pr_to_merge_p50,open_to_merge_p50,n_started,n_pr,n_merged}` persisted under `/data/sla-history.json`, `stale_in_progress[]` (`{number,repo,started_at,age_h,agent}` — issues carrying `hive/in-progress` longer than the agent claim TTL with no linked PR), `stale_count`, `merged_7d`, `claim_ttl_s` and, on the fleet block, the effective `targets` (`start_to_pr_h`, `pr_to_merge_h`, `open_to_merge_h`); `staleSince` and `repos[].countsStale` / `countsStaleSince` mark totals served from last-good figures — the previous enumeration (#13382) or the ForgeState cache's last-good counts (#13376); `ci_failure_signals` `{count, last, kicks, unmatched}` is the webhook CI-failure signal store — distinct red (repo, head, check) signals retained, the most recent one `{repo, number, head_sha, check, conclusion, details_url, agent, at}`, webhook-triggered kicks/nudges since boot, and failure deliveries whose head matched no open PR; `workflow_approvals` `{approved, denied, permission_errors, last}` counts the hive's approvals of GitHub-gated workflow runs since boot (#16603) — runs approved, runs refused by the trust policy (once per head), `403`/`404` refusals for want of `actions: write`, and the most recent decision `{repo, number, head, run_id, outcome, at}`; `code_scanning` `{enabled, repos[], notices[]}` is the code scanning sweep's picture, absent while the sweep is off everywhere — per repo `{repo, security_url, checked_at, open_alerts, by_severity{critical,high,medium,low,note}, qualifying, below_threshold, excluded, tracked, tracked_closed, untracked, open_issues, filed, updated, closed, truncated, notice, notice_kind, error}`, where `tracked` is open alerts listed on an open hive issue and `notice_kind` is `missing_permission` (the App lacks Code scanning alerts: Read) or `not_enabled` (see `docs/code-scanning.md`); `hotspots` `{count, actionable, filed}` is the merge-hotspot advisor's one-line summary (files at/above the thresholds, those with a split recommendation, those with a modularize issue filed), absent when the advisor is not wired — details on `GET /api/hotspots`; `writeback.dedupe` `{enabled, dedupe_same_item, dedupe_burst, rate_capped, hourly_cap, burst_items, burst_window_s, same_item_ttl_s}` is the write-back gate's content layer — the effective `writeback.dedupe.*` settings and the last-hour counts of writes held as `duplicate`, `burst` and `rate-capped` (each also listed under `GET /api/writeback/suppressed`); `config.gate_regressions[]` `{gate, previous, effective, source}` is the boot-time safety-gate regression list (#16438) — gates a previous runtime overlay on the PVC carried that the running config lacks or loosens (`review.require_labels`, `project.issue_filter.require_labels`, `project.paused_repos[]`, `writeback.per_repo[].mode`), empty on a healthy boot; `ghRateLimits.agent_caps` `{fleet_cap, fleet_cap_set, window_s, agents: [{agent, cap, source: agent|base_agent|fleet, base_agent, calls}]}` is the GitHub API budget panel's per-agent proxy cap (0 = no cap) and the calls the proxy counted against it in the current sliding hour (`calls` is null for an uncapped agent, which the proxy does not count) | `pkg/dashboard/server.go:1836` | | `GET` | `/api/status/summary` | Dashboard auth/session | Compact agent/governor/budget status summary for pollers; honors `Accept-Encoding: gzip` | `pkg/dashboard/server.go:1837` | | `GET` | `/api/overview/issues.csv` | Dashboard auth/session | Cached Overview issue-band export as browser-compatible CSV; filters `band`, `repo`/`repos`, `stale`, and `held` are ANDed. | `pkg/dashboard/server.go:1840` | | `GET` | `/api/overview/issues.json` | Dashboard auth/session | Cached Overview issue-band export as typed JSON with `bands[]` metadata and row fields matching the CSV columns. | `pkg/dashboard/server.go:1841` | diff --git a/src/docs/operator-reference/runtime-and-quota.md b/src/docs/operator-reference/runtime-and-quota.md index 8f0532ac61..5d7ac1d26e 100644 --- a/src/docs/operator-reference/runtime-and-quota.md +++ b/src/docs/operator-reference/runtime-and-quota.md @@ -163,11 +163,13 @@ loops, so the proxy enforces two safeguards: minting is not agent-attributed and is not counted. Set the value to `0` to disable the fleet default, or set `agents..github_api_hourly_cap` to override one agent (`0` disables that agent's cap). - The override applies on its own, whether or not the fleet key is set. A - replica (`reviewer-2`, …) or review head takes its base agent's override - unless it has one of its own. The order is the agent's own value, then the - base agent's, then `agents.github_api_hourly_cap`, then the default of 300. - A config reload reaches the proxy without a restart. + The override applies whether or not the fleet key is set. A replica + (`reviewer-2`, …) or review head takes its base agent's override unless it + has one of its own. The order is the agent's own value, then the base + agent's, then `agents.github_api_hourly_cap`, then the default of 300. A + config reload reaches the proxy without a restart. Both can also be set + from the dashboard and the admin MCP; see + [Changing the agent caps](#changing-the-agent-caps). - `github.agent_reserve_floor` (default `400`) protects the shared core bucket. When the latest rate-limit reading shows `core.remaining` below the floor, agent read requests receive a proxy `429` even if the agent has not @@ -233,6 +235,70 @@ for repositories, PRs, pages, REST fallbacks, errors, and the last GraphQL query cost. Per-PR REST reads still happen when the batch is disabled, a repo's GraphQL query fails, or GitHub returns `mergeable: UNKNOWN` for that PR. +### Changing the agent caps + +Both caps can be changed without editing `hive.yaml`, which matters on a +hub-managed spoke where the hub owns that file: + +- **Fleet default:** Settings → Forge App → *Agent GitHub API hourly cap*, + next to the agent reserve floor. A number sets + `agents.github_api_hourly_cap`, `0` removes the cap, and a blank field + removes the key so the default (`300`) applies again. +- **One agent:** the agent's Settings → General tab → *GitHub API hourly cap*. + A number sets `agents..github_api_hourly_cap`, `0` removes the cap for + that agent, and a blank field goes back to the fleet default. +- **Admin MCP:** `agent.github_api_hourly_cap` (`agent`, and `cap` as a number + or `inherit`) and `github.agent_api_hourly_cap` (`cap` as a number or + `default`), through `write_preview` and `write_confirm`. A preview that + raises a cap, removes one, or makes an agent inherit a higher fleet value + says so in its `widening_disclosure`. Lowering a cap is not widening. + +For example, a reviewer that runs out of calls overnight while the rest of the +fleet stays at the default: + +```yaml +agents: + github_api_hourly_cap: 300 + reviewer: + github_api_hourly_cap: 600 +``` + +**Which cap applies.** The first of these that is set wins, and `0` at any +step means no cap: + +1. the agent's own `agents..github_api_hourly_cap`; +2. for a replica or review head, its base agent's value; +3. `agents.github_api_hourly_cap`; +4. the default, `300`. + +**Replicas and review heads.** `reviewer-2` and a review head are copies of +their base agent, rebuilt from it whenever the configuration loads. Set the cap +on the base agent; the save rebuilds the copies at once, so they follow it +straight away. Their own General tab shows the cap they inherit, read-only, +and the endpoint refuses an override on them. + +**When it applies.** A save changes the running configuration the proxy reads, +so the agent's next GitHub API request uses the new cap, with no restart or +reload. Calls already counted in the current hour still count against it. The +proxy only counts an agent's calls while it has a cap, so a cap switched on +starts from an empty window. + +**Saving and audit.** The value is saved like any dashboard setting: to +`hive.yaml`, the dashboard overlay and the runtime copy on the PVC, so it +survives a restart. The audit log records `config_github` for the fleet value +and `config_agent_general` for one agent, each with the old and new value. + +**The reserve floor still applies.** Every agent spends from the one hourly +bucket of the App installation, which the daemon's scan, merge and review loops +share. Raising or removing a cap does not lift `github.agent_reserve_floor`: +once the bucket drops below the floor, agent reads are refused whatever their +cap. + +**Seeing it.** Debug → GitHub API budget lists each agent's cap, where it comes +from, and its calls in the current hour (`ghRateLimits.agent_caps` in +`/api/status`). The admin MCP's `config_effective_read` shows both keys' values +and each agent's effective cap under `agent_github_api_hourly_caps`. + ### Webhook-driven PR cache invalidation See [GitHub webhooks: cadence, patch and reconcile](../webhooks.md) for the diff --git a/src/pkg/adminmcp/tools_agent_ops.go b/src/pkg/adminmcp/tools_agent_ops.go index 6aab9292a0..41139cae71 100644 --- a/src/pkg/adminmcp/tools_agent_ops.go +++ b/src/pkg/adminmcp/tools_agent_ops.go @@ -31,6 +31,7 @@ func DefaultWriteOps() []WriteOp { ops := append(agentOps, repoOpsWriteOps()...) ops = append(ops, fleetWriteOps()...) ops = append(ops, stageTrackerWriteOps()...) + ops = append(ops, githubCapWriteOps()...) return append(ops, governorWriteOps()...) } diff --git a/src/pkg/adminmcp/tools_diag.go b/src/pkg/adminmcp/tools_diag.go index f5065e2faf..b8420a4f15 100644 --- a/src/pkg/adminmcp/tools_diag.go +++ b/src/pkg/adminmcp/tools_diag.go @@ -243,7 +243,7 @@ func diagToolDefs() []struct{ name, desc string } { {ToolRunDetailRead, fmt.Sprintf("Read a run's detail (GET /api/runs/{key}/detail): run, issue, plan, PRs, stages, events and the raw event log, scrubbed. Kept under %d KiB: the raw log and events keep their newest part, and what was trimmed is listed. Pane tails need read-write, as on the dashboard. Read-only.", DiagResultByteCap/1024) + untrusted}, {ToolHealthDeepRead, "Read this hive's deep health check (GET /api/health/deep) whole: overall status, each check with pass/fail and detail, and the fail count. Read-only."}, {ToolPromptHistoryRead, fmt.Sprintf("Read the prompts recently delivered to agents (GET /api/prompt-history), newest first; optional agent and window_hours (default 24, at most 168). Each prompt is scrubbed and cut at %d KiB; at most %d KiB in total, keeping the newest entries, with truncation disclosed. Requires the read-write role. Read-only.", DiagPromptEntryBytes/1024, DiagResultByteCap/1024) + untrusted}, - {ToolConfigEffectiveRead, "Read this hive's effective configuration and its provenance: every configured key with the layer that set it and the layers it overrode, whether the dashboard overlay was rejected and why, and whether the last-good configuration was used. Values are shown only for keys and environment names on a documented non-secret list (listed in the result); every other key or name is shown only as set or not set. The raw configuration file is never returned. Page with offset/next_offset. Requires the owner role as verified by this hive. Read-only."}, + {ToolConfigEffectiveRead, "Read this hive's effective configuration and its provenance: every configured key with the layer that set it and the layers it overrode, whether the dashboard overlay was rejected and why, and whether the last-good configuration was used. Values are shown only for keys and environment names on a documented non-secret list (listed in the result); every other key or name is shown only as set or not set. agent_github_api_hourly_caps gives the API proxy's effective GitHub API hourly cap per agent. The raw configuration file is never returned. Page with offset/next_offset. Requires the owner role as verified by this hive. Read-only."}, } } diff --git a/src/pkg/adminmcp/tools_github_cap_ops.go b/src/pkg/adminmcp/tools_github_cap_ops.go new file mode 100644 index 0000000000..4a0f626b55 --- /dev/null +++ b/src/pkg/adminmcp/tools_github_cap_ops.go @@ -0,0 +1,293 @@ +package adminmcp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" +) + +// Write operations for the GitHub API proxy's per-agent hourly cap: one +// agent's override (agents..github_api_hourly_cap, through the agent's +// General settings) and the fleet default (agents.github_api_hourly_cap, +// through the Forge App settings). Both go through the endpoints the +// dashboard controls use, so they save and audit the same way. +// +// A higher cap, no cap (0), or an inherited value higher than the current cap +// lets agents spend more of the App installation's one hourly rate-limit +// bucket, which the hive's own loops share, so the preview discloses it as +// widening. Lowering a cap is not widening. +const ( + WriteOpAgentGitHubAPIHourlyCap = "agent.github_api_hourly_cap" + WriteOpGitHubAgentAPIHourlyCap = "github.agent_api_hourly_cap" + + // defaultAgentGitHubAPIHourlyCap mirrors config.DefaultAgentsGitHubAPIHourlyCap; + // it is only used to word a preview when the fleet value cannot be read. + defaultAgentGitHubAPIHourlyCap = 300 +) + +func githubCapWriteOps() []WriteOp { + return []WriteOp{agentGitHubCapOp{}, fleetGitHubCapOp{}} +} + +type agentGitHubCapOp struct{} +type fleetGitHubCapOp struct{} + +const githubCapBucketDisclosure = "Every agent's GitHub API calls draw on the one hourly rate-limit bucket of this hive's GitHub App installation, which the hive's own scan, merge and review loops share, so agents may now spend more of it. The agent reserve floor (github.agent_reserve_floor, default 400) still applies: once the bucket's remaining calls drop below it, agent reads are refused whatever their cap." + +func (agentGitHubCapOp) Name() string { return WriteOpAgentGitHubAPIHourlyCap } +func (agentGitHubCapOp) Description() string { + return "Set one agent's GitHub API hourly cap in the API proxy (agents..github_api_hourly_cap) through PUT /api/config/agent/{name}/general: a whole number, 0 for no cap, or inherit to follow the fleet default. Set it on a base agent; its replicas and review heads use the same cap." +} +func (agentGitHubCapOp) InputSchema() map[string]any { + return map[string]any{"type": "object", "properties": map[string]any{ + "agent": map[string]any{"type": "string", "minLength": 1}, + "cap": githubCapArgSchema("inherit", "the fleet default"), + }, "required": []string{"agent", "cap"}, "additionalProperties": false} +} +func (op agentGitHubCapOp) Preview(ctx context.Context, args map[string]any) (WritePreview, error) { + return op.PreviewWithProvider(ctx, args, nil) +} + +// PreviewWithProvider reads the agent's settings to compare the current cap +// with the new one; without a reading the change is disclosed as potential +// widening. +func (agentGitHubCapOp) PreviewWithProvider(ctx context.Context, args map[string]any, provider Provider) (WritePreview, error) { + agent, err := requiredString(args, "agent") + if err != nil { + return WritePreview{}, err + } + newCap, err := githubCapArg(args, "inherit") + if err != nil { + return WritePreview{}, err + } + cur := readAgentGitHubCap(ctx, provider, agent) + if cur.base != "" { + return WritePreview{}, fmt.Errorf("agent %s is a replica or review head of %s and takes its GitHub API hourly cap from it; set the cap on %s", agent, cur.base, cur.base) + } + label := githubCapOverrideLabel(newCap) + to, toKnown := 0, false + if newCap != nil { + to, toKnown = *newCap, true + } else if cur.fleetKnown { + to, toKnown = cur.fleet, true + } + if newCap == nil { + label = "inherit the fleet default" + if cur.fleetKnown { + label += " (" + githubCapLabel(cur.fleet) + ")" + } + } + disclosure := githubCapDisclosure("agent "+agent+"'s cap", cur.effective, cur.effectiveKnown, to, toKnown) + var body any + if newCap != nil { + body = *newCap + } + effects := []string{ + "Hive writes agents." + agent + ".github_api_hourly_cap through the agent's General settings (" + githubCapEffectWord(newCap) + "), saves it to hive.yaml and the dashboard overlay, and records a config_agent_general audit entry naming the old and new value.", + "The proxy applies the new cap to this agent's next GitHub API request, without a restart. Replicas and review heads of this agent take the same cap.", + "Calls already counted in the current sliding hour still count against the new cap.", + } + details := map[string]any{"agent": agent, "cap": githubCapDetail(newCap)} + if cur.effectiveKnown { + details["current_cap"] = cur.effective + } + return WritePreview{ + Operation: WriteOpAgentGitHubAPIHourlyCap, + Summary: "Set the GitHub API hourly cap for agent " + agent + " to " + label, + Target: agent, + Request: WriteRequest{Method: http.MethodPut, Path: "/api/config/agent/" + url.PathEscape(agent) + "/general", Body: map[string]any{"githubApiHourlyCap": body}}, + Effects: effects, + WideningDisclosure: disclosure, + ConfirmationMessage: "Confirm setting agent " + agent + "'s GitHub API hourly cap to " + label + ".", + Details: details, + }, nil +} + +func (fleetGitHubCapOp) Name() string { return WriteOpGitHubAgentAPIHourlyCap } +func (fleetGitHubCapOp) Description() string { + return "Set the fleet default GitHub API hourly cap per agent in the API proxy (agents.github_api_hourly_cap) through PUT /api/config/github: a whole number, 0 for no cap, or default to remove the key so the built-in default (300) applies. Agents with their own cap keep it." +} +func (fleetGitHubCapOp) InputSchema() map[string]any { + return map[string]any{"type": "object", "properties": map[string]any{ + "cap": githubCapArgSchema("default", "the built-in default (300)"), + }, "required": []string{"cap"}, "additionalProperties": false} +} +func (op fleetGitHubCapOp) Preview(ctx context.Context, args map[string]any) (WritePreview, error) { + return op.PreviewWithProvider(ctx, args, nil) +} + +// PreviewWithProvider reads the Forge App settings to compare the current +// fleet cap with the new one. +func (fleetGitHubCapOp) PreviewWithProvider(ctx context.Context, args map[string]any, provider Provider) (WritePreview, error) { + newCap, err := githubCapArg(args, "default") + if err != nil { + return WritePreview{}, err + } + cur, curKnown := readFleetGitHubCap(ctx, provider) + to := defaultAgentGitHubAPIHourlyCap + label := "the default (" + githubCapLabel(defaultAgentGitHubAPIHourlyCap) + ")" + if newCap != nil { + to = *newCap + label = githubCapLabel(*newCap) + } + var body any + if newCap != nil { + body = *newCap + } + details := map[string]any{"cap": githubCapDetail(newCap)} + if curKnown { + details["current_cap"] = cur + } + return WritePreview{ + Operation: WriteOpGitHubAgentAPIHourlyCap, + Summary: "Set the fleet default GitHub API hourly cap per agent to " + label, + Target: "agents.github_api_hourly_cap", + Request: WriteRequest{Method: http.MethodPut, Path: "/api/config/github", Body: map[string]any{"agent_github_api_hourly_cap": body}}, + Effects: []string{ + "Hive writes agents.github_api_hourly_cap through the Forge App settings (" + githubCapEffectWord(newCap) + "), saves it to hive.yaml and the dashboard overlay, and records a config_github audit entry naming the old and new value.", + "The proxy applies it to the next GitHub API request of every agent without a cap of its own, without a restart; agents with their own cap keep it.", + "As with any save of these settings, the hive also rebuilds its GitHub App client from the saved App settings.", + }, + WideningDisclosure: githubCapDisclosure("the fleet default cap", cur, curKnown, to, true), + ConfirmationMessage: "Confirm setting the fleet default GitHub API hourly cap per agent to " + label + ".", + Details: details, + }, nil +} + +func githubCapArgSchema(keyword, meaning string) map[string]any { + return map[string]any{ + "anyOf": []any{map[string]any{"type": "integer", "minimum": 0}, map[string]any{"type": "string", "enum": []string{keyword}}}, + "description": "Charged GitHub API responses allowed per sliding hour: a whole number, 0 for no cap, or \"" + keyword + "\" for " + meaning + ".", + } +} + +// githubCapArg returns the requested cap, or nil for the keyword. +func githubCapArg(args map[string]any, keyword string) (*int, error) { + if s, ok := args["cap"].(string); ok && strings.EqualFold(strings.TrimSpace(s), keyword) { + return nil, nil + } + n, ok, err := optionalIntArg(args, "cap") + if err != nil || !ok || n < 0 { + return nil, fmt.Errorf("cap must be a whole number of at least 0 (0 = no cap), or %q", keyword) + } + return &n, nil +} + +// githubCapWidens reports whether moving a cap from one value to another lets +// an agent make more calls: 0 is no cap. +func githubCapWidens(from, to int) bool { + if from == 0 { + return false + } + return to == 0 || to > from +} + +func githubCapDisclosure(what string, from int, fromKnown bool, to int, toKnown bool) string { + if fromKnown && toKnown { + if githubCapWidens(from, to) { + return "Widening: " + what + " goes from " + githubCapLabel(from) + " to " + githubCapLabel(to) + ". " + githubCapBucketDisclosure + } + return "No widening: " + what + " goes from " + githubCapLabel(from) + " to " + githubCapLabel(to) + ", which does not raise it." + } + return "Potential widening: the current or resulting cap could not be read. If this raises " + what + " or removes it (0), it is widening. " + githubCapBucketDisclosure +} + +func githubCapLabel(n int) string { + if n == 0 { + return "no cap (0)" + } + return strconv.Itoa(n) +} + +func githubCapOverrideLabel(cap *int) string { + if cap == nil { + return "inherit" + } + return githubCapLabel(*cap) +} + +func githubCapEffectWord(cap *int) string { + if cap == nil { + return "removing the key" + } + return "setting it to " + strconv.Itoa(*cap) +} + +func githubCapDetail(cap *int) any { + if cap == nil { + return nil + } + return *cap +} + +type agentGitHubCapReading struct { + effective int + effectiveKnown bool + fleet int + fleetKnown bool + base string +} + +// readAgentGitHubCap reads the agent's General settings (settings_read +// section agent): its effective cap, the fleet default and, for a replica or +// review head, its base agent. A failed read leaves everything unknown. +func readAgentGitHubCap(ctx context.Context, provider Provider, agent string) agentGitHubCapReading { + var out agentGitHubCapReading + if provider == nil { + return out + } + data, err := provider.Read(ctx, ToolSettingsRead, map[string]any{"section": "agent", "agent": agent}) + if err != nil { + return out + } + m, _ := data.(map[string]any) + general, _ := m["general"].(map[string]any) + if general == nil { + return out + } + out.effective, out.effectiveKnown = numberArg(general["githubApiHourlyCapEffective"]) + out.fleet, out.fleetKnown = numberArg(general["githubApiHourlyCapFleet"]) + out.base, _ = general["githubApiHourlyCapBase"].(string) + return out +} + +// readFleetGitHubCap reads the fleet default from the Forge App settings +// (settings_read section github_apps). +func readFleetGitHubCap(ctx context.Context, provider Provider) (int, bool) { + if provider == nil { + return 0, false + } + data, err := provider.Read(ctx, ToolSettingsRead, map[string]any{"section": "github_apps"}) + if err != nil { + return 0, false + } + m, _ := data.(map[string]any) + settings, _ := m["settings"].(map[string]any) + if settings == nil { + return 0, false + } + return numberArg(settings["agent_github_api_hourly_cap"]) +} + +func numberArg(v any) (int, bool) { + switch n := v.(type) { + case float64: + if n < 0 || n != float64(int(n)) { + return 0, false + } + return int(n), true + case int: + return n, n >= 0 + case json.Number: + i, err := n.Int64() + if err != nil || i < 0 { + return 0, false + } + return int(i), true + } + return 0, false +} diff --git a/src/pkg/adminmcp/tools_github_cap_ops_test.go b/src/pkg/adminmcp/tools_github_cap_ops_test.go new file mode 100644 index 0000000000..e317e5cb86 --- /dev/null +++ b/src/pkg/adminmcp/tools_github_cap_ops_test.go @@ -0,0 +1,196 @@ +package adminmcp + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" +) + +// capSettingsProvider answers settings_read for one agent's General tab and +// the Forge App settings, and records writes. +type capSettingsProvider struct { + agentGeneral map[string]any + forge map[string]any + requests []WriteRequest +} + +func (p *capSettingsProvider) Read(_ context.Context, tool string, args map[string]any) (any, error) { + if tool != ToolSettingsRead { + return map[string]any{}, nil + } + switch args["section"] { + case "agent": + return map[string]any{"general": p.agentGeneral}, nil + case "github_apps": + return map[string]any{"settings": p.forge}, nil + } + return map[string]any{}, nil +} + +func (p *capSettingsProvider) ExecuteWrite(_ context.Context, req WriteRequest) (any, error) { + p.requests = append(p.requests, req) + return map[string]any{"status": "updated"}, nil +} + +func capPreview(t *testing.T, h *Handler, op string, args map[string]any) (WritePreview, string) { + t.Helper() + out, err := h.PreviewWrite(context.Background(), map[string]any{"operation": op, "args": args}) + if err != nil { + t.Fatalf("%s %v: preview: %v", op, args, err) + } + m := out.(map[string]any) + return m["preview"].(WritePreview), m["confirmation_id"].(string) +} + +func isWidening(p WritePreview) bool { + return strings.HasPrefix(p.WideningDisclosure, "Widening:") || strings.HasPrefix(p.WideningDisclosure, "Potential widening:") +} + +func namesBucketAndFloor(p WritePreview) bool { + return strings.Contains(p.WideningDisclosure, "App installation") && strings.Contains(p.WideningDisclosure, "github.agent_reserve_floor") +} + +func TestGitHubCapWriteOpsRegistered(t *testing.T) { + for _, name := range []string{WriteOpAgentGitHubAPIHourlyCap, WriteOpGitHubAgentAPIHourlyCap} { + if _, ok := DefaultWriteRegistry().Get(name); !ok { + t.Fatalf("%s is not registered", name) + } + } +} + +// Raising an agent's cap, removing it (0), or inheriting a higher fleet value +// is widening and names the shared bucket and the reserve floor; lowering is +// not. +func TestAgentGitHubCapPreviewDisclosesWideningOnlyWhenRaising(t *testing.T) { + provider := &capSettingsProvider{agentGeneral: map[string]any{ + "githubApiHourlyCap": float64(300), "githubApiHourlyCapEffective": float64(300), + "githubApiHourlyCapFleet": float64(500), "githubApiHourlyCapBase": "", + }} + h := NewHandler(provider, WithWritesEnabled(true), WithPendingStore(NewMemoryPendingStore()), WithHiveID("hive-a")) + + for _, tc := range []struct { + name string + cap any + widen bool + body any + }{ + {"raise", float64(600), true, 600}, + {"no cap", float64(0), true, 0}, + {"inherit a higher fleet value", "inherit", true, nil}, + {"lower", float64(100), false, 100}, + {"same", float64(300), false, 300}, + } { + t.Run(tc.name, func(t *testing.T) { + p, _ := capPreview(t, h, WriteOpAgentGitHubAPIHourlyCap, map[string]any{"agent": "reviewer", "cap": tc.cap}) + if p.Request.Method != http.MethodPut || p.Request.Path != "/api/config/agent/reviewer/general" { + t.Fatalf("request = %s %s", p.Request.Method, p.Request.Path) + } + got, _ := json.Marshal(p.Request.Body) + want, _ := json.Marshal(map[string]any{"githubApiHourlyCap": tc.body}) + if string(got) != string(want) { + t.Fatalf("body = %s, want %s", got, want) + } + if isWidening(p) != tc.widen || namesBucketAndFloor(p) != tc.widen { + t.Fatalf("widening_disclosure = %q, want widening=%v", p.WideningDisclosure, tc.widen) + } + if p.WideningDisclosure == "" || p.ConfirmationMessage == "" || len(p.Effects) == 0 { + t.Fatalf("incomplete preview %#v", p) + } + }) + } + + // Inheriting a LOWER fleet value is not widening. + provider.agentGeneral["githubApiHourlyCapFleet"] = float64(200) + if p, _ := capPreview(t, h, WriteOpAgentGitHubAPIHourlyCap, map[string]any{"agent": "reviewer", "cap": "inherit"}); isWidening(p) { + t.Fatalf("inheriting 200 over 300 disclosed as widening: %q", p.WideningDisclosure) + } + // An uncapped agent cannot be widened. + provider.agentGeneral["githubApiHourlyCapEffective"] = float64(0) + if p, _ := capPreview(t, h, WriteOpAgentGitHubAPIHourlyCap, map[string]any{"agent": "reviewer", "cap": float64(900)}); isWidening(p) { + t.Fatalf("capping an uncapped agent disclosed as widening: %q", p.WideningDisclosure) + } +} + +func TestAgentGitHubCapPreviewRefusesReplicasAndBadValues(t *testing.T) { + provider := &capSettingsProvider{agentGeneral: map[string]any{"githubApiHourlyCapEffective": float64(600), "githubApiHourlyCapFleet": float64(300), "githubApiHourlyCapBase": "reviewer"}} + h := NewHandler(provider, WithWritesEnabled(true), WithPendingStore(NewMemoryPendingStore()), WithHiveID("hive-a")) + _, err := h.PreviewWrite(context.Background(), map[string]any{"operation": WriteOpAgentGitHubAPIHourlyCap, "args": map[string]any{"agent": "reviewer-2", "cap": float64(900)}}) + if err == nil || !strings.Contains(err.Error(), "set the cap on reviewer") { + t.Fatalf("replica preview err = %v", err) + } + for _, bad := range []any{float64(-1), float64(1.5), "lots", nil} { + if _, err := (agentGitHubCapOp{}).Preview(context.Background(), map[string]any{"agent": "reviewer", "cap": bad}); err == nil { + t.Errorf("cap %v accepted", bad) + } + } + if _, err := (fleetGitHubCapOp{}).Preview(context.Background(), map[string]any{"cap": "inherit"}); err == nil { + t.Error("the fleet op accepted inherit; its keyword is default") + } +} + +// Without a reading of the current cap, the preview cannot rule widening out. +func TestGitHubCapPreviewWithoutProviderDisclosesPotentialWidening(t *testing.T) { + p, err := (agentGitHubCapOp{}).Preview(context.Background(), map[string]any{"agent": "reviewer", "cap": float64(100)}) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(p.WideningDisclosure, "Potential widening:") || !namesBucketAndFloor(p) { + t.Fatalf("disclosure = %q", p.WideningDisclosure) + } +} + +func TestFleetGitHubCapPreviewAndConfirm(t *testing.T) { + provider := &capSettingsProvider{forge: map[string]any{"agent_github_api_hourly_cap": float64(300), "agent_github_api_hourly_cap_set": false}} + h := NewHandler(provider, WithWritesEnabled(true), WithPendingStore(NewMemoryPendingStore()), WithHiveID("hive-a")) + + raise, id := capPreview(t, h, WriteOpGitHubAgentAPIHourlyCap, map[string]any{"cap": float64(600)}) + if !isWidening(raise) || !namesBucketAndFloor(raise) { + t.Fatalf("raise disclosure = %q", raise.WideningDisclosure) + } + if raise.Request.Method != http.MethodPut || raise.Request.Path != "/api/config/github" { + t.Fatalf("request = %s %s", raise.Request.Method, raise.Request.Path) + } + if _, err := h.ConfirmWrite(context.Background(), map[string]any{"confirmation_id": id}); err != nil { + t.Fatal(err) + } + if len(provider.requests) != 1 { + t.Fatalf("writes = %+v", provider.requests) + } + got, _ := json.Marshal(provider.requests[0]) + if !strings.Contains(string(got), `"path":"/api/config/github"`) || !strings.Contains(string(got), `"agent_github_api_hourly_cap":600`) { + t.Fatalf("executed %s", got) + } + + if lower, _ := capPreview(t, h, WriteOpGitHubAgentAPIHourlyCap, map[string]any{"cap": float64(150)}); isWidening(lower) || namesBucketAndFloor(lower) { + t.Fatalf("lowering disclosed as widening: %q", lower.WideningDisclosure) + } + if none, _ := capPreview(t, h, WriteOpGitHubAgentAPIHourlyCap, map[string]any{"cap": float64(0)}); !isWidening(none) { + t.Fatalf("removing the cap not disclosed: %q", none.WideningDisclosure) + } + // "default" removes the key (null); from 600 back to 300 it is not widening. + provider.forge["agent_github_api_hourly_cap"] = float64(600) + reset, _ := capPreview(t, h, WriteOpGitHubAgentAPIHourlyCap, map[string]any{"cap": "default"}) + body, _ := json.Marshal(reset.Request.Body) + if string(body) != `{"agent_github_api_hourly_cap":null}` || isWidening(reset) { + t.Fatalf("reset body = %s, disclosure = %q", body, reset.WideningDisclosure) + } +} + +// The agent op confirms through the same General endpoint the dashboard uses. +func TestAgentGitHubCapConfirmExecutesGeneralSave(t *testing.T) { + provider := &capSettingsProvider{agentGeneral: map[string]any{"githubApiHourlyCapEffective": float64(300), "githubApiHourlyCapFleet": float64(300), "githubApiHourlyCapBase": ""}} + h := NewHandler(provider, WithWritesEnabled(true), WithPendingStore(NewMemoryPendingStore()), WithHiveID("hive-a")) + _, id := capPreview(t, h, WriteOpAgentGitHubAPIHourlyCap, map[string]any{"agent": "reviewer", "cap": "inherit"}) + if _, err := h.ConfirmWrite(context.Background(), map[string]any{"confirmation_id": id}); err != nil { + t.Fatal(err) + } + if len(provider.requests) != 1 || provider.requests[0].Path != "/api/config/agent/reviewer/general" { + t.Fatalf("writes = %+v", provider.requests) + } + got, _ := json.Marshal(provider.requests[0].Body) + if string(got) != `{"githubApiHourlyCap":null}` { + t.Fatalf("body = %s", got) + } +} diff --git a/src/pkg/config/github_api_cap_settings.go b/src/pkg/config/github_api_cap_settings.go new file mode 100644 index 0000000000..4abd0332d7 --- /dev/null +++ b/src/pkg/config/github_api_cap_settings.go @@ -0,0 +1,39 @@ +package config + +// Settings-side helpers for the proxy's per-agent GitHub API hourly cap +// (agents.github_api_hourly_cap and agents..github_api_hourly_cap). +// The dashboard and the admin MCP write these through the same save path as +// every other dashboard setting; the proxy reads the live *Config through +// AgentGitHubAPIHourlyCap, so a change applies on the next proxied request. + +// FleetAgentGitHubAPIHourlyCap returns the fleet default cap the proxy applies +// to an agent with no override of its own: the configured +// agents.github_api_hourly_cap, or DefaultAgentsGitHubAPIHourlyCap when the key +// is not set. 0 means no cap. +func (c *Config) FleetAgentGitHubAPIHourlyCap() int { + if c == nil { + return DefaultAgentsGitHubAPIHourlyCap + } + if !c.AgentsGitHubAPIHourlyCapSet && c.AgentsGitHubAPIHourlyCap == 0 { + return DefaultAgentsGitHubAPIHourlyCap + } + return c.AgentsGitHubAPIHourlyCap +} + +// SetFleetAgentGitHubAPIHourlyCap sets agents.github_api_hourly_cap. A nil cap +// removes the key, so the default applies again and the next save writes no +// key; a non-nil cap is written as given, 0 included (no cap). +func (c *Config) SetFleetAgentGitHubAPIHourlyCap(cap *int) { + if c == nil { + return + } + if cap == nil { + // The value applyDefaults leaves for an absent key, with the "set" + // mark cleared so the save omits the key. + c.AgentsGitHubAPIHourlyCap = DefaultAgentsGitHubAPIHourlyCap + c.AgentsGitHubAPIHourlyCapSet = false + return + } + c.AgentsGitHubAPIHourlyCap = *cap + c.AgentsGitHubAPIHourlyCapSet = true +} diff --git a/src/pkg/dashboard/api_agent_config.go b/src/pkg/dashboard/api_agent_config.go index 4389c4d6b6..caaa61259c 100644 --- a/src/pkg/dashboard/api_agent_config.go +++ b/src/pkg/dashboard/api_agent_config.go @@ -199,6 +199,14 @@ func (s *Server) handleAgentConfigGet(w http.ResponseWriter, r *http.Request) { "sandboxEnabled": agentCfg.Sandbox != nil && agentCfg.Sandbox.Enabled != nil && *agentCfg.Sandbox.Enabled, "sandboxEffective": agentCfg.SandboxEnabled(s.deps.Config.AgentSandbox), "replicas": agentCfg.Replicas, + // The proxy's GitHub API hourly cap (agents..github_api_hourly_cap): + // this agent's own override (null = inherit), the cap the proxy + // applies now, the fleet default it inherits, and, for a replica + // or review head, the base agent it takes its cap from. + "githubApiHourlyCap": agentCfg.GitHubAPIHourlyCap, + "githubApiHourlyCapEffective": s.deps.Config.AgentGitHubAPIHourlyCap(name), + "githubApiHourlyCapFleet": s.deps.Config.FleetAgentGitHubAPIHourlyCap(), + "githubApiHourlyCapBase": agentCfg.ReplicaOf, }, "cadences": cadences, "models": models, @@ -418,11 +426,28 @@ func (s *Server) handleAgentConfigGeneral(w http.ResponseWriter, r *http.Request jsonError(w, err.Error(), http.StatusBadRequest) return } + // agents..github_api_hourly_cap (api_github_api_cap.go): a number, + // 0 for no cap, or null to inherit the fleet default. Only a base agent + // carries one; its replicas and review heads are copies rebuilt from it. + ghCap, ghCapPresent, ghCapErr := agentGitHubCapFromBody(body) + if ghCapErr != nil { + jsonError(w, ghCapErr.Error(), http.StatusBadRequest) + return + } + if base := s.deps.Config.Agents[name].ReplicaOf; ghCapPresent && base != "" { + jsonError(w, agentGitHubCapReplicaError(name, base).Error(), http.StatusBadRequest) + return + } agentCfg := s.deps.Config.Agents[name] // Captured before the field writes below, because leaving on-demand is a // TRANSITION rather than a state: acting on it needs the previous value. prevOnDemand := agentCfg.OnDemand + ghCapAudit := "" + if ghCapPresent { + ghCapAudit = agentGitHubOverrideLabel(agentCfg.GitHubAPIHourlyCap) + " -> " + agentGitHubOverrideLabel(ghCap) + agentCfg.GitHubAPIHourlyCap = ghCap + } if v, ok := body["enabled"]; ok { if b, ok := v.(bool); ok { agentCfg.Enabled = b @@ -941,7 +966,11 @@ func (s *Server) handleAgentConfigGeneral(w http.ResponseWriter, r *http.Request } } - s.auditFromRequest(r, "config_agent_general", auditDetail("section", "general"), name) + generalAudit := auditDetail("section", "general") + if ghCapPresent { + generalAudit = auditDetail("section", "general", "github_api_hourly_cap", ghCapAudit) + } + s.auditFromRequest(r, "config_agent_general", generalAudit, name) s.refreshAndPersist() okResponse(w, map[string]string{"status": "updated", "agent": name}) } diff --git a/src/pkg/dashboard/api_config.go b/src/pkg/dashboard/api_config.go index 2c90f31b98..e8bc669c4d 100644 --- a/src/pkg/dashboard/api_config.go +++ b/src/pkg/dashboard/api_config.go @@ -2,6 +2,7 @@ package dashboard import ( "context" + "encoding/json" "fmt" "net/http" "os" @@ -128,18 +129,28 @@ type githubAPISettings struct { GraphQLPRBatchPageSize *int `json:"graphql_pr_batch_page_size"` AppAuthoredPRs *bool `json:"app_authored_prs"` AppSignedCommits *bool `json:"app_signed_commits"` + // AgentGitHubAPIHourlyCap is agents.github_api_hourly_cap, the proxy's + // fleet default per-agent cap, edited on the Forge App tab beside + // agent_reserve_floor: a whole number sets it (0 = no cap), null removes + // the key so the default applies, absent leaves it alone. Raw so null and + // absent stay apart; see fleetAgentGitHubCap. + AgentGitHubAPIHourlyCap json.RawMessage `json:"agent_github_api_hourly_cap"` } func (b githubAPISettings) empty() bool { return b.AgentReserveFloor == nil && b.APIReserve == nil && b.APICritical == nil && b.PRDetailTTLS == nil && b.ETagCacheMaxMB == nil && b.ETagCacheMaxEntries == nil && - b.GraphQLPRBatch == nil && b.GraphQLPRBatchPageSize == nil && b.AppAuthoredPRs == nil && b.AppSignedCommits == nil + b.GraphQLPRBatch == nil && b.GraphQLPRBatchPageSize == nil && b.AppAuthoredPRs == nil && b.AppSignedCommits == nil && + len(b.AgentGitHubAPIHourlyCap) == 0 } // validate checks the request against the current github block with the // same bounds config.Validate enforces at boot, so the dialog cannot save a // value the next start would reject. func (b githubAPISettings) validate(cur config.GitHubConfig) error { + if _, _, err := b.fleetAgentGitHubCap(); err != nil { + return err + } for _, f := range []struct { v *int name string @@ -272,7 +283,7 @@ func (s *Server) handleConfigGitHub(w http.ResponseWriter, r *http.Request) { } if body.AppID == nil && body.InstallationID == nil && body.KeyFile == "" && body.PrivateKey == "" && body.SelfAuthorizationHold == nil && body.githubAPISettings.empty() { - jsonError(w, "at least one field required: app_id, installation_id, key_file, private_key, self_authorization_hold, agent_reserve_floor, api_reserve, api_critical, etag_cache_max_mb, etag_cache_max_entries, pr_detail_ttl_s, graphql_pr_batch, graphql_pr_batch_page_size, app_authored_prs, app_signed_commits", http.StatusBadRequest) + jsonError(w, "at least one field required: app_id, installation_id, key_file, private_key, self_authorization_hold, agent_reserve_floor, agent_github_api_hourly_cap, api_reserve, api_critical, etag_cache_max_mb, etag_cache_max_entries, pr_detail_ttl_s, graphql_pr_batch, graphql_pr_batch_page_size, app_authored_prs, app_signed_commits", http.StatusBadRequest) return } @@ -357,8 +368,15 @@ func (s *Server) applyGitHubConfigUpdate(r *http.Request, body githubConfigUpdat if body.ETagCacheMaxMB != nil || body.ETagCacheMaxEntries != nil { github.SetETagCacheLimits(cfg.GitHub.EffectiveETagCacheMaxBytes(), cfg.GitHub.EffectiveETagCacheMaxEntries()) } + // The fleet agent cap is not in the github block, but the proxy reads it + // from this same live *Config on every request (main.go binds + // cfg.AgentGitHubAPIHourlyCap), so setting it here is the live apply. + auditNote := "" + if fleetCap, present, _ := body.githubAPISettings.fleetAgentGitHubCap(); present { + auditNote = applyFleetAgentGitHubCap(cfg, fleetCap) + } - result, err := s.finishGitHubConfigUpdateLocked(requestAuditUser(r), "") + result, err := s.finishGitHubConfigUpdateLocked(requestAuditUser(r), auditNote) if err != nil { if strings.Contains(err.Error(), "source path") { s.logger.Error("github config update rejected: config has no source path, save would be an in-memory no-op") @@ -367,6 +385,8 @@ func (s *Server) applyGitHubConfigUpdate(r *http.Request, body githubConfigUpdat s.logger.Error("failed to persist github config", "error", err) return nil, newGitHubConfigUpdateError("failed to save config", http.StatusInternalServerError) } + result["agent_github_api_hourly_cap"] = cfg.FleetAgentGitHubAPIHourlyCap() + result["agent_github_api_hourly_cap_set"] = cfg.AgentsGitHubAPIHourlyCapSet return result, nil } diff --git a/src/pkg/dashboard/api_diag_config.go b/src/pkg/dashboard/api_diag_config.go index bb568aaebb..28aa93f62a 100644 --- a/src/pkg/dashboard/api_diag_config.go +++ b/src/pkg/dashboard/api_diag_config.go @@ -137,6 +137,10 @@ type diagConfigResult struct { Provenance diagConfigProvenance `json:"provenance"` Redactions []logread.Redaction `json:"redactions"` ResultByteCap int `json:"result_byte_cap"` + // AgentGitHubCaps is the proxy's effective per-agent GitHub API hourly + // cap under this configuration (diag_github_api_caps.go): non-secret + // numbers, shown whatever the value list says. + AgentGitHubCaps *diagAgentGitHubCaps `json:"agent_github_api_hourly_caps,omitempty"` } const diagConfigNote = "Values are shown only for configuration keys and environment names on value_list, which lists keys known not to hold secrets; every other key or name is shown only as set or not set. Shown values are scrubbed as log lines are. The configuration file itself is never returned. layer names the layer that set each key (see provenance.layers); also_set_by lists lower layers it overrode." @@ -220,6 +224,7 @@ func buildDiagConfig(in diagConfigInput) diagConfigResult { } if in.merged != nil { res.Provenance.IdentityIssues = config.IdentitySetIssues(in.merged.GitHub) + res.AgentGitHubCaps = buildDiagAgentGitHubCaps(in.merged) } keys := diagConfigKeys(in, prov, scrub) res.Env = diagEnvNames(in.environ, scrub) diff --git a/src/pkg/dashboard/api_github_api_cap.go b/src/pkg/dashboard/api_github_api_cap.go new file mode 100644 index 0000000000..9b477df762 --- /dev/null +++ b/src/pkg/dashboard/api_github_api_cap.go @@ -0,0 +1,95 @@ +package dashboard + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "strconv" + + "github.com/hivecommons/hive/pkg/config" +) + +// Request parsing and audit wording for the proxy's per-agent GitHub API +// hourly cap: the fleet default (agent_github_api_hourly_cap on +// PUT /api/config/github, the Forge App tab) and one agent's override +// (githubApiHourlyCap on PUT /api/config/agent/{name}/general, the agent's +// General tab). Both write the live config the proxy reads, then save it +// through the normal dashboard save (hive.yaml, the dashboard overlay and the +// PVC runtime copy). + +const agentGitHubCapField = "githubApiHourlyCap" + +// fleetAgentGitHubCap decodes agent_github_api_hourly_cap: present=false when +// the field is absent, a nil cap for null (back to the default), else a whole +// number of at least 0 (0 = no cap). +func (b githubAPISettings) fleetAgentGitHubCap() (cap *int, present bool, err error) { + raw := bytes.TrimSpace(b.AgentGitHubAPIHourlyCap) + if len(raw) == 0 { + return nil, false, nil + } + if string(raw) == "null" { + return nil, true, nil + } + var n int + if err := json.Unmarshal(raw, &n); err != nil || n < 0 { + return nil, true, fmt.Errorf("agent_github_api_hourly_cap must be a whole number of at least 0 (0 = no cap), or null for the default (%d)", config.DefaultAgentsGitHubAPIHourlyCap) + } + return &n, true, nil +} + +// applyFleetAgentGitHubCap sets agents.github_api_hourly_cap on the live +// config and returns the audit detail naming the change. +func applyFleetAgentGitHubCap(cfg *config.Config, cap *int) string { + before := fleetAgentGitHubCapLabel(cfg) + cfg.SetFleetAgentGitHubAPIHourlyCap(cap) + return auditDetail("agents.github_api_hourly_cap", before+" -> "+fleetAgentGitHubCapLabel(cfg)) +} + +func fleetAgentGitHubCapLabel(cfg *config.Config) string { + label := agentGitHubCapLabel(cfg.FleetAgentGitHubAPIHourlyCap()) + if !cfg.AgentsGitHubAPIHourlyCapSet { + label += " (default)" + } + return label +} + +func agentGitHubCapLabel(n int) string { + if n == 0 { + return "0 (no cap)" + } + return strconv.Itoa(n) +} + +func agentGitHubOverrideLabel(cap *int) string { + if cap == nil { + return "inherit" + } + return agentGitHubCapLabel(*cap) +} + +// agentGitHubCapFromBody reads githubApiHourlyCap from a General-tab save: +// present=false when absent, a nil cap for null (inherit the fleet default), +// else a whole number of at least 0 (0 = no cap for this agent). +func agentGitHubCapFromBody(body map[string]interface{}) (cap *int, present bool, err error) { + v, ok := body[agentGitHubCapField] + if !ok { + return nil, false, nil + } + if v == nil { + return nil, true, nil + } + f, ok := v.(float64) + if !ok || f < 0 || f != math.Trunc(f) || f > math.MaxInt32 { + return nil, true, fmt.Errorf("%s must be a whole number of at least 0 (0 = no cap), or null to inherit the fleet default", agentGitHubCapField) + } + n := int(f) + return &n, true, nil +} + +// agentGitHubCapReplicaError refuses an override on a replica or review head: +// those are rebuilt from their base agent on every load, so the value would +// be dropped. The base agent's override covers them. +func agentGitHubCapReplicaError(name, base string) error { + return fmt.Errorf("%s is a copy of agent %s and takes its GitHub API hourly cap from it; set %s on %s instead", name, base, agentGitHubCapField, base) +} diff --git a/src/pkg/dashboard/diag_config_value_keys.txt b/src/pkg/dashboard/diag_config_value_keys.txt index 48e04cc042..80ddea2067 100644 --- a/src/pkg/dashboard/diag_config_value_keys.txt +++ b/src/pkg/dashboard/diag_config_value_keys.txt @@ -32,6 +32,10 @@ agents.*.backend agents.*.model agents.*.enabled agents.*.paused +# The proxy's per-agent GitHub API hourly cap: the fleet default and each +# agent's override (whole numbers; 0 = no cap). +agents.github_api_hourly_cap +agents.*.github_api_hourly_cap # Per-repository merge policy (the dashboard's Merge settings): repository # names it checks against project.repos, and the path globs it accepts for # each. human_merge_paths is keyed by owner/repo; "*[*]" matches that key and diff --git a/src/pkg/dashboard/diag_github_api_caps.go b/src/pkg/dashboard/diag_github_api_caps.go new file mode 100644 index 0000000000..b835ff23a8 --- /dev/null +++ b/src/pkg/dashboard/diag_github_api_caps.go @@ -0,0 +1,51 @@ +package dashboard + +import ( + "sort" + + "github.com/hivecommons/hive/pkg/config" +) + +// diagAgentGitHubCaps is config_effective_read's agent_github_api_hourly_caps: +// the proxy's per-agent GitHub API hourly cap as this configuration resolves +// it. Fleet is agents.github_api_hourly_cap (or the default 300; 0 = no cap) +// and FleetSet says whether the key is written. Agents maps each declared +// agent to the cap the proxy applies to it: its own +// agents..github_api_hourly_cap, else the fleet value. Replicas and +// review heads are not listed; they take their base agent's cap. +type diagAgentGitHubCaps struct { + Fleet int `json:"fleet"` + FleetSet bool `json:"fleet_set"` + Agents map[string]int `json:"agents"` + Overrides []string `json:"overrides"` +} + +func buildDiagAgentGitHubCaps(merged *config.Config) *diagAgentGitHubCaps { + if merged == nil { + return nil + } + // The merged layers have not been through Load's defaults; give the + // fleet key the value an absent key gets at load, so the getter reads + // the configuration the way the running proxy will. + cfg := *merged + if !cfg.AgentsGitHubAPIHourlyCapSet && cfg.AgentsGitHubAPIHourlyCap == 0 { + cfg.AgentsGitHubAPIHourlyCap = config.DefaultAgentsGitHubAPIHourlyCap + } + out := &diagAgentGitHubCaps{ + Fleet: cfg.FleetAgentGitHubAPIHourlyCap(), + FleetSet: cfg.AgentsGitHubAPIHourlyCapSet, + Agents: map[string]int{}, + Overrides: []string{}, + } + for name, agent := range cfg.Agents { + if agent.ReplicaOf != "" { + continue + } + out.Agents[name] = cfg.AgentGitHubAPIHourlyCap(name) + if agent.GitHubAPIHourlyCap != nil { + out.Overrides = append(out.Overrides, name) + } + } + sort.Strings(out.Overrides) + return out +} diff --git a/src/pkg/dashboard/forge_apps.go b/src/pkg/dashboard/forge_apps.go index c4e0070c9d..e18ba1183d 100644 --- a/src/pkg/dashboard/forge_apps.go +++ b/src/pkg/dashboard/forge_apps.go @@ -94,6 +94,12 @@ type forgeGitHubSettings struct { GraphQLPRBatchPageSize int `json:"graphql_pr_batch_page_size"` AppAuthoredPRs bool `json:"app_authored_prs"` AppSignedCommits bool `json:"app_signed_commits"` + // AgentGitHubAPIHourlyCap is agents.github_api_hourly_cap as the proxy + // applies it (the default when unset; 0 = no cap). It is not a github.* + // key but is edited here beside agent_reserve_floor; _set says whether + // the key is written, so the field shows blank for the default. + AgentGitHubAPIHourlyCap int `json:"agent_github_api_hourly_cap"` + AgentGitHubAPIHourlyCapSet bool `json:"agent_github_api_hourly_cap_set"` Defaults map[string]int `json:"defaults"` } @@ -101,28 +107,31 @@ type forgeGitHubSettings struct { func forgeGitHubSettingsFor(cfg *config.Config, hubManaged bool) forgeGitHubSettings { g := cfg.GitHub return forgeGitHubSettings{ - Forge: strings.TrimSpace(g.Forge_), - APIURL: strings.TrimSpace(g.APIURL), - DocsInstallationID: g.DocsInstallationID, - OAuthClientID: g.OAuthClientID, - TokenSet: strings.TrimSpace(g.Token) != "", - HubManaged: hubManaged, - AgentReserveFloor: cfg.GitHubAgentReserveFloor(), - APIReserve: g.APIReserve, - APICritical: g.APICritical, - ETagCacheMaxMB: g.ETagCacheMaxMB, - ETagCacheMaxEntries: g.ETagCacheMaxEntries, - PRDetailTTLS: g.PRDetailTTLS, - GraphQLPRBatch: g.GraphQLPRBatchEnabled(), - GraphQLPRBatchPageSize: g.GraphQLPRBatchPageSize, - AppAuthoredPRs: g.AppAuthoredPRsEnabled(), - AppSignedCommits: g.AppSignedCommitsEnabled(), + Forge: strings.TrimSpace(g.Forge_), + APIURL: strings.TrimSpace(g.APIURL), + DocsInstallationID: g.DocsInstallationID, + OAuthClientID: g.OAuthClientID, + TokenSet: strings.TrimSpace(g.Token) != "", + HubManaged: hubManaged, + AgentReserveFloor: cfg.GitHubAgentReserveFloor(), + APIReserve: g.APIReserve, + APICritical: g.APICritical, + ETagCacheMaxMB: g.ETagCacheMaxMB, + ETagCacheMaxEntries: g.ETagCacheMaxEntries, + PRDetailTTLS: g.PRDetailTTLS, + GraphQLPRBatch: g.GraphQLPRBatchEnabled(), + GraphQLPRBatchPageSize: g.GraphQLPRBatchPageSize, + AppAuthoredPRs: g.AppAuthoredPRsEnabled(), + AppSignedCommits: g.AppSignedCommitsEnabled(), + AgentGitHubAPIHourlyCap: cfg.FleetAgentGitHubAPIHourlyCap(), + AgentGitHubAPIHourlyCapSet: cfg.AgentsGitHubAPIHourlyCapSet, Defaults: map[string]int{ - "agent_reserve_floor": config.DefaultGitHubAgentReserveFloor, - "etag_cache_max_mb": config.DefaultGitHubETagCacheMaxMB, - "etag_cache_max_entries": config.DefaultGitHubETagCacheMaxEntries, - "pr_detail_ttl_s": int(config.DefaultGitHubPRDetailTTL / time.Second), - "graphql_pr_batch_page_size": config.DefaultGraphQLPRBatchPageSize, + "agent_reserve_floor": config.DefaultGitHubAgentReserveFloor, + "agent_github_api_hourly_cap": config.DefaultAgentsGitHubAPIHourlyCap, + "etag_cache_max_mb": config.DefaultGitHubETagCacheMaxMB, + "etag_cache_max_entries": config.DefaultGitHubETagCacheMaxEntries, + "pr_detail_ttl_s": int(config.DefaultGitHubPRDetailTTL / time.Second), + "graphql_pr_batch_page_size": config.DefaultGraphQLPRBatchPageSize, }, } } diff --git a/src/pkg/dashboard/github_api_cap_settings_test.go b/src/pkg/dashboard/github_api_cap_settings_test.go new file mode 100644 index 0000000000..527204a25b --- /dev/null +++ b/src/pkg/dashboard/github_api_cap_settings_test.go @@ -0,0 +1,361 @@ +package dashboard + +import ( + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hivecommons/hive/crux" + "github.com/hivecommons/hive/crux/cruxtest" + "github.com/hivecommons/hive/pkg/config" + "gopkg.in/yaml.v3" +) + +// The proxy's per-agent GitHub API hourly cap (agents.github_api_hourly_cap +// and agents..github_api_hourly_cap) was hive.yaml-only, so on a +// hub-managed spoke nobody but the hub operator could raise a reviewer that +// hit its 300/h cap overnight. The Forge App tab now edits the fleet default +// and each agent's General tab its override. Both save through the normal +// dashboard save (hive.yaml, the dashboard overlay), audit the change, and +// write the live config the proxy reads, so the cap applies without a restart. + +// capTestLayers points every config layer at a temp dir and forces +// Kubernetes mode, so Save writes the dashboard overlay as on a hosted spoke. +func capTestLayers(t *testing.T, cfg *config.Config) (overlay string) { + t.Helper() + dir := t.TempDir() + t.Setenv("KUBERNETES_SERVICE_HOST", "10.96.0.1") // IsKubernetesPod() → true + cfg.SourcePath = filepath.Join(dir, "hive.yaml") + oldRuntime, oldOverlay := config.RuntimeConfigFile, config.DashboardOverlayFile + config.RuntimeConfigFile = filepath.Join(dir, "hive.yaml.runtime") + config.DashboardOverlayFile = filepath.Join(dir, "hive.yaml.dashboard") + t.Cleanup(func() { config.RuntimeConfigFile, config.DashboardOverlayFile = oldRuntime, oldOverlay }) + // What Load's applyDefaults leaves for an absent fleet key. + cfg.AgentsGitHubAPIHourlyCap = config.DefaultAgentsGitHubAPIHourlyCap + return config.DashboardOverlayFile +} + +func readOverlayConfig(t *testing.T, path string) config.Config { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("dashboard overlay not written: %v", err) + } + var c config.Config + if err := yaml.Unmarshal(data, &c); err != nil { + t.Fatalf("overlay does not parse: %v\n%s", err, data) + } + return c +} + +func lastCapAudit(t *testing.T, s *Server) AuditEntry { + t.Helper() + entries := s.GetAudit().Recent(1) + if len(entries) != 1 { + t.Fatal("no audit entry recorded") + } + return entries[0] +} + +func TestFleetAgentGitHubCapSavesAuditsAndAppliesLive(t *testing.T) { + s, deps := apiServer(t) + overlay := capTestLayers(t, deps.Config) + // main.go hands the proxy this method value, bound to the live *Config. + proxyCap := deps.Config.AgentGitHubAPIHourlyCap + + for _, bad := range []string{`{"agent_github_api_hourly_cap": -1}`, `{"agent_github_api_hourly_cap": 1.5}`, `{"agent_github_api_hourly_cap": "600"}`} { + if rec := doPutRaw(s, "/api/config/github", bad); rec.Code != http.StatusBadRequest { + t.Fatalf("%s: status = %d, want 400 (%s)", bad, rec.Code, rec.Body.String()) + } + } + if deps.Config.AgentsGitHubAPIHourlyCapSet || proxyCap("scanner") != 300 { + t.Fatal("a rejected PUT changed the fleet cap") + } + + rec := doPut(s, "/api/config/github", map[string]any{"agent_github_api_hourly_cap": 600}) + if rec.Code != http.StatusOK { + t.Fatalf("PUT: %d %s", rec.Code, rec.Body.String()) + } + if got := proxyCap("scanner"); got != 600 { + t.Fatalf("proxy cap after save = %d, want 600 without a reload", got) + } + if o := readOverlayConfig(t, overlay); !o.AgentsGitHubAPIHourlyCapSet || o.AgentsGitHubAPIHourlyCap != 600 { + t.Fatalf("overlay fleet cap = %d (set %v), want 600", o.AgentsGitHubAPIHourlyCap, o.AgentsGitHubAPIHourlyCapSet) + } + if e := lastCapAudit(t, s); e.Action != "config_github" || !strings.Contains(e.Detail, "agents.github_api_hourly_cap=300 (default) -> 600") { + t.Fatalf("audit = %+v", e) + } + forge := decodeJSON(t, doOwnerGet(s, "/api/config/github/forge-apps")) + settings, _ := forge["settings"].(map[string]any) + if settings["agent_github_api_hourly_cap"] != float64(600) || settings["agent_github_api_hourly_cap_set"] != true { + t.Fatalf("forge-apps settings = %v", settings) + } + + // 0 is a real value: no cap. It is kept, not read as "unset". + if rec := doPut(s, "/api/config/github", map[string]any{"agent_github_api_hourly_cap": 0}); rec.Code != http.StatusOK { + t.Fatalf("PUT 0: %d %s", rec.Code, rec.Body.String()) + } + if got := proxyCap("scanner"); got != 0 || !deps.Config.AgentsGitHubAPIHourlyCapSet { + t.Fatalf("after 0: proxy cap = %d, set = %v; want 0 and set", got, deps.Config.AgentsGitHubAPIHourlyCapSet) + } + if o := readOverlayConfig(t, overlay); !o.AgentsGitHubAPIHourlyCapSet || o.AgentsGitHubAPIHourlyCap != 0 { + t.Fatalf("overlay after 0 = %d (set %v), want an explicit 0", o.AgentsGitHubAPIHourlyCap, o.AgentsGitHubAPIHourlyCapSet) + } + + // null removes the key: the default applies again and the overlay omits it. + if rec := doPutRaw(s, "/api/config/github", `{"agent_github_api_hourly_cap": null}`); rec.Code != http.StatusOK { + t.Fatalf("PUT null: %d %s", rec.Code, rec.Body.String()) + } + if got := proxyCap("scanner"); got != 300 || deps.Config.AgentsGitHubAPIHourlyCapSet { + t.Fatalf("after null: proxy cap = %d, set = %v; want the default 300, unset", got, deps.Config.AgentsGitHubAPIHourlyCapSet) + } + if o := readOverlayConfig(t, overlay); o.AgentsGitHubAPIHourlyCapSet { + t.Fatal("overlay still carries agents.github_api_hourly_cap after a reset to the default") + } + if e := lastCapAudit(t, s); !strings.Contains(e.Detail, "0 (no cap) -> 300 (default)") { + t.Fatalf("audit after reset = %+v", e) + } +} + +// capReplicaServer is apiServer with scanner running two replicas, built +// before the agent manager so the save does not see scanner-2 as new. +func capReplicaServer(t *testing.T) (*Server, *Dependencies) { + t.Helper() + deps := testDeps(t) + sc := deps.Config.Agents["scanner"] + sc.Replicas = 2 + deps.Config.Agents["scanner"] = sc + if err := deps.Config.ExpandAgentReplicas(); err != nil { + t.Fatal(err) + } + if deps.Config.Agents["scanner-2"].ReplicaOf != "scanner" { + t.Fatalf("scanner-2 not expanded: %+v", deps.Config.Agents) + } + deps.AgentMgr = cruxtest.NewRuntime(deps.Config.Agents, deps.Logger, crux.ProjectContext{}) + s := NewServer(0, deps.Logger) + s.RegisterAPI(deps) + return s, deps +} + +func TestAgentGitHubCapGeneralSaveInheritZeroAndReplicas(t *testing.T) { + s, deps := capReplicaServer(t) + overlay := capTestLayers(t, deps.Config) + deps.Config.Data.AgentsDir = t.TempDir() + proxyCap := deps.Config.AgentGitHubAPIHourlyCap + + g := generalSection(t, s, "scanner") + if v, present := g["githubApiHourlyCap"]; !present || v != nil { + t.Fatalf("githubApiHourlyCap = %v (present %v), want null", v, present) + } + if g["githubApiHourlyCapEffective"] != float64(300) || g["githubApiHourlyCapFleet"] != float64(300) || g["githubApiHourlyCapBase"] != "" { + t.Fatalf("general before save = %v", g) + } + + for _, bad := range []any{-5, 2.5, "600"} { + if rec := doPut(s, "/api/config/agent/scanner/general", map[string]any{"githubApiHourlyCap": bad}); rec.Code != http.StatusBadRequest { + t.Fatalf("%v: status = %d, want 400", bad, rec.Code) + } + } + + rec := doPut(s, "/api/config/agent/scanner/general", map[string]any{"githubApiHourlyCap": 600}) + if rec.Code != http.StatusOK { + t.Fatalf("PUT 600: %d %s", rec.Code, rec.Body.String()) + } + if proxyCap("scanner") != 600 || proxyCap("scanner-2") != 600 { + t.Fatalf("proxy caps = %d / %d, want 600 for the agent and its replica at once", proxyCap("scanner"), proxyCap("scanner-2")) + } + if e := lastCapAudit(t, s); e.Action != "config_agent_general" || e.Agent != "scanner" || !strings.Contains(e.Detail, "github_api_hourly_cap=inherit -> 600") { + t.Fatalf("audit = %+v", e) + } + if o := readOverlayConfig(t, overlay); o.Agents["scanner"].GitHubAPIHourlyCap == nil || *o.Agents["scanner"].GitHubAPIHourlyCap != 600 { + t.Fatalf("overlay scanner = %+v", o.Agents["scanner"]) + } + if _, ok := readOverlayConfig(t, overlay).Agents["scanner-2"]; ok { + t.Fatal("the overlay persisted the derived replica") + } + agentFile, err := os.ReadFile(filepath.Join(deps.Config.Data.AgentsDir, "scanner.yaml")) + if err != nil || !strings.Contains(string(agentFile), "github_api_hourly_cap: 600") { + t.Fatalf("agent file (%v):\n%s", err, agentFile) + } + + // A replica shows its base agent's cap and refuses an override of its own. + r := generalSection(t, s, "scanner-2") + if r["githubApiHourlyCapBase"] != "scanner" || r["githubApiHourlyCapEffective"] != float64(600) { + t.Fatalf("replica general = %v", r) + } + if rec := doPut(s, "/api/config/agent/scanner-2/general", map[string]any{"githubApiHourlyCap": 50}); rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "scanner") { + t.Fatalf("replica PUT: %d %s, want 400 naming scanner", rec.Code, rec.Body.String()) + } + + // 0 is kept as "no cap for this agent", not cleared. + if rec := doPut(s, "/api/config/agent/scanner/general", map[string]any{"githubApiHourlyCap": 0}); rec.Code != http.StatusOK { + t.Fatalf("PUT 0: %d %s", rec.Code, rec.Body.String()) + } + if got := deps.Config.Agents["scanner"].GitHubAPIHourlyCap; got == nil || *got != 0 || proxyCap("scanner") != 0 { + t.Fatalf("after 0: override = %v, proxy cap = %d; want an explicit 0", got, proxyCap("scanner")) + } + + // null clears the override: the agent inherits the fleet default again. + if rec := doPutRaw(s, "/api/config/agent/scanner/general", `{"githubApiHourlyCap": null}`); rec.Code != http.StatusOK { + t.Fatalf("PUT null: %d %s", rec.Code, rec.Body.String()) + } + if got := deps.Config.Agents["scanner"].GitHubAPIHourlyCap; got != nil || proxyCap("scanner") != 300 || proxyCap("scanner-2") != 300 { + t.Fatalf("after null: override = %v, caps = %d / %d; want inherit and 300", got, proxyCap("scanner"), proxyCap("scanner-2")) + } + if o := readOverlayConfig(t, overlay); o.Agents["scanner"].GitHubAPIHourlyCap != nil { + t.Fatal("overlay still carries the cleared override") + } + if e := lastCapAudit(t, s); !strings.Contains(e.Detail, "github_api_hourly_cap=0 (no cap) -> inherit") { + t.Fatalf("audit after inherit = %+v", e) + } + + // A save that does not name the cap leaves it alone and says nothing of it. + if rec := doPut(s, "/api/config/agent/scanner/general", map[string]any{"githubApiHourlyCap": 450}); rec.Code != http.StatusOK { + t.Fatal(rec.Body.String()) + } + if rec := doPut(s, "/api/config/agent/scanner/general", map[string]any{"description": "scans"}); rec.Code != http.StatusOK { + t.Fatal(rec.Body.String()) + } + if proxyCap("scanner") != 450 || strings.Contains(lastCapAudit(t, s).Detail, "github_api_hourly_cap") { + t.Fatalf("an unrelated save touched the cap: %d, audit %+v", proxyCap("scanner"), lastCapAudit(t, s)) + } +} + +// The GitHub API budget panel carries each agent's effective cap, where it +// comes from, and the calls the proxy counted against it this hour. +func TestGHRateLimitsCarryAgentCaps(t *testing.T) { + SetAgentGitHubCallsProvider(func() map[string]int { return map[string]int{"scanner": 42, "scanner-2": 7, "reviewer": 9} }) + t.Cleanup(func() { SetAgentGitHubCallsProvider(nil) }) + six := 600 + zero := 0 + cfg := &config.Config{AgentRuntimeConfig: config.AgentRuntimeConfig{ + AgentsGitHubAPIHourlyCap: config.DefaultAgentsGitHubAPIHourlyCap, + Agents: map[string]config.AgentConfig{ + "scanner": {GitHubAPIHourlyCap: &six}, + "scanner-2": {GitHubAPIHourlyCap: &six, ReplicaOf: "scanner"}, + "reviewer": {GitHubAPIHourlyCap: &zero}, + "architect": {}, + }, + }} + + caps, ok := buildGHRateLimits(nil, nil, cfg)["agent_caps"].(map[string]any) + if !ok { + t.Fatal("ghRateLimits has no agent_caps") + } + if caps["fleet_cap"] != 300 || caps["fleet_cap_set"] != false { + t.Fatalf("fleet = %v / %v", caps["fleet_cap"], caps["fleet_cap_set"]) + } + rows, _ := caps["agents"].([]map[string]any) + byName := map[string]map[string]any{} + var order []string + for _, r := range rows { + byName[r["agent"].(string)] = r + order = append(order, r["agent"].(string)) + } + if strings.Join(order, ",") != "architect,reviewer,scanner,scanner-2" { + t.Fatalf("rows = %v, want every agent sorted", order) + } + for name, want := range map[string]struct { + cap int + calls any + source string + }{ + "scanner": {600, 42, "agent"}, + "scanner-2": {600, 7, "base_agent"}, + "reviewer": {0, nil, "agent"}, + "architect": {300, 0, "fleet"}, + } { + r := byName[name] + if r["cap"] != want.cap || r["calls"] != want.calls || r["source"] != want.source { + t.Errorf("%s = %v, want cap %d calls %v source %s", name, r, want.cap, want.calls, want.source) + } + } + if byName["scanner-2"]["base_agent"] != "scanner" { + t.Errorf("replica row = %v, want base_agent scanner", byName["scanner-2"]) + } +} + +// config_effective_read shows both caps: the keys' values (they are on the +// non-secret value list) and the effective cap per agent. +func TestDiagConfigShowsAgentGitHubCaps(t *testing.T) { + writeProvenanceFixture(t, diagSeedYAML, `project: + org: acme +agents: + github_api_hourly_cap: 300 + scanner: {backend: claude} + reviewer: + backend: claude + github_api_hourly_cap: 600 +`) + s, _ := apiServer(t) + res, _ := diagConfigRead(t, s, "/api/diag/config") + for key, want := range map[string]string{ + "agents.github_api_hourly_cap": "300", + "agents.reviewer.github_api_hourly_cap": "600", + } { + got := diagKey(t, res, key) + if !got.ValueShown || got.Value != want { + t.Errorf("%s = %+v, want %s shown", key, got, want) + } + } + caps := res.AgentGitHubCaps + if caps == nil { + t.Fatal("no agent_github_api_hourly_caps in the result") + } + if caps.Fleet != 300 || !caps.FleetSet || caps.Agents["reviewer"] != 600 || caps.Agents["scanner"] != 300 { + t.Fatalf("agent_github_api_hourly_caps = %+v", caps) + } + if strings.Join(caps.Overrides, ",") != "reviewer" { + t.Fatalf("overrides = %v", caps.Overrides) + } + for _, key := range []string{"agents.github_api_hourly_cap", "agents.reviewer.github_api_hourly_cap"} { + if !diagConfigValueKey(key) { + t.Errorf("%s is not on the value list", key) + } + } +} + +// Both controls render with their YAML key and a ? help mark, the fleet save +// sends null for a blank field, and the budget panel lists the agent caps. +func TestGitHubCapControlsRender(t *testing.T) { + html := indexHTML(t) + gh := extractJSFunction(t, html, "renderForgeGitHubSettings") + for _, want := range []string{ + "yamlHint('agents.github_api_hourly_cap')", + `id="forge-gh-${capKey}"`, + "settingHelpMark('operator-reference/runtime-and-quota', 'github-api-quota', 'Agent GitHub API hourly cap', ", + "${fleetCapField}", + } { + if !strings.Contains(gh, want) { + t.Errorf("Forge App settings missing %q", want) + } + } + if save := extractJSFunction(t, html, "saveForgeGitHubSettings"); !strings.Contains(save, "body.agent_github_api_hourly_cap = null") || !strings.Contains(save, "body.agent_github_api_hourly_cap = v") { + t.Error("saveForgeGitHubSettings does not send the fleet cap (null for blank)") + } + field := extractJSFunction(t, html, "agentGitHubCapFieldHtml") + for _, want := range []string{ + "yamlHint('agents..github_api_hourly_cap')", + "settingHelpMark('operator-reference/runtime-and-quota', 'github-api-quota', 'GitHub API hourly cap', tip)", + `data-change-action="githubApiCapChanged"`, + "g.githubApiHourlyCapBase", + } { + if !strings.Contains(field, want) { + t.Errorf("agent General cap field missing %q", want) + } + } + if !strings.Contains(extractJSFunction(t, html, "renderAgentGeneral"), "agentGitHubCapFieldHtml(g)") { + t.Error("the General tab does not render the cap field") + } + if !strings.Contains(html, "githubApiCapChanged: function") || !strings.Contains(html, "markDirty('general','githubApiHourlyCap',null)") { + t.Error("no change handler saving null for a blank cap") + } + panel := extractJSFunction(t, html, "renderGitHubAPIBudgetPanel") + for _, want := range []string{"ghRate.agent_caps", `id="gh-api-budget-agent-caps"`, "ghApiBudgetHelpMark('agentCaps', 'Agent caps')"} { + if !strings.Contains(panel, want) { + t.Errorf("budget panel missing %q", want) + } + } +} diff --git a/src/pkg/dashboard/github_api_cap_view.go b/src/pkg/dashboard/github_api_cap_view.go new file mode 100644 index 0000000000..1015fbc2ce --- /dev/null +++ b/src/pkg/dashboard/github_api_cap_view.go @@ -0,0 +1,107 @@ +package dashboard + +import ( + "sort" + "sync" + + "github.com/hivecommons/hive/pkg/config" +) + +// Per-agent rows for the "GitHub API budget" panel: each agent's effective +// proxy cap (agents..github_api_hourly_cap, else its base agent's, else +// agents.github_api_hourly_cap, else 300; 0 = no cap) and the calls the proxy +// has counted against it in the current sliding hour. + +var ( + agentGitHubCallsMu sync.RWMutex + agentGitHubCallsFn func() map[string]int +) + +// SetAgentGitHubCallsProvider registers the proxy's per-agent count of GitHub +// API calls in the current sliding hour (proxy.GitHubProxy.AgentGitHubCallsLastHour). +func SetAgentGitHubCallsProvider(fn func() map[string]int) { + agentGitHubCallsMu.Lock() + defer agentGitHubCallsMu.Unlock() + agentGitHubCallsFn = fn +} + +func getAgentGitHubCallsFn() func() map[string]int { + agentGitHubCallsMu.RLock() + defer agentGitHubCallsMu.RUnlock() + return agentGitHubCallsFn +} + +// buildAgentGitHubCaps is ghRateLimits.agent_caps: the fleet default and one +// row per configured agent (plus any agent the proxy counted that is no +// longer configured), sorted by name. calls is null for an uncapped agent, +// because the proxy keeps no window for it. +func buildAgentGitHubCaps(cfg *config.Config) map[string]any { + calls := map[string]int{} + if fn := getAgentGitHubCallsFn(); fn != nil { + if got := fn(); got != nil { + calls = got + } + } + names := map[string]bool{} + if cfg != nil { + for name := range cfg.Agents { + names[name] = true + } + } + for name := range calls { + names[name] = true + } + sorted := make([]string, 0, len(names)) + for name := range names { + sorted = append(sorted, name) + } + sort.Strings(sorted) + + rows := make([]map[string]any, 0, len(sorted)) + for _, name := range sorted { + cap := cfg.AgentGitHubAPIHourlyCap(name) + row := map[string]any{"agent": name, "cap": cap, "source": agentGitHubCapSource(cfg, name)} + if cfg != nil { + if a, ok := cfg.Agents[name]; ok && a.ReplicaOf != "" { + row["base_agent"] = a.ReplicaOf + } + } + if cap > 0 { + row["calls"] = calls[name] + } else { + row["calls"] = nil + } + rows = append(rows, row) + } + return map[string]any{ + "fleet_cap": cfg.FleetAgentGitHubAPIHourlyCap(), + "fleet_cap_set": cfg != nil && cfg.AgentsGitHubAPIHourlyCapSet, + "window_s": 3600, + "agents": rows, + } +} + +// agentGitHubCapSource says where an agent's cap comes from: "agent" (its own +// override), "base_agent" (a replica or review head inheriting its base's +// override), or "fleet" (agents.github_api_hourly_cap or the default). +func agentGitHubCapSource(cfg *config.Config, name string) string { + if cfg == nil { + return "fleet" + } + a, ok := cfg.Agents[name] + if !ok { + return "fleet" + } + if a.ReplicaOf != "" { + if base, ok := cfg.Agents[a.ReplicaOf]; ok && base.GitHubAPIHourlyCap != nil { + return "base_agent" + } + } + if a.GitHubAPIHourlyCap != nil { + if a.ReplicaOf != "" { + return "base_agent" + } + return "agent" + } + return "fleet" +} diff --git a/src/pkg/dashboard/settings_parity_allowlist.txt b/src/pkg/dashboard/settings_parity_allowlist.txt index ea9cb66fc2..8885bb10cd 100644 --- a/src/pkg/dashboard/settings_parity_allowlist.txt +++ b/src/pkg/dashboard/settings_parity_allowlist.txt @@ -57,7 +57,6 @@ agents..display_name # TODO #11854 agents..emoji # TODO #11854 agents..enabled # TODO #11854 agents..explain_mode # TODO #11854 -agents..github_api_hourly_cap # TODO #11854 agents..id # TODO #11854 agents..include_repos # TODO #11854 agents..jev_mode # TODO #11854 @@ -155,7 +154,6 @@ agents.cli_memory.auto_memory # TODO #11854 agents.cli_memory.clear_on_kick # TODO #11854 agents.cli_memory.drain_on_write # TODO #11854 agents.cli_memory.drain_to_knowledge # TODO #11854 -agents.github_api_hourly_cap # TODO #11854 agents.session_recycle.enabled # TODO #11854 agents.session_recycle.max_age # TODO #11854 agents.session_recycle.max_rss_mb # TODO #11854 diff --git a/src/pkg/dashboard/static-src/js/config-agent.js b/src/pkg/dashboard/static-src/js/config-agent.js index a60bb81207..5c49cd1264 100644 --- a/src/pkg/dashboard/static-src/js/config-agent.js +++ b/src/pkg/dashboard/static-src/js/config-agent.js @@ -27,6 +27,30 @@ `; } + // The API proxy's GitHub API hourly cap for this agent + // (agents..github_api_hourly_cap). Blank inherits the fleet default + // (Settings → Forge App) and saves null; 0 removes the cap for this agent. + // A replica or review head is rebuilt from its base agent, so it shows the + // base agent's cap read-only and names where to change it. + function agentGitHubCapFieldHtml(g) { + const capLabel = n => Number(n) === 0 ? 'no cap' : String(n); + const fleet = g.githubApiHourlyCapFleet === undefined ? 300 : g.githubApiHourlyCapFleet; + const effective = g.githubApiHourlyCapEffective === undefined ? fleet : g.githubApiHourlyCapEffective; + const own = (g.githubApiHourlyCap === null || g.githubApiHourlyCap === undefined) ? '' : String(g.githubApiHourlyCap); + const tip = `How many charged GitHub API responses this agent may receive through the API proxy in a sliding hour before the proxy answers 429. Blank inherits the fleet default from Settings → Forge App; 0 removes the cap for this agent. Replicas and review heads of this agent use the same value. All agents draw on the one hourly bucket of the App installation, and the agent reserve floor still refuses agent reads when that bucket runs low. Applies without a restart.`; + const label = `GitHub API hourly cap ${yamlHint('agents..github_api_hourly_cap')} ${settingHelpMark('operator-reference/runtime-and-quota', 'github-api-quota', 'GitHub API hourly cap', tip)}`; + if (g.githubApiHourlyCapBase) { + return `
+ +
${esc(capLabel(effective))} — taken from ${esc(g.githubApiHourlyCapBase)}; change it there
+
`; + } + return `
+ + +
Now: ${esc(capLabel(effective))}. Blank inherits the fleet default; 0 = no cap.
+
`; + } function renderAgentGeneral(g) { const agentName = _configState.agent || ''; const isCreate = _configState.isCreate === true; @@ -229,6 +253,7 @@
${(g.repos && g.repos.length) ? `🎯 scoped to ${g.repos.length} repo(s)` : 'hive-wide — serves every repo'}${(g.projectRepos && g.projectRepos.length) ? ` · this hive watches: ${esc(g.projectRepos.join(', '))}` : ''}
+ ${isCreate ? '' : agentGitHubCapFieldHtml(g)}
diff --git a/src/pkg/dashboard/static-src/js/config-tabs.js b/src/pkg/dashboard/static-src/js/config-tabs.js index b12d8bc918..656b4f15d7 100644 --- a/src/pkg/dashboard/static-src/js/config-tabs.js +++ b/src/pkg/dashboard/static-src/js/config-tabs.js @@ -1873,6 +1873,17 @@
${help}${restart ? ' Requires restart.' : ' Applies without a restart.'}
`; + // agents.github_api_hourly_cap sits here beside the agent reserve floor + // but differs from the num() fields: blank means the default (the key + // is removed) and 0 is a real value, no cap. + const capKey = 'agent_github_api_hourly_cap'; + const capDefault = d.agent_github_api_hourly_cap || 300; + const fleetCapField = ` +
+ + +
Charged GitHub API calls each agent may make through the proxy per sliding hour. 0 = no cap; blank = default (${esc(String(capDefault))}). Applies without a restart.
+
`; const toggle = (key, label, path, help, restart, mark) => `
@@ -1895,6 +1906,9 @@ ${num('api_critical', 'API critical floor', 'github.api_critical', 250, 'Lower floor where Hive enters critical mode and stretches the eval interval further. Must be below the reserve. 0 uses the default (250).', true, settingHelpMark('operator-reference/sweeps-and-operations', 'github-api-budget-governor', 'API critical floor', 'Below this many remaining calls the governor also sheds unstick steps and stretches the eval interval 4x. Must stay below the reserve; 0 restores the default (250).'))} ${num('agent_reserve_floor', 'Agent reserve floor', 'github.agent_reserve_floor', d.agent_reserve_floor || 400, 'Agent reads through the API proxy get a 429 once the shared core bucket drops below this, so daemon merge and scan work is never starved.', true, settingHelpMark('operator-reference/runtime-and-quota', 'github-api-quota', 'Agent reserve floor', 'Protects the shared core bucket from agent traffic: once the latest rate-limit reading drops below the floor, agent reads through the API proxy are refused with a 429 until the bucket recovers. 0 uses the default.'))}
+
+ ${fleetCapField} +
${num('pr_detail_ttl_s', 'PR detail cache TTL (seconds)', 'github.pr_detail_ttl_s', d.pr_detail_ttl_s || 1800, 'Reuses a PR detail response while the PR list still reports the same head SHA and updated_at. 0 uses the default.', false, settingHelpMark('operator-reference/README', 'notable-fields', 'PR detail cache TTL', 'How long a GET /pulls/{number} detail response is reused while the cheap PR list still reports the same head SHA and updated_at and GitHub has resolved mergeable_state. 0 uses the default.'))} ${num('graphql_pr_batch_page_size', 'GraphQL PR batch page size', 'github.graphql_pr_batch_page_size', d.graphql_pr_batch_page_size || 50, 'Page size for the batched pullRequests query, 1–100. 0 uses the default.', false, settingHelpMark('operator-reference/README', 'notable-fields', 'GraphQL PR batch page size', 'Page size for the GraphQL pullRequests(first:) batch query. Values above GitHub's maximum of 100 are rejected; 0 uses the default.'))} @@ -1927,6 +1941,20 @@ if (isNaN(v) || v < 0) { showToast(key + ' must be a non-negative number', 'error'); return; } if (v !== (st[key] || 0)) body[key] = v; } + // Blank removes agents.github_api_hourly_cap (null), so the default + // applies; a number sets it, 0 included (no cap). + const capEl = document.getElementById('forge-gh-agent_github_api_hourly_cap'); + if (capEl) { + const raw = (capEl.value || '').trim(); + const wasSet = st.agent_github_api_hourly_cap_set === true; + if (raw === '') { + if (wasSet) body.agent_github_api_hourly_cap = null; + } else { + const v = Number(raw); + if (!Number.isInteger(v) || v < 0) { showToast('Agent GitHub API hourly cap must be a whole number of at least 0, or blank for the default', 'error'); return; } + if (!wasSet || v !== st.agent_github_api_hourly_cap) body.agent_github_api_hourly_cap = v; + } + } for (const key of ['graphql_pr_batch', 'app_authored_prs', 'app_signed_commits']) { const el = document.getElementById('forge-gh-' + key); if (!el) continue; diff --git a/src/pkg/dashboard/static-src/js/debug-logs.js b/src/pkg/dashboard/static-src/js/debug-logs.js index 54e66603c6..224c5818e5 100644 --- a/src/pkg/dashboard/static-src/js/debug-logs.js +++ b/src/pkg/dashboard/static-src/js/debug-logs.js @@ -132,6 +132,7 @@ core: 'Requests left in GitHub’s core REST rate limit for the current hour, out of the hourly limit for this credential (5,000 for a token; App installations may get more). GitHub resets the counter once an hour. The bar turns yellow at 25% free and red at 10%. Below the configured API reserve (default 800 requests) the governor enters conserve mode — it stretches the eval interval and skips optional steps such as recommendations and advisories; below the API critical floor (default 250) it stretches the interval further.', etag: 'Share of cacheable GitHub REST GETs answered 304 Not Modified because the hive re-sent a remembered ETag (If-None-Match). GitHub does not charge a 304 against the core rate limit, so a higher rate means more of the hive’s polling is free. Counted since the process started, not over the 1h window — a low rate right after a restart is normal while the cache warms. The 304 column below shows the same hits per caller.', etagFill: 'How full the ETag response cache is: remembered responses held, and gzip-compressed bytes against the configured cap (Settings → Forge App → ETag cache size, github.etag_cache_max_mb, default 128 MB). At the cap, each new store evicts the least recently used body, and a body evicted before its next poll costs a charged fetch instead of a free 304 — if the hit rate slides while this sits at the cap, raise the cap.', + agentCaps: 'Each agent may receive this many charged GitHub API responses through the API proxy in a sliding hour; past it the proxy answers 429 until the window frees up. Calls is what the proxy has counted against the cap in the last hour; an agent with no cap is not counted. The cap comes from the agent’s own setting (its General tab), its base agent for a replica or review head, or the fleet default (Settings → Forge App, agents.github_api_hourly_cap, default 300). Every agent draws on the one hourly bucket of the App installation, and below the agent reserve floor (github.agent_reserve_floor) agent reads are refused whatever their cap.', }; function ghEtagCacheFillText(etag) { etag = etag || {}; @@ -164,6 +165,20 @@ const endpoint = esc(c.endpoint || ''); return `${caller}${endpoint}${Number(c.charged) || 0}${Number(c.requests) || 0}${Number(c.not_modified) || 0}${Number(c.rate_limited) || 0}`; }).join(''); + // Per-agent proxy caps (ghRateLimits.agent_caps): the cap the proxy + // applies now, where it comes from, and the calls counted against it in + // the current hour. An uncapped agent has no window, so no count. + const agentCaps = ghRate.agent_caps || ghRate.agentCaps || {}; + const capText = n => Number(n) === 0 ? 'no cap' : String(Number(n) || 0); + const capFrom = r => r.source === 'agent' ? 'own setting' : (r.source === 'base_agent' ? 'from ' + (r.base_agent || 'base agent') : 'fleet default'); + const capRows = (agentCaps.agents || []).map(r => { + const agent = esc(r.agent || ''); + const cap = Number(r.cap) || 0; + const calls = Number(r.calls) || 0; + const used = cap > 0 ? Math.round((calls * 100) / cap) + '%' : '—'; + return `${agent}${esc(capFrom(r))}${cap > 0 ? calls : '—'}${esc(capText(cap))}${used}`; + }).join(''); + const fleetCap = agentCaps.fleet_cap === undefined ? 300 : agentCaps.fleet_cap; return `
GitHub API budget (1h)
Core remaining${ghApiBudgetHelpMark('core', 'Core remaining')}${core.remaining} / ${core.limit || '—'} (${core.remainingPct}% free)
@@ -178,6 +193,13 @@
+
Agent caps${ghApiBudgetHelpMark('agentCaps', 'Agent caps')}fleet default ${esc(capText(fleetCap))}
+
+ + + ${capRows || ''} +
AgentCap fromCallsCapUsed
No agents
+
`; } function debugQuotaWidgetHtml(data) { diff --git a/src/pkg/dashboard/static-src/js/shimmy-handlers.js b/src/pkg/dashboard/static-src/js/shimmy-handlers.js index 105c38898c..20b0a335f9 100644 --- a/src/pkg/dashboard/static-src/js/shimmy-handlers.js +++ b/src/pkg/dashboard/static-src/js/shimmy-handlers.js @@ -275,6 +275,8 @@ gh33: function (event, A) { markDirty('general','sortOrder',parseInt(this.value,10)); }, gh34: function (event, A) { markDirty('general','replicas',Math.max(1,Math.min(MAX_AGENT_REPLICAS,parseInt(this.value,10)||1)));this.value=Math.max(1,Math.min(MAX_AGENT_REPLICAS,parseInt(this.value,10)||1)); }, gh35: function (event, A) { markDirty('general','staleTimeout',cadenceFromHuman(this.value));this.value=cadenceToHuman(cadenceFromHuman(this.value)); }, + // GitHub API hourly cap (agents..github_api_hourly_cap): blank saves null, which inherits the fleet default; 0 = no cap. + githubApiCapChanged: function (event, A) { const raw=this.value.trim(); if(raw===''){markDirty('general','githubApiHourlyCap',null);return;} const n=Number(raw); if(!Number.isInteger(n)||n<0){showToast('GitHub API hourly cap must be a whole number of at least 0, or blank to inherit','error');return;} markDirty('general','githubApiHourlyCap',n); }, // Contributor-first grace (#16518): blank or unparseable saves 0, which clears it to the 10m default. contributorFirstGraceChanged: function (event, A) { const secs=cadenceFromHuman(this.value);markDirty('general','contributorFirstGrace',secs);this.value=secs>0?cadenceToHuman(secs):''; }, // Repo scope (#6204). An empty field is meaningful — it CLEARS the scope diff --git a/src/pkg/dashboard/static/index.html b/src/pkg/dashboard/static/index.html index f0837c9c4c..5bb33b3b78 100644 --- a/src/pkg/dashboard/static/index.html +++ b/src/pkg/dashboard/static/index.html @@ -51042,6 +51042,7 @@

GitHub Projects sync

core: 'Requests left in GitHub’s core REST rate limit for the current hour, out of the hourly limit for this credential (5,000 for a token; App installations may get more). GitHub resets the counter once an hour. The bar turns yellow at 25% free and red at 10%. Below the configured API reserve (default 800 requests) the governor enters conserve mode — it stretches the eval interval and skips optional steps such as recommendations and advisories; below the API critical floor (default 250) it stretches the interval further.', etag: 'Share of cacheable GitHub REST GETs answered 304 Not Modified because the hive re-sent a remembered ETag (If-None-Match). GitHub does not charge a 304 against the core rate limit, so a higher rate means more of the hive’s polling is free. Counted since the process started, not over the 1h window — a low rate right after a restart is normal while the cache warms. The 304 column below shows the same hits per caller.', etagFill: 'How full the ETag response cache is: remembered responses held, and gzip-compressed bytes against the configured cap (Settings → Forge App → ETag cache size, github.etag_cache_max_mb, default 128 MB). At the cap, each new store evicts the least recently used body, and a body evicted before its next poll costs a charged fetch instead of a free 304 — if the hit rate slides while this sits at the cap, raise the cap.', + agentCaps: 'Each agent may receive this many charged GitHub API responses through the API proxy in a sliding hour; past it the proxy answers 429 until the window frees up. Calls is what the proxy has counted against the cap in the last hour; an agent with no cap is not counted. The cap comes from the agent’s own setting (its General tab), its base agent for a replica or review head, or the fleet default (Settings → Forge App, agents.github_api_hourly_cap, default 300). Every agent draws on the one hourly bucket of the App installation, and below the agent reserve floor (github.agent_reserve_floor) agent reads are refused whatever their cap.', }; function ghEtagCacheFillText(etag) { etag = etag || {}; @@ -51074,6 +51075,20 @@

GitHub Projects sync

const endpoint = esc(c.endpoint || ''); return `${caller}${endpoint}${Number(c.charged) || 0}${Number(c.requests) || 0}${Number(c.not_modified) || 0}${Number(c.rate_limited) || 0}`; }).join(''); + // Per-agent proxy caps (ghRateLimits.agent_caps): the cap the proxy + // applies now, where it comes from, and the calls counted against it in + // the current hour. An uncapped agent has no window, so no count. + const agentCaps = ghRate.agent_caps || ghRate.agentCaps || {}; + const capText = n => Number(n) === 0 ? 'no cap' : String(Number(n) || 0); + const capFrom = r => r.source === 'agent' ? 'own setting' : (r.source === 'base_agent' ? 'from ' + (r.base_agent || 'base agent') : 'fleet default'); + const capRows = (agentCaps.agents || []).map(r => { + const agent = esc(r.agent || ''); + const cap = Number(r.cap) || 0; + const calls = Number(r.calls) || 0; + const used = cap > 0 ? Math.round((calls * 100) / cap) + '%' : '—'; + return `${agent}${esc(capFrom(r))}${cap > 0 ? calls : '—'}${esc(capText(cap))}${used}`; + }).join(''); + const fleetCap = agentCaps.fleet_cap === undefined ? 300 : agentCaps.fleet_cap; return `
GitHub API budget (1h)
Core remaining${ghApiBudgetHelpMark('core', 'Core remaining')}${core.remaining} / ${core.limit || '—'} (${core.remainingPct}% free)
@@ -51088,6 +51103,13 @@

GitHub Projects sync

+
Agent caps${ghApiBudgetHelpMark('agentCaps', 'Agent caps')}fleet default ${esc(capText(fleetCap))}
+
+ + + ${capRows || ''} +
AgentCap fromCallsCapUsed
No agents
+
`; } function debugQuotaWidgetHtml(data) { @@ -59407,6 +59429,17 @@

Configure LiteLLM

${help}${restart ? ' Requires restart.' : ' Applies without a restart.'}
`; + // agents.github_api_hourly_cap sits here beside the agent reserve floor + // but differs from the num() fields: blank means the default (the key + // is removed) and 0 is a real value, no cap. + const capKey = 'agent_github_api_hourly_cap'; + const capDefault = d.agent_github_api_hourly_cap || 300; + const fleetCapField = ` +
+ + +
Charged GitHub API calls each agent may make through the proxy per sliding hour. 0 = no cap; blank = default (${esc(String(capDefault))}). Applies without a restart.
+
`; const toggle = (key, label, path, help, restart, mark) => `
@@ -59429,6 +59462,9 @@

Configure LiteLLM

${num('api_critical', 'API critical floor', 'github.api_critical', 250, 'Lower floor where Hive enters critical mode and stretches the eval interval further. Must be below the reserve. 0 uses the default (250).', true, settingHelpMark('operator-reference/sweeps-and-operations', 'github-api-budget-governor', 'API critical floor', 'Below this many remaining calls the governor also sheds unstick steps and stretches the eval interval 4x. Must stay below the reserve; 0 restores the default (250).'))} ${num('agent_reserve_floor', 'Agent reserve floor', 'github.agent_reserve_floor', d.agent_reserve_floor || 400, 'Agent reads through the API proxy get a 429 once the shared core bucket drops below this, so daemon merge and scan work is never starved.', true, settingHelpMark('operator-reference/runtime-and-quota', 'github-api-quota', 'Agent reserve floor', 'Protects the shared core bucket from agent traffic: once the latest rate-limit reading drops below the floor, agent reads through the API proxy are refused with a 429 until the bucket recovers. 0 uses the default.'))}
+
+ ${fleetCapField} +
${num('pr_detail_ttl_s', 'PR detail cache TTL (seconds)', 'github.pr_detail_ttl_s', d.pr_detail_ttl_s || 1800, 'Reuses a PR detail response while the PR list still reports the same head SHA and updated_at. 0 uses the default.', false, settingHelpMark('operator-reference/README', 'notable-fields', 'PR detail cache TTL', 'How long a GET /pulls/{number} detail response is reused while the cheap PR list still reports the same head SHA and updated_at and GitHub has resolved mergeable_state. 0 uses the default.'))} ${num('graphql_pr_batch_page_size', 'GraphQL PR batch page size', 'github.graphql_pr_batch_page_size', d.graphql_pr_batch_page_size || 50, 'Page size for the batched pullRequests query, 1–100. 0 uses the default.', false, settingHelpMark('operator-reference/README', 'notable-fields', 'GraphQL PR batch page size', 'Page size for the GraphQL pullRequests(first:) batch query. Values above GitHub's maximum of 100 are rejected; 0 uses the default.'))} @@ -59461,6 +59497,20 @@

Configure LiteLLM

if (isNaN(v) || v < 0) { showToast(key + ' must be a non-negative number', 'error'); return; } if (v !== (st[key] || 0)) body[key] = v; } + // Blank removes agents.github_api_hourly_cap (null), so the default + // applies; a number sets it, 0 included (no cap). + const capEl = document.getElementById('forge-gh-agent_github_api_hourly_cap'); + if (capEl) { + const raw = (capEl.value || '').trim(); + const wasSet = st.agent_github_api_hourly_cap_set === true; + if (raw === '') { + if (wasSet) body.agent_github_api_hourly_cap = null; + } else { + const v = Number(raw); + if (!Number.isInteger(v) || v < 0) { showToast('Agent GitHub API hourly cap must be a whole number of at least 0, or blank for the default', 'error'); return; } + if (!wasSet || v !== st.agent_github_api_hourly_cap) body.agent_github_api_hourly_cap = v; + } + } for (const key of ['graphql_pr_batch', 'app_authored_prs', 'app_signed_commits']) { const el = document.getElementById('forge-gh-' + key); if (!el) continue; @@ -61429,6 +61479,30 @@

Hub settings

`; } + // The API proxy's GitHub API hourly cap for this agent + // (agents..github_api_hourly_cap). Blank inherits the fleet default + // (Settings → Forge App) and saves null; 0 removes the cap for this agent. + // A replica or review head is rebuilt from its base agent, so it shows the + // base agent's cap read-only and names where to change it. + function agentGitHubCapFieldHtml(g) { + const capLabel = n => Number(n) === 0 ? 'no cap' : String(n); + const fleet = g.githubApiHourlyCapFleet === undefined ? 300 : g.githubApiHourlyCapFleet; + const effective = g.githubApiHourlyCapEffective === undefined ? fleet : g.githubApiHourlyCapEffective; + const own = (g.githubApiHourlyCap === null || g.githubApiHourlyCap === undefined) ? '' : String(g.githubApiHourlyCap); + const tip = `How many charged GitHub API responses this agent may receive through the API proxy in a sliding hour before the proxy answers 429. Blank inherits the fleet default from Settings → Forge App; 0 removes the cap for this agent. Replicas and review heads of this agent use the same value. All agents draw on the one hourly bucket of the App installation, and the agent reserve floor still refuses agent reads when that bucket runs low. Applies without a restart.`; + const label = `GitHub API hourly cap ${yamlHint('agents..github_api_hourly_cap')} ${settingHelpMark('operator-reference/runtime-and-quota', 'github-api-quota', 'GitHub API hourly cap', tip)}`; + if (g.githubApiHourlyCapBase) { + return `
+ +
${esc(capLabel(effective))} — taken from ${esc(g.githubApiHourlyCapBase)}; change it there
+
`; + } + return `
+ + +
Now: ${esc(capLabel(effective))}. Blank inherits the fleet default; 0 = no cap.
+
`; + } function renderAgentGeneral(g) { const agentName = _configState.agent || ''; const isCreate = _configState.isCreate === true; @@ -61631,6 +61705,7 @@

Hub settings

${(g.repos && g.repos.length) ? `🎯 scoped to ${g.repos.length} repo(s)` : 'hive-wide — serves every repo'}${(g.projectRepos && g.projectRepos.length) ? ` · this hive watches: ${esc(g.projectRepos.join(', '))}` : ''}
+ ${isCreate ? '' : agentGitHubCapFieldHtml(g)}
@@ -74669,6 +74744,8 @@

${model.editIndex === null ? 'Add Event trigger' : 'Edit Event trigger'}

.github_api_hourly_cap): blank saves null, which inherits the fleet default; 0 = no cap. + githubApiCapChanged: function (event, A) { const raw=this.value.trim(); if(raw===''){markDirty('general','githubApiHourlyCap',null);return;} const n=Number(raw); if(!Number.isInteger(n)||n<0){showToast('GitHub API hourly cap must be a whole number of at least 0, or blank to inherit','error');return;} markDirty('general','githubApiHourlyCap',n); }, // Contributor-first grace (#16518): blank or unparseable saves 0, which clears it to the 10m default. contributorFirstGraceChanged: function (event, A) { const secs=cadenceFromHuman(this.value);markDirty('general','contributorFirstGrace',secs);this.value=secs>0?cadenceToHuman(secs):''; }, // Repo scope (#6204). An empty field is meaningful — it CLEARS the scope diff --git a/src/pkg/dashboard/status_builder.go b/src/pkg/dashboard/status_builder.go index e251eaa6a1..a49e526858 100644 --- a/src/pkg/dashboard/status_builder.go +++ b/src/pkg/dashboard/status_builder.go @@ -3238,6 +3238,9 @@ func buildGHRateLimits(ghClient *github.Client, ctx context.Context, cfg *config result["top_consumers"] = top } } + // Per-agent proxy caps and calls in the current hour (github_api_cap_view.go). + // Config and proxy only: present even before the first rate-limit reading. + result["agent_caps"] = buildAgentGitHubCaps(cfg) return result }