git-ents.gitmain
⌘K
foforge
commit 82769e7
config: forge-wide agent provider + default model

Add non-secret agent_provider and agent_default_model fields to the refs/meta/config typed tree (kept in ents-gate for now; the move to ents-model stays deferred until config grows enough non-gate fields to migrate in one pass). The API token stays out of repo data entirely — it lives only in the GIT_ENTS_CREDENTIALS_FILE deployment seam.

Add git ents config {show,set} as the first production write path for refs/meta/config (mirroring account/members): set reads-merges-writes so narrowing one field never clobbers epoch/workers or the other agent field. Wire the web new-session form to fall back to the configured default model, keeping the compiled-in DEFAULT_MODEL as the floor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Joseph D. Carpinelli · 28 days ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/cli/git-ents/src/cli.rs @@ -102,6 +102,17 @@ #[facet(args::subcommand)] action: AccountAction, }, + /// Manage this repository's forge-wide configuration at + /// `refs/meta/config`: today, non-secret agent-runtime defaults (a + /// provider name, a default model id) alongside the gate's own + /// `epoch`/`workers` fields. The API token is never here; it lives in + /// the deployment-time credential seam + /// (`GIT_ENTS_CREDENTIALS_FILE`), never in a signed, replicated tree. + Config { + /// The config action to run. + #[facet(args::subcommand)] + action: ConfigAction, + }, /// Manage the configured effects at `refs/meta/effects/<name>` and run /// them locally. Effect { @@ -317,6 +328,32 @@ }, } +/// `git ents config` actions. +#[derive(Facet)] +#[repr(u8)] +pub enum ConfigAction { + /// Show this repository's current forge-wide configuration. + Show, + /// Narrow the agent-runtime defaults. Each flag is independent: omit + /// one to leave whatever it currently holds untouched rather than + /// clearing it. + Set { + /// The agent runtime's provider name (e.g. `anthropic`); + /// non-secret -- the API token itself is never a flag here, only + /// `git-ents`'s own `GIT_ENTS_CREDENTIALS_FILE` seam. + #[facet(args::named)] + agent_provider: Option<String>, + /// The agent runtime's default model id (e.g. + /// `claude-sonnet-5`), used whenever a session start omits its + /// own `--model`. + #[facet(args::named)] + agent_default_model: Option<String>, + /// Key to sign with; defaults to `user.signingkey`. + #[facet(args::named)] + key: Option<PathBuf>, + }, +} + /// `git ents effect` actions. #[derive(Facet)] #[repr(u8)]
crates/cli/git-ents/src/exe.rs @@ -8,8 +8,8 @@ )] use crate::cli::{ - AccountAction, AgentAction, Cli, CommentAction, EffectAction, HookAction, InboxAction, - IssueAction, MembersAction, RedactAction, ReviewAction, ToolchainAction, Top, + AccountAction, AgentAction, Cli, CommentAction, ConfigAction, EffectAction, HookAction, + InboxAction, IssueAction, MembersAction, RedactAction, ReviewAction, ToolchainAction, Top, }; use crate::commands; use crate::error::Result; @@ -69,6 +69,7 @@ } Top::Members { action } => run_members(action, out), Top::Account { action } => run_account(action, out), + Top::Config { action } => run_config(action, out), Top::Effect { action } => run_effect(action, out), Top::Toolchain { action } => run_toolchain(action, out), Top::Comment { action } => run_comment(action, out), @@ -172,6 +173,34 @@ Ok(()) } +fn run_config(action: ConfigAction, out: &mut impl std::io::Write) -> Result<()> { + let root = LocalRoot::discover(".")?; + match action { + ConfigAction::Show => { + let config = commands::config::show(&root)?; + let _ = writeln!( + out, + "agent_provider: {}", + config.agent_provider.as_deref().unwrap_or("(unset)") + ); + let _ = writeln!( + out, + "agent_default_model: {}", + config.agent_default_model.as_deref().unwrap_or("(unset)") + ); + } + ConfigAction::Set { + agent_provider, + agent_default_model, + key, + } => { + commands::config::set(&root, agent_provider, agent_default_model, key)?; + let _ = writeln!(out, "config updated"); + } + } + Ok(()) +} + fn run_effect(action: EffectAction, out: &mut impl std::io::Write) -> Result<()> { let root = LocalRoot::discover(".")?; match action {
crates/kernel/ents-gate/src/config.rs @@ -23,11 +23,14 @@ /// /// `model.sdoc` defines no Config entity yet, so this struct is the /// first (and currently only) definition of the config tree's shape; it -/// lives here rather than in `ents-model` because these are the only -/// fields any crate reads today. When configuration grows non-gate fields -/// (description, role rules, ...), the entity moves to `ents-model` and -/// that change is a storage migration like any other struct change -/// (`meta-ref.migration`). +/// lives here rather than in `ents-model` because these were, until now, +/// the only fields any crate reads. `agent_provider`/`agent_default_model` +/// (below) are the first fields this crate itself has no use for — they +/// exist purely for other crates (`ents-web`'s new-session form) to read +/// — but they stay here rather than moving to `ents-model` today: that +/// move is still deferred until configuration grows enough non-gate +/// fields to justify the storage migration (`meta-ref.migration`) in one +/// pass, rather than one field at a time. /// /// `epoch` is `None` on a config written before verification was turned /// on. Once it is `Some`, the gate applies the tip invariant to every @@ -61,6 +64,21 @@ /// narrowing lands. Empty by default: no worker is designated until a /// signed config write adds one. pub workers: Vec<MemberId>, + /// The agent runtime's provider name (e.g. `"anthropic"`), forge-wide; + /// `None` until a signed config write sets one. Non-secret by + /// construction — the credential that authenticates to the provider + /// never lives here or anywhere in `refs/meta/*`, only in the + /// deployment-time seam (`git-ents`'s `credentials.rs`, + /// `GIT_ENTS_CREDENTIALS_FILE`). The agent runtime that would consume + /// this is not built yet; storing and reading it is this field's + /// entire job for now. + pub agent_provider: Option<String>, + /// The agent runtime's default model id (e.g. `"claude-sonnet-5"`), + /// forge-wide; `None` until a signed config write sets one. Read as a + /// fallback ahead of any hardcoded default a caller carries — today, + /// `ents-web`'s new-agent-session form falls back to it before its own + /// compiled-in constant. + pub agent_default_model: Option<String>, } /// The config recorded by the tree of the commit at `oid`, or an @@ -119,3 +137,21 @@ ) -> Result<Vec<MemberId>> { Ok(current_config(refs, objects)?.workers) } + +/// The forge-wide agent provider name currently in force, read from +/// `refs/meta/config`'s tip; `None` when the config ref does not exist or +/// names no provider. Public (unlike [`designated_workers`], which only +/// this crate's gate consults) because the only consumer today, an agent +/// runtime, does not live in this crate. +pub fn agent_provider(refs: &dyn RefStoreRead, objects: &dyn Find) -> Result<Option<String>> { + Ok(current_config(refs, objects)?.agent_provider) +} + +/// The forge-wide default agent model id currently in force, read from +/// `refs/meta/config`'s tip; `None` when the config ref does not exist or +/// names no default — callers fall back to their own compiled-in default +/// (`ents-web`'s new-agent-session form, today) rather than treating +/// `None` as an error. +pub fn agent_default_model(refs: &dyn RefStoreRead, objects: &dyn Find) -> Result<Option<String>> { + Ok(current_config(refs, objects)?.agent_default_model) +}
crates/kernel/ents-gate/src/lib.rs @@ -155,7 +155,7 @@ mod verdict; mod verify; -pub use config::Config; +pub use config::{Config, agent_default_model, agent_provider}; pub use error::{Error, Result}; pub use verdict::{Admission, AdmissionKind, Refusal, Requirement, Verdict}; pub use verify::{Update, verify};
crates/kernel/ents-gate/tests/gate.rs @@ -856,6 +856,7 @@ &Config { epoch: Some(200), workers: vec![MemberId::new(worker_id)], + ..Config::default() }, Some(&f.admin), seconds,
crates/cli/ents-web/src/pages/agents.rs @@ -61,6 +61,7 @@ .map(|entry| (entry.refname, entry.error)) .collect(); let default_base = default_base_ref(&state); + let default_model = resolved_default_model(&state); Ok(super::layout_split( &super::RepoHeader::from_state(&state), &super::identity_label(&state), @@ -79,7 +80,7 @@ } div.card { div.card-header { "Start an Agent Session" } - (new_form(&session, &default_base)) + (new_form(&session, &default_base, &default_model)) } } }, @@ -696,18 +697,19 @@ /// The start-a-session form (`POST /agents`, `docs/agent-sessions-plan.adoc`'s /// Phase 3, "mobile-critical"): a prompt textarea, a base-branch text input /// pre-filled with `default_base` ([`default_base_ref`]), a model text -/// input pre-filled with [`DEFAULT_MODEL`], and a closed two-option review -/// policy picker defaulting to `manual` (mirrors -/// `crate::pages::commits::start_review_form`'s identical closed-verdict -/// picker) -- deliberately no toolchain or retry field: the plan's own -/// words are "complexity lives in the session doc, not the form." -fn new_form(session: &Session, default_base: &str) -> Markup { +/// input pre-filled with `default_model` ([`resolved_default_model`]), and +/// a closed two-option review policy picker defaulting to `manual` +/// (mirrors `crate::pages::commits::start_review_form`'s identical +/// closed-verdict picker) -- deliberately no toolchain or retry field: the +/// plan's own words are "complexity lives in the session doc, not the +/// form." +fn new_form(session: &Session, default_base: &str, default_model: &str) -> Markup { html! { form method="post" action="/agents" { (super::csrf_input(session)) label { "prompt" textarea name="prompt" {} } label { "base branch" input type="text" name="base_ref" value=(default_base); } - label { "model" input type="text" name="model" value=(DEFAULT_MODEL); } + label { "model" input type="text" name="model" value=(default_model); } div { p.muted { "review policy" } div.picker { @@ -737,11 +739,26 @@ } } -/// The model id [`new_form`] pre-fills and [`NewForm::model`] defaults to -/// when a submission omits the field entirely -- the same default id this -/// codebase's own fixtures and `git-ents::agent_worker` tests already use. +/// The model id [`resolved_default_model`] falls back to when +/// `refs/meta/config` names no `agent_default_model` (or does not exist +/// yet) -- the same default id this codebase's own fixtures and +/// `git-ents::agent_worker` tests already use. const DEFAULT_MODEL: &str = "claude-sonnet-5"; +/// The model id [`new_form`] pre-fills and [`NewForm::model`] falls back to +/// when a submission omits the field entirely: `refs/meta/config`'s +/// `agent_default_model` (`ents_gate::agent_default_model`) when a signed +/// config write has set one, else [`DEFAULT_MODEL`] -- a forge-wide +/// default lets an operator change what every new session starts at +/// without touching this crate's own compiled-in fallback, which stays as +/// the floor for a repository that has never configured one. +fn resolved_default_model<O: Find>(state: &AppState<O>) -> String { + ents_gate::agent_default_model(state.refs.as_ref(), &*state.objects()) + .ok() + .flatten() + .unwrap_or_else(|| DEFAULT_MODEL.to_owned()) +} + /// The base ref [`new_form`] pre-fills: `refs/heads/<branch>` for the /// served repository's own current `HEAD` branch /// ([`super::RepoHeader::from_state`]), or plain `HEAD` when that cannot @@ -782,10 +799,11 @@ /// [`default_base_ref`]). #[serde(default = "default_base_ref_field")] base_ref: String, - /// The model id the run executes against; defaults to - /// [`DEFAULT_MODEL`]. - #[serde(default = "default_model_field")] - model: String, + /// The model id the run executes against; `None` when a submission + /// omits the field entirely, resolved by [`resolved_default_model`] + /// (see [`create`]). + #[serde(default)] + model: Option<String>, /// The session's initially resolved review policy: `auto` or `manual`; /// defaults to `manual` (see [`default_review_policy`]). #[serde(default = "default_review_policy")] @@ -794,11 +812,6 @@ csrf: String, } -/// [`NewForm::model`]'s serde default -- see [`DEFAULT_MODEL`]. -fn default_model_field() -> String { - DEFAULT_MODEL.to_owned() -} - /// `POST /agents`: start an agent session owned by the current signing /// identity's resolved member (`ents_forge::agent::new`), signed /// (`roots.web-signing`) on behalf of the current session @@ -824,10 +837,11 @@ super::require_csrf(&session, &form.csrf)?; let member = session_owner(&state); let identity = state.identity.as_ref(); + let model = form.model.unwrap_or_else(|| resolved_default_model(&state)); let new = NewAgentSession { member, prompt: form.prompt, - model: form.model, + model, toolchains: Vec::new(), base_ref: form.base_ref, review_policy: form
crates/cli/git-ents/src/commands/mod.rs @@ -12,6 +12,7 @@ pub mod agent; pub mod bootstrap; pub mod comment; +pub mod config; pub mod effect; pub mod inbox; pub mod issue;
crates/cli/git-ents/src/commands/config.rs @@ -1,0 +1,96 @@ +//! `git ents config`: forge-wide, non-secret agent-runtime defaults +//! (provider name, default model) recorded in `refs/meta/config` alongside +//! the gate's own `epoch`/`workers` fields (`ents_gate::Config`). +//! +//! The API token is deliberately not here and never will be: it lives only +//! in the deployment-time credential seam (`crate::credentials`, +//! `GIT_ENTS_CREDENTIALS_FILE`), never in a signed, replicated, +//! multi-reader tree. + +use ents_gate::Config; +use ents_model::namespace; +use ents_receive::{Identity, propose_entity}; +use gix_ref_store::RefStoreRead; + +use super::{actor, signer}; +use crate::error::Result; +use crate::mutate::outcome_to_result; +use crate::root::LocalRoot; + +/// `git ents config show`: this repository's current forge-wide +/// configuration -- [`Config::default`] (every field unset) when +/// `refs/meta/config` has no tip yet, the same "absent means unconfigured" +/// reading every `ents_gate::config` reader already gives. +/// +/// # Errors +/// +/// Propagates a ref-store or object read failure, or an unreadable config +/// tree. +pub fn show(root: &LocalRoot) -> Result<Config> { + #[expect( + clippy::expect_used, + clippy::unwrap_in_result, + reason = "CONFIG_REF is a fixed, compile-time-known-valid refname literal" + )] + let name: gix::refs::FullName = namespace::CONFIG_REF + .try_into() + .expect("fixed, valid refname"); + let Some(tip) = root.refs.get(name.as_ref())? else { + return Ok(Config::default()); + }; + let tree = super::commit_tree(&root.objects, tip)?; + Ok(facet_git_tree::deserialize::<Config>(&tree, &root.objects)?) +} + +/// `git ents config set`: narrow the agent-runtime defaults. Each argument +/// is an independent optional narrowing -- omit one to leave whatever it +/// currently holds untouched, rather than resetting it to `None`. Reads +/// the current config first and writes the merged whole back, so a `set` +/// naming only `agent_provider` cannot clobber an `agent_default_model` +/// set earlier, or the gate's own `epoch`/`workers`. +/// +/// # Errors +/// +/// Propagates a ref-store/object read failure or a signing failure; +/// otherwise see [`crate::mutate::outcome_to_result`] for how a reached +/// refusal renders. +pub fn set( + root: &LocalRoot, + agent_provider: Option<String>, + agent_default_model: Option<String>, + key: Option<std::path::PathBuf>, +) -> Result<()> { + let signer = signer(root, key)?; + let mut config = show(root)?; + if let Some(provider) = agent_provider { + config.agent_provider = Some(provider); + } + if let Some(model) = agent_default_model { + config.agent_default_model = Some(model); + } + #[expect( + clippy::expect_used, + clippy::unwrap_in_result, + reason = "CONFIG_REF is a fixed, compile-time-known-valid refname literal" + )] + let name: gix::refs::FullName = namespace::CONFIG_REF + .try_into() + .expect("fixed, valid refname"); + let identity = Identity { + actor: actor(&signer), + author: None, + sign: &|payload| signer.sign(payload), + }; + let outcome = propose_entity( + &root.refs, + &root.objects, + &root.events, + name, + &config, + &identity, + "Set agent config", + root.mode(), + )?; + outcome_to_result(outcome, None)?; + Ok(()) +}
crates/cli/git-ents/tests/config.rs @@ -1,0 +1,65 @@ +//! Integration coverage for `git ents config` against a real local +//! composition root (`roots.local`): narrowing the agent-runtime defaults +//! at `refs/meta/config` without disturbing fields `set` was not told to +//! touch, and reading an unconfigured repository back as +//! `ents_gate::Config::default()`. + +#![allow(clippy::expect_used, reason = "integration test")] + +mod common; + +use ents_gate::Config; +use git_ents::commands::config; +use git_ents::root::LocalRoot; + +/// A repository that has never had `refs/meta/config` written reads back +/// as every field unset -- the same "absent means unconfigured" reading +/// `ents_gate::config`'s own readers give, and what lets an old config +/// (predating `agent_provider`/`agent_default_model`) keep parsing. +// @relation(roots.local, scope=function, role=Verifies) +#[test] +fn absent_config_reads_as_default() { + let fixture = common::Fixture::new(1); + let root = LocalRoot::open(fixture.path()).expect("opens"); + + let config = config::show(&root).expect("shows"); + assert_eq!(config, Config::default()); +} + +/// `set` narrows one field at a time: a later call naming only +/// `agent_default_model` must not clobber an `agent_provider` an earlier +/// call set, and neither call ever touches `workers`/`epoch` (unset by +/// either call). +// @relation(roots.local, scope=function, role=Verifies) +#[test] +fn set_narrows_without_clobbering_other_fields() { + let fixture = common::Fixture::new(1); + let root = LocalRoot::open(fixture.path()).expect("opens"); + + config::set( + &root, + Some("anthropic".to_owned()), + None, + Some(fixture.key_path.clone()), + ) + .expect("sets provider"); + let config = config::show(&root).expect("shows"); + assert_eq!(config.agent_provider.as_deref(), Some("anthropic")); + assert_eq!(config.agent_default_model, None); + assert!(config.workers.is_empty()); + assert_eq!(config.epoch, None); + + config::set( + &root, + None, + Some("claude-sonnet-5".to_owned()), + Some(fixture.key_path.clone()), + ) + .expect("sets default model"); + let config = config::show(&root).expect("shows"); + assert_eq!(config.agent_provider.as_deref(), Some("anthropic")); + assert_eq!( + config.agent_default_model.as_deref(), + Some("claude-sonnet-5") + ); +}