~ruther/agent-environments

6ae0ea389dadd61712693f2090dba54608d54e53 — František Boháček 2 months ago cc848b0
Add companions: every launcher of an environment callable from its sessions
5 files changed, 152 insertions(+), 4 deletions(-)

M README.md
M checks/default.nix
M lib/default.nix
A modules/companions.nix
M modules/launcher.nix
M README.md => README.md +1 -0
@@ 71,6 71,7 @@ Runnable examples: [`examples/basic`](examples/basic) and
| `skills.packages` | `mkSkill` packages to enable. Their skill dependencies are added automatically. |
| `modules.skills.<name>.enable` | Declared by `mkSkillModule`; adds that skill. |
| `launchers.<cmd>.{agent,id,stateDir}` | A command. `id` defaults to the command name; `stateDir` defaults to `~/.local/agent-environments/<id>`; it must be absolute or start with `~/`, and is taken literally. |
| `companions.enable` | Put every launcher of the environment on the agent's `PATH` (as companions, which cannot start companions themselves) and add a generated `companions` skill listing each one with its non-interactive call. Launchers export `AGENT_ENV_LAUNCHER` (their command name). |

## What a launcher does


M checks/default.nix => checks/default.nix +40 -0
@@ 15,6 15,7 @@ let
        echo "CLAUDE_CONFIG_DIR=''${CLAUDE_CONFIG_DIR:-}"
        echo "CODEX_HOME=''${CODEX_HOME:-}"
        echo "AGENT_ENV_LAUNCHER=''${AGENT_ENV_LAUNCHER:-}"
        echo "PATH=$PATH"
        if command -v hello >/dev/null; then echo "HELLO=yes"; else echo "HELLO=no"; fi
      } >"$log.env"
    '';


@@ 245,6 246,45 @@ in
      grep -qxF "CLAUDE_CONFIG_DIR=$HOME/odd \"dir\"/\$(touch injected)" $STUB_LOG.env || fail "$(cat $STUB_LOG.env)"
    '';

  # With companions, a session can start every profile of its environment, but a
  # companion session cannot start further companions.
  companions =
    let
      env = mkEnvironment {
        inherit pkgs;
        modules = [
          stubs
          {
            claude.enable = true;
            codex.enable = true;
            companions.enable = true;
            launchers = {
              claude-work.agent = "claude";
              codex-pers.agent = "codex";
            };
          }
        ];
      };
    in
    runTest "companions" ''
      ${env.launchers.claude-work}/bin/claude-work
      grep -qxF "AGENT_ENV_LAUNCHER=claude-work" $STUB_LOG.env || fail "AGENT_ENV_LAUNCHER not exported"
      session_path=$(sed -n 's/^PATH=//p' $STUB_LOG.env)
      companion=$(PATH=$session_path command -v codex-pers) || fail "codex-pers not on the session PATH"
      PATH=$session_path command -v claude-work >/dev/null || fail "own profile not available as companion"

      skill=$HOME/.local/agent-environments/claude-work/skills/companions/SKILL.md
      grep -qF '`codex-pers exec --sandbox read-only' $skill || fail "codex call missing from skill"
      grep -qF '`claude-work -p "<prompt>"' $skill || fail "claude call missing from skill"

      # The companion runs with its own login and without companions of its own.
      $companion exec x
      grep -qxF "CODEX_HOME=$HOME/.local/agent-environments/codex-pers" $STUB_LOG.env || fail "companion login"
      grep -qxF "AGENT_ENV_LAUNCHER=codex-pers" $STUB_LOG.env || fail "companion name"
      nested_path=$(sed -n 's/^PATH=//p' $STUB_LOG.env)
      if PATH=$nested_path command -v claude-work >/dev/null; then fail "companion can start companions"; fi
    '';

  # The stubs only prove what the launcher hands over; this proves the real Codex
  # reads it: AGENTS.md, linked skills and the settings profile all reach the prompt.
  codex-integration =

M lib/default.nix => lib/default.nix +2 -1
@@ 47,10 47,11 @@ in
          ../modules/claude.nix
          ../modules/codex.nix
          ../modules/launcher.nix
          ../modules/companions.nix
          {
            _module.args = {
              inherit pkgs;
              agentEnvLib = { inherit safeName; };
              agentEnvLib = { inherit safeName mkSkill; };
            };
            claude.package = lib.mkDefault (defaultPackages pkgs.stdenv.hostPlatform.system).claude;
            codex.package = lib.mkDefault (defaultPackages pkgs.stdenv.hostPlatform.system).codex;

A modules/companions.nix => modules/companions.nix +94 -0
@@ 0,0 1,94 @@
{
  config,
  lib,
  pkgs,
  agentEnvLib,
  ...
}:
let
  agentNames = {
    claude = "Claude Code";
    codex = "Codex";
  };
  call = {
    claude = name: ''`${name} -p "<prompt>" < /dev/null`'';
    codex =
      name:
      ''`${name} exec --sandbox read-only --skip-git-repo-check -o <answer-file> "<prompt>" < /dev/null`'';
  };
  rows = lib.concatStrings (
    lib.mapAttrsToList (
      name: l: "| `${name}` | ${agentNames.${l.agent}} | `${l.id}` | ${call.${l.agent} name} |\n"
    ) config.launchers
  );

  skill = agentEnvLib.mkSkill {
    inherit pkgs;
    name = "companions";
    src = pkgs.writeTextDir "SKILL.md" ''
      ---
      name: companions
      description: Use when you need a companion agent - a second opinion, a reviewer, a delegated task - when the user names one or a fallback order ("use claude work as your companion, fall back to codex work"), or when a companion run hits a usage limit.
      ---

      # Companion agents

      Each command below starts another agent under its own login (profile) and
      prints its answer. `$AGENT_ENV_LAUNCHER` names the profile you are running
      as. Run companions unsandboxed: they need their own state dir and the network.

      | Command | Agent | Profile | Non-interactive call |
      |---|---|---|---|
      ${rows}
      ## Choosing a companion

      1. The user named companions or an order ("claude work, then codex work"):
         use exactly that, in that order.
      2. Otherwise: the other agent family than yours. Among those, prefer the one
         on the same account as yours (compare the command names); else the first
         listed.

      ## Calling

      - **Claude Code** (`-p`): prints the final answer on stdout. Tools that need
        approval are refused in this mode, so it is effectively read-only. Allow
        specific commands with `--allowedTools 'Bash(git diff:*)'` when needed.
      - **Codex** (`exec`): the final answer goes to `-o <answer-file>`, the
        transcript to stderr. Keep `< /dev/null`: with stdin open it waits forever
        ("Reading additional input from stdin...").
      - Long runs: start them in the background, redirect stderr to a log, and
        read the answer when the command exits.

      ## Usage limits and fallback

      A companion is limited when it exits non-zero and its output talks about a
      usage or rate limit ("usage limit", "limit reached", "hit your limit").
      Then:

      1. Record which companion was limited, and the reset time if it says one.
      2. Send the same prompt to the next companion in the order; if there is no
         order, use the next choice from "Choosing a companion".
      3. Do not retry a limited companion in the same task unless its reset time
         has passed.
      4. When every companion is limited, say so and follow the calling skill's
         rule for an unreachable companion.

      Always report which companion actually answered.

      ## Nesting

      A companion session does not have these commands on its `PATH`. If
      `command -v <command>` finds nothing, you are running as a companion
      yourself: do not start further companions.
    '';
  };
in
{
  options.companions.enable = lib.mkEnableOption ''
    companions: put every launcher of this environment on the agent's PATH (without
    companions of their own) and add a generated `companions` skill describing them'';

  config = lib.mkIf config.companions.enable {
    skills.packages = [ skill ];
  };
}

M modules/launcher.nix => modules/launcher.nix +15 -3
@@ 37,11 37,13 @@ let
      lib.escapeShellArg dir;

  mkLauncher =
    name: launcher:
    companionBin: name: launcher:
    let
      agent = build.agents.${launcher.agent};
      links = managedLinks agent;
      runtimePath = lib.makeBinPath build.skillRuntimeInputs;
      runtimePath = lib.makeBinPath (
        lib.optional (companionBin != null) companionBin ++ build.skillRuntimeInputs
      );
    in
    pkgs.writeShellApplication {
      inherit name;


@@ 147,7 149,17 @@ in

  config = {
    build = {
      launchers = lib.mapAttrs mkLauncher config.launchers;
      # Companions on PATH are launchers without companions of their own: a store
      # path cannot contain itself, and companions should not start companions.
      launchers = lib.mapAttrs (mkLauncher (
        if config.companions.enable then
          pkgs.symlinkJoin {
            name = "agent-companions";
            paths = lib.attrValues (lib.mapAttrs (mkLauncher null) config.launchers);
          }
        else
          null
      )) config.launchers;
      package = pkgs.symlinkJoin {
        name = "agent-environment";
        paths = lib.attrValues config.build.launchers;