Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agentctl/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ agents. The Rust workspace version is a build detail and is not tracked here.

### Added

- `agentctl setup` takes you from nothing to a running agent: from a standard agent agentctl carries, or from your team's agent on disk or on GitHub. It fills in recommended answers and shows them before it creates anything, and asks each question only if you choose to change something: a name, a size that fits your machine, which harnesses the agent has and the default one, the folders on your machine to share with the agent, a GitHub token and any other values the manifest declares. It signs you in to Claude Code, Codex and GitHub in a short-lived sandbox, so this machine needs none of them, or uses a harness login agentctl holds already, or the GitHub account another agent uses, if you say so, writes `~/agents/<name>/`, creates the agent, sets up SSH and opens it in VS Code or Zed. Run again, it offers the agents in `~/agents` first, to connect, sign in or replace; replacing asks first, and removes the old folder and agent before the new one takes its place. When a standard agent's image cannot be pulled, such as before it is published, setup offers to build it on your machine from the Dockerfile agentctl carries, and `--build-image` always does. You choose by number or with the arrow keys and go back with Esc. Every question has a flag for scripts, setup ends by printing the command that repeats it without questions, and the installers start it after a first install unless `AGENT_SETUP=0`.
- `agentctl setup` takes you from nothing to a running agent: from a standard agent agentctl carries, or from your team's agent on disk or on GitHub. It fills in recommended answers and shows them before it creates anything, and asks each question only if you choose to change something: a name, a size that fits your machine, which harnesses the agent has and the default one, the folders on your machine to share with the agent, a GitHub token and any other values the manifest declares. It signs you in to Claude Code, Codex and GitHub in a short-lived sandbox, so this machine needs none of them, or uses a harness login agentctl holds already, or the GitHub account another agent uses, if you say so, writes `~/agents/<name>/`, creates the agent, sets up SSH and opens it in VS Code or Zed. Run again, it offers the agents in `~/agents` first, to connect, sign in, update from where they came from, keeping their disk and sessions where the change allows, or replace; replacing asks first, and removes the old folder and agent before the new one takes its place. When a standard agent's image cannot be pulled, such as before it is published, setup offers to build it on your machine from the Dockerfile agentctl carries, and `--build-image` always does. You choose by number or with the arrow keys and go back with Esc. Every question has a flag for scripts, setup ends by printing the command that repeats it without questions, and the installers start it after a first install unless `AGENT_SETUP=0`.
- `optional: true` in `spec.instructions` and `spec.skills` leaves out a source that does not exist, so a committed manifest can name your own files, such as `~/agent-instructions/everywhere.md`. `agentctl describe` names what was left out, and the agent gets it once it exists. ([#331](https://github.com/digdir/digdir-agents/issues/331))
- Your terminal tab is named after the agent and session you are attached to, such as `builder/b1`, and reads `agentctl` again when you return to `agentctl tui`. ([#343](https://github.com/digdir/digdir-agents/pull/343))
- `agentctl get agents --watch -o json` prints the agents as one JSON line, then again each time they change, so a tool can follow them without polling. It exits once an `agentd` upgrade makes it incompatible. `get sessions --watch` works the same way. ([#349](https://github.com/digdir/digdir-agents/issues/349))
Expand Down
11 changes: 9 additions & 2 deletions agentctl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,15 @@ it later.
setup offers the account another agent in `~/agents` uses (`--github existing`), after checking its token still
works.
- **Agents you set up before** are offered first when you run setup again: connect a running one to an editor, sign
in to its harnesses, or replace it with a new definition, which setup asks about first. For a stopped agent or one
that is not ready, setup names the `agentctl` command that helps.
in to its harnesses, update it, or replace it with a new definition, which setup asks about first. For a stopped
agent or one that is not ready, setup names the `agentctl` command that helps.
- **Updating** fetches an agent's definition again from where setup got it, such as after its repository improved it,
and applies it with the agent's disk, sessions and `.env` kept (`--existing <name> --action update`). Your answers
for its name, size, harnesses and shared folders carry over, so what you left out stays out while what the source
adds comes in. The review lists the files that change first, and anything else you edited in its `agent.yaml` gives
way to the source. An existing agent cannot change some fields, such as its image or the list of skills it reads, so
an update that changes one creates the agent again, deleting its disk. Setup asks first, and a script passes
`--replace`.
- **Editors:** setup opens the agent in VS Code or Zed. Other apps that work over SSH, such as JetBrains Gateway and
the Claude desktop app's Code tab, connect to the SSH host `agentctl-<name>` that setup names, in the folder it
names. The Claude desktop app copies its own Claude sign-in into the agent, while sessions agentctl starts never let
Expand Down
41 changes: 28 additions & 13 deletions agentctl/src/bin/agentctl/setup/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ use super::answers::{Answers, Choice};
use super::sign_in::login_command;
use super::sizing::Whole;
use super::{
BrowserSignIn, Draft, SetupArguments, bundle, command_line, env_file, existing, github, ide, image, repeat, staging,
BrowserSignIn, Draft, SetupArguments, bundle, command_line, env_file, existing, github, ide, image, repeat,
staging, update,
};
use crate::{CommandError, CommandResult};
use agent::local::home::ControlPlaneHome;
Expand Down Expand Up @@ -251,19 +252,33 @@ pub(super) fn write_folder(draft: &mut Draft, folder: &Path) -> CommandResult<Pa
return Err(CommandError::Message(format!("{} already exists", folder.display())));
}
std::fs::create_dir_all(folder).map_err(Error::from)?;
let written = (|| {
let mut agent = draft.agent.clone();
bundle_into(&mut agent, draft, folder)?;
draft.agent = agent;
let path = folder.join(manifest::MANIFEST_FILE);
std::fs::write(&path, manifest_text(&draft.agent, draft)?)?;
env_file::write(folder, &draft.access.values)?;
Ok::<_, Error>(path)
})();
if written.is_err() {
std::fs::remove_dir_all(folder).ok();
match write_contents(draft, folder) {
Ok(agent) => {
draft.agent = agent;
Ok(folder.join(manifest::MANIFEST_FILE))
}
Err(error) => {
std::fs::remove_dir_all(folder).ok();
Err(error.into())
}
}
}

/// Writes into `folder` what makes up the agent: its manifest, the files it reads, its `.env` and
/// where it came from. Returns the manifest written, whose paths point at the copies.
pub(super) fn write_contents(draft: &Draft, folder: &Path) -> Result<Agent, Error> {
let mut agent = draft.agent.clone();
bundle_into(&mut agent, draft, folder)?;
std::fs::write(folder.join(manifest::MANIFEST_FILE), manifest_text(&agent, draft)?)?;
env_file::write(folder, &draft.access.values)?;
if let Some(recorded) = &draft.source.recorded {
let record = update::Record {
source: recorded.clone(),
left_out: draft.left_out.clone(),
};
update::record(folder, &record)?;
}
Ok(written?)
Ok(agent)
}

/// Copies what `agent` reads into `folder`, and the sources of its image when it is built here.
Expand Down
73 changes: 55 additions & 18 deletions agentctl/src/bin/agentctl/setup/existing.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//! The agents setup made before, in `~/agents/<name>/`: finding them with their state, carrying
//! on with one, and replacing one with a new definition.
//! on with one, updating one from where it came from, and replacing one with a new definition.
//!
//! Replacing is done so that a failure or a change of mind leaves the old definition as it was:
//! the new folder is written in full first, and only then is the old agent deleted and its folder
Expand Down Expand Up @@ -33,6 +33,8 @@ pub(super) struct Existing {
pub(super) name: String,
/// What setup made it from, when setup made it.
pub(super) origin: Option<String>,
/// Where setup can fetch it again, when setup recorded that.
pub(super) recorded: Option<super::update::Record>,
pub(super) state: State,
}

Expand Down Expand Up @@ -114,6 +116,7 @@ pub(super) async fn scan(client: Option<&Client>, root: &Path, agents: &[Agent])
}
};
found.push(Existing {
recorded: super::update::recorded(&folder),
folder,
name,
origin,
Expand Down Expand Up @@ -152,6 +155,8 @@ pub(crate) enum ExistingAction {
Connect,
/// Sign in to its harnesses.
SignIn,
/// Update it from the definition it came from, keeping its disk and sessions.
Update,
/// Replace it with a new agent definition.
Replace,
}
Expand All @@ -168,6 +173,7 @@ pub(super) enum Proceed {
pub(super) struct Context<'a> {
pub(super) arguments: &'a SetupArguments,
pub(super) answers: &'a Answers,
pub(super) catalog: &'a agent::catalog::Catalog,
pub(super) client: &'a Client,
pub(super) home: &'a agent::local::home::ControlPlaneHome,
pub(super) staging: &'a super::staging::Staging,
Expand Down Expand Up @@ -204,7 +210,7 @@ pub(super) async fn proceed(context: &Context<'_>, existing: Existing) -> Comman
}
_ => false,
};
let offered = actions(&existing.state, sign_in);
let offered = actions(&existing.state, sign_in, existing.recorded.is_some());
let action = match context.arguments.action {
Some(action) if offered.iter().any(|(offered, _)| *offered == Some(action)) => action,
Some(action) => {
Expand Down Expand Up @@ -232,7 +238,8 @@ pub(super) async fn proceed(context: &Context<'_>, existing: Existing) -> Comman
action
}
};
if action != ExistingAction::Replace {
// Updating and replacing ask more before they do anything.
if !matches!(action, ExistingAction::Update | ExistingAction::Replace) {
context.answers.no_way_back();
}
let name = existing.name.as_str();
Expand All @@ -259,6 +266,7 @@ pub(super) async fn proceed(context: &Context<'_>, existing: Existing) -> Comman
.await?;
println!("Signed in; agent/{name} picks the logins up as it converges.");
}
ExistingAction::Update => Box::pin(super::update::run(context, &existing)).await?,
ExistingAction::Replace => return Ok(Proceed::Replace(existing)),
}
Ok(Proceed::Done)
Expand All @@ -285,15 +293,28 @@ fn remedy(existing: &Existing, cli: &str) -> Option<String> {
}

/// What setup offers for an agent in `state`: connecting a running one, signing in when
/// `sign_in` allows it, replacing it, and leaving it as it is.
fn actions(state: &State, sign_in: bool) -> Vec<(Option<ExistingAction>, &'static str)> {
/// `sign_in` allows it, updating it when it was `recorded` where it came from, replacing it, and
/// leaving it as it is.
fn actions(state: &State, sign_in: bool, recorded: bool) -> Vec<(Option<ExistingAction>, &'static str)> {
let mut actions = Vec::new();
if *state == State::Running {
actions.push((Some(ExistingAction::Connect), "Connect it to an editor on this machine"));
}
if sign_in {
actions.push((Some(ExistingAction::SignIn), "Sign in to its harnesses"));
}
// An update keeps what setup's questions decided, so it reads them from a manifest that
// resolves, and applies it to the agent of this folder.
let updatable = matches!(
state,
State::Running | State::Stopped | State::NotReady | State::NotCreated
);
if recorded && updatable {
actions.push((
Some(ExistingAction::Update),
"Update it from where it came from, keeping its disk and sessions",
));
}
actions.push((Some(ExistingAction::Replace), "Replace it with a new agent definition"));
actions.push((None, "Nothing, for now"));
actions
Expand Down Expand Up @@ -335,15 +356,20 @@ pub(super) async fn swap(
}
wait_until_deleted(client, name, timeout).await?;
}
std::fs::rename(&replaced.folder, backup).map_err(|error| {
CommandError::Message(format!("could not move {} aside: {error}", replaced.folder.display()))
})?;
if let Err(error) = move_folder(staged, &replaced.folder) {
std::fs::remove_dir_all(&replaced.folder).ok();
std::fs::rename(backup, &replaced.folder).ok();
exchange(&replaced.folder, staged, backup)
}

/// Moves `folder` aside to `backup` and the folder written at `staged` into its place. Should the
/// move fail, the old folder is put back.
pub(super) fn exchange(folder: &Path, staged: &Path, backup: &Path) -> CommandResult<()> {
std::fs::rename(folder, backup)
.map_err(|error| CommandError::Message(format!("could not move {} aside: {error}", folder.display())))?;
if let Err(error) = move_folder(staged, folder) {
std::fs::remove_dir_all(folder).ok();
std::fs::rename(backup, folder).ok();
return Err(CommandError::Message(format!(
"could not put the new definition in {}: {error}; the old one is back",
replaced.folder.display()
folder.display()
)));
}
Ok(())
Expand Down Expand Up @@ -397,22 +423,33 @@ mod tests {
use super::{ExistingAction, State, actions, move_folder, scan};

#[test]
fn a_running_agent_can_be_connected_and_any_replaced() {
let offered = |state: &State, sign_in| -> Vec<_> {
actions(state, sign_in).into_iter().map(|(action, _)| action).collect()
fn a_running_agent_can_be_connected_one_with_a_known_source_updated_and_any_replaced() {
let offered = |state: &State, sign_in, recorded| -> Vec<_> {
actions(state, sign_in, recorded)
.into_iter()
.map(|(action, _)| action)
.collect()
};
assert_eq!(
offered(&State::Running, true),
offered(&State::Running, true, true),
[
Some(ExistingAction::Connect),
Some(ExistingAction::SignIn),
Some(ExistingAction::Update),
Some(ExistingAction::Replace),
None
]
);
assert_eq!(offered(&State::Stopped, false), [Some(ExistingAction::Replace), None]);
assert_eq!(
offered(&State::Invalid("bad".into()), false),
offered(&State::Stopped, false, false),
[Some(ExistingAction::Replace), None]
);
assert_eq!(
offered(&State::NotCreated, false, true),
[Some(ExistingAction::Update), Some(ExistingAction::Replace), None]
);
assert_eq!(
offered(&State::Invalid("bad".into()), false, true),
[Some(ExistingAction::Replace), None]
);
}
Expand Down
7 changes: 7 additions & 0 deletions agentctl/src/bin/agentctl/setup/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub(crate) mod sign_in;
mod sizing;
mod source;
mod staging;
mod update;
mod values;

use std::{
Expand Down Expand Up @@ -221,6 +222,8 @@ struct Draft {
access: Access,
/// The published image the agent's image is built here in place of.
built_here: Option<String>,
/// What its source defines that setup left out, so an update leaves it out again.
left_out: update::LeftOut,
}

impl Draft {
Expand All @@ -233,6 +236,7 @@ impl Draft {
replace: None,
access: Access::default(),
built_here: None,
left_out: update::LeftOut::default(),
}
}
}
Expand All @@ -243,6 +247,8 @@ struct Source {
directory: PathBuf,
/// What the agent came from, named in the written manifest.
origin: String,
/// Where to fetch it again to update the agent from it.
recorded: Option<update::Recorded>,
/// The repository the manifest is in, whose checkout its relative bind mounts mount.
repository: Option<mounts::Repository>,
/// Whether it is a standard agent, which a mounted folder's own agents may replace.
Expand Down Expand Up @@ -332,6 +338,7 @@ async fn run_once(
let context = existing::Context {
arguments,
answers,
catalog,
client,
home,
staging: &staging,
Expand Down
18 changes: 12 additions & 6 deletions agentctl/src/bin/agentctl/setup/mounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,8 @@ fn listed(
}

/// Applies `shares` to `agent`: its repository's mounts share the checkout chosen, or are left out
/// without one, and the mounts left out are removed.
fn apply(agent: &mut Agent, repository: Option<&RepositoryShares>, shares: &Shares) {
/// without one, and the mounts left out are removed. Returns where the agent would have seen those.
fn apply(agent: &mut Agent, repository: Option<&RepositoryShares>, shares: &Shares) -> Vec<String> {
let mut removed = shares.left_out.clone();
for mount in repository
.map(|repository| repository.found.as_slice())
Expand All @@ -400,9 +400,15 @@ fn apply(agent: &mut Agent, repository: Option<&RepositoryShares>, shares: &Shar
}
removed.sort_unstable();
removed.dedup();
for index in removed.into_iter().rev() {
agent.spec.sandbox.mounts.remove(index);
}
let mut targets: Vec<String> = removed
.into_iter()
.rev()
.map(|index| match agent.spec.sandbox.mounts.remove(index) {
MountSpec::Bind { target, .. } | MountSpec::Tmpfs { target, .. } => target.as_str().to_owned(),
})
.collect();
targets.reverse();
targets
}

/// The folder `within` of `checkout`. Joining an empty path would add a trailing separator.
Expand Down Expand Up @@ -458,7 +464,7 @@ pub(super) fn choose_shared(
{
return Ok(Some((manifest, folder.clone())));
}
apply(&mut draft.agent, repository.as_ref(), &shares);
draft.left_out.shared = apply(&mut draft.agent, repository.as_ref(), &shares);
if let Some(folder) = &added {
mount_unless_mounted(draft, folder)?;
}
Expand Down
5 changes: 5 additions & 0 deletions agentctl/src/bin/agentctl/setup/shape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ pub(super) fn choose_harnesses(arguments: &SetupArguments, answers: &Answers, dr
"--harness {harness} must be one of the --harnesses the agent has"
)));
}
draft.left_out.harnesses = declared
.iter()
.copied()
.filter(|harness| !kept.contains(harness))
.collect();
let harnesses = &mut draft.agent.spec.harnesses;
harnesses.retain(|harness| kept.contains(&harness.kind));
let current = harnesses.iter().position(|harness| harness.default).unwrap_or_default();
Expand Down
Loading
Loading