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
147 changes: 147 additions & 0 deletions apps/minds/imbue/minds/cli/update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""CLI command for updating an existing mind with the latest parent code.

The ``mind update <agent-name>`` command:

1. Stops the mind (via ``mng stop``)
2. Fetches and merges the latest code from the parent repository
3. Updates all vendored git subtrees
4. Starts the mind back up (via ``mng start``)
"""

from pathlib import Path

import click
from loguru import logger
from pydantic import Field

from imbue.concurrency_group.concurrency_group import ConcurrencyGroup
from imbue.imbue_common.frozen_model import FrozenModel
from imbue.minds.config.data_types import MNG_BINARY
from imbue.minds.config.data_types import parse_agents_from_mng_output
from imbue.minds.errors import MindError
from imbue.minds.errors import MngCommandError
from imbue.minds.forwarding_server.agent_creator import load_creation_settings
from imbue.minds.forwarding_server.parent_tracking import fetch_and_merge_parent
from imbue.minds.forwarding_server.parent_tracking import read_parent_info
from imbue.minds.forwarding_server.vendor_mng import default_vendor_configs
from imbue.minds.forwarding_server.vendor_mng import find_mng_repo_root
from imbue.minds.forwarding_server.vendor_mng import update_vendor_repos
from imbue.mng.primitives import AgentId


class MindAgentRecord(FrozenModel):
"""Essential fields from a mind agent's ``mng list`` JSON record.

Validated on construction so callers get a clear error if required
fields are missing from the mng output.
"""

agent_id: AgentId = Field(description="The agent's unique identifier")
work_dir: Path = Field(description="Absolute path to the agent's working directory")


def find_mind_agent(agent_name: str) -> MindAgentRecord:
"""Find a mind agent by name using ``mng list``.

Searches for agents whose name matches ``agent_name`` (the agent name
is set to the mind name during creation, so each mind has a unique name).
Returns a validated MindAgentRecord with the agent's ID and work directory.

Raises MindError if the agent cannot be found or the record is malformed.
"""
cg = ConcurrencyGroup(name="mng-list")
with cg:
result = cg.run_process_to_completion(
command=[
MNG_BINARY,
"list",
"--include",
'name == "{}"'.format(agent_name),
"--format=json",
],
is_checked_after=False,
)
if result.returncode != 0:
raise MindError(
"Failed to list agents: {}".format(
result.stderr.strip() if result.stderr.strip() else result.stdout.strip()
)
)

agents = parse_agents_from_mng_output(result.stdout)
if not agents:
raise MindError("No mind found with name '{}'".format(agent_name))

return parse_mind_agent_record(agents[0], agent_name)


def parse_mind_agent_record(raw: dict[str, object], agent_name: str) -> MindAgentRecord:
"""Parse a raw agent dict from ``mng list`` JSON into a MindAgentRecord.

Validates that the required ``id`` and ``work_dir`` fields are present.
Raises MindError if either field is missing.
"""
raw_id = raw.get("id")
raw_work_dir = raw.get("work_dir")
if raw_id is None or raw_work_dir is None:
raise MindError(
"Agent record for '{}' is missing required fields (id={}, work_dir={})".format(
agent_name, raw_id, raw_work_dir
)
)

return MindAgentRecord(agent_id=AgentId(str(raw_id)), work_dir=Path(str(raw_work_dir)))


def _run_mng_command(verb: str, agent_id: AgentId) -> None:
"""Run an ``mng <verb> <agent-id>`` command.

Raises MngCommandError if the command fails.
"""
logger.info("Running mng {} {}...", verb, agent_id)
cg = ConcurrencyGroup(name="mng-{}".format(verb))
with cg:
result = cg.run_process_to_completion(
command=[MNG_BINARY, verb, str(agent_id)],
is_checked_after=False,
)
if result.returncode != 0:
raise MngCommandError(
"mng {} failed (exit code {}):\n{}".format(
verb,
result.returncode,
result.stderr.strip() if result.stderr.strip() else result.stdout.strip(),
)
)


@click.command()
@click.argument("agent_name")
def update(agent_name: str) -> None:
"""Update a mind with the latest code from its parent repository.

Stops the mind, merges the latest parent code, updates vendored
subtrees, and starts the mind back up.
"""
logger.info("Looking up mind '{}'...", agent_name)
record = find_mind_agent(agent_name)

logger.info("Found mind '{}' (agent_id={}, work_dir={})", agent_name, record.agent_id, record.work_dir)

_run_mng_command("stop", record.agent_id)

logger.info("Merging latest code from parent repository...")
parent_info = read_parent_info(record.work_dir)
new_hash = fetch_and_merge_parent(record.work_dir, parent_info)
logger.info("Merged parent changes (new hash: {})", str(new_hash)[:12])

logger.info("Updating vendored subtrees...")
settings = load_creation_settings(record.work_dir)
mng_repo_root = find_mng_repo_root()
vendor_configs = settings.vendor if settings.vendor else default_vendor_configs(mng_repo_root)
update_vendor_repos(record.work_dir, vendor_configs)
logger.info("Vendored subtrees updated ({} configured)", len(vendor_configs))

_run_mng_command("start", record.agent_id)

logger.info("Mind '{}' updated successfully.", agent_name)
40 changes: 40 additions & 0 deletions apps/minds/imbue/minds/cli/update_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from pathlib import Path

import pytest

from imbue.minds.cli.update import MindAgentRecord
from imbue.minds.cli.update import parse_mind_agent_record
from imbue.minds.errors import MindError
from imbue.mng.primitives import AgentId


def test_mind_agent_record_stores_fields() -> None:
"""Verify MindAgentRecord stores agent_id and work_dir."""
agent_id = AgentId()
record = MindAgentRecord(agent_id=agent_id, work_dir=Path("/tmp/test"))
assert record.agent_id == agent_id
assert record.work_dir == Path("/tmp/test")


def test_parse_mind_agent_record_extracts_fields() -> None:
"""Verify parse_mind_agent_record extracts id and work_dir from raw dict."""
valid_id = "agent-" + "a" * 32
raw = {"id": valid_id, "name": "selene", "work_dir": "/tmp/minds/selene"}
record = parse_mind_agent_record(raw, "selene")
assert str(record.agent_id) == valid_id
assert record.work_dir == Path("/tmp/minds/selene")


def test_parse_mind_agent_record_raises_on_missing_id() -> None:
"""Verify parse_mind_agent_record raises MindError when id is missing."""
raw: dict[str, object] = {"name": "selene", "work_dir": "/tmp/minds/selene"}
with pytest.raises(MindError, match="missing required fields"):
parse_mind_agent_record(raw, "selene")


def test_parse_mind_agent_record_raises_on_missing_work_dir() -> None:
"""Verify parse_mind_agent_record raises MindError when work_dir is missing."""
valid_id = "agent-" + "a" * 32
raw: dict[str, object] = {"id": valid_id, "name": "selene"}
with pytest.raises(MindError, match="missing required fields"):
parse_mind_agent_record(raw, "selene")
21 changes: 21 additions & 0 deletions apps/minds/imbue/minds/config/data_types.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import json
from pathlib import Path
from typing import Final

from loguru import logger
from pydantic import Field

from imbue.imbue_common.frozen_model import FrozenModel
Expand Down Expand Up @@ -33,3 +35,22 @@ def mind_dir(self, agent_id: AgentId) -> Path:
def get_default_data_dir() -> Path:
"""Return the default data directory for minds (~/.minds)."""
return Path.home() / DEFAULT_DATA_DIR_NAME


def parse_agents_from_mng_output(stdout: str) -> list[dict[str, object]]:
"""Extract agent records from ``mng list --format json`` stdout.

The stdout may contain non-JSON lines (e.g. SSH error tracebacks)
mixed with the JSON. Finds the first line starting with ``{`` and
parses the ``agents`` array from it.
"""
for line in stdout.splitlines():
stripped = line.strip()
if stripped.startswith("{"):
try:
data = json.loads(stripped)
return list(data.get("agents", []))
except json.JSONDecodeError:
logger.trace("Failed to parse JSON from mng list output line: {}", stripped[:200])
continue
return []
41 changes: 41 additions & 0 deletions apps/minds/imbue/minds/config/data_types_test.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import json
from pathlib import Path

from imbue.minds.config.data_types import MindPaths
from imbue.minds.config.data_types import get_default_data_dir
from imbue.minds.config.data_types import parse_agents_from_mng_output
from imbue.mng.primitives import AgentId


Expand All @@ -24,3 +26,42 @@ def test_get_default_data_dir_returns_home_minds() -> None:
result = get_default_data_dir()
assert result.name == ".minds"
assert result.parent == Path.home()


# -- parse_agents_from_mng_output tests --


def test_parse_agents_from_mng_output_extracts_records() -> None:
"""Verify parse_agents_from_mng_output extracts agent records from JSON."""
json_str = json.dumps(
{
"agents": [
{"id": "agent-abc123", "name": "selene", "work_dir": "/tmp/minds/selene"},
]
}
)
agents = parse_agents_from_mng_output(json_str)
assert len(agents) == 1
assert agents[0]["id"] == "agent-abc123"
assert agents[0]["name"] == "selene"


def test_parse_agents_from_mng_output_handles_empty() -> None:
"""Verify parse_agents_from_mng_output returns empty list for no agents."""
json_str = json.dumps({"agents": []})
agents = parse_agents_from_mng_output(json_str)
assert agents == []


def test_parse_agents_from_mng_output_handles_non_json() -> None:
"""Verify parse_agents_from_mng_output handles non-JSON output gracefully."""
agents = parse_agents_from_mng_output("not json at all")
assert agents == []


def test_parse_agents_from_mng_output_handles_mixed_output() -> None:
"""Verify parse_agents_from_mng_output handles SSH errors mixed with JSON."""
output = "WARNING: some SSH error\n" + json.dumps({"agents": [{"id": "agent-xyz", "name": "test"}]})
agents = parse_agents_from_mng_output(output)
assert len(agents) == 1
assert agents[0]["id"] == "agent-xyz"
14 changes: 13 additions & 1 deletion apps/minds/imbue/minds/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,24 @@ class MngCommandError(MindError):
...


class VendorError(MindError):
class GitOperationError(MindError):
"""Raised when a git operation fails during mind management."""

...


class VendorError(GitOperationError):
"""Raised when vendoring a repo into a mind fails."""

...


class ParentTrackingError(GitOperationError):
"""Raised when a parent tracking git operation fails."""

...


class DirtyRepoError(VendorError):
"""Raised when a local vendor repo has uncommitted changes or untracked files."""

Expand Down
8 changes: 6 additions & 2 deletions apps/minds/imbue/minds/forwarding_server/agent_creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@
from imbue.minds.config.data_types import MNG_BINARY
from imbue.minds.config.data_types import MindPaths
from imbue.minds.errors import GitCloneError
from imbue.minds.errors import GitOperationError
from imbue.minds.errors import MngCommandError
from imbue.minds.errors import VendorError
from imbue.minds.forwarding_server.parent_tracking import setup_mind_branch_and_parent
from imbue.minds.forwarding_server.vendor_mng import default_vendor_configs
from imbue.minds.forwarding_server.vendor_mng import find_mng_repo_root
from imbue.minds.forwarding_server.vendor_mng import vendor_repos
Expand Down Expand Up @@ -274,6 +275,9 @@ def _create_agent_background(
log_queue.put("[minds] Cloning {}...".format(git_url))
clone_git_repo(GitUrl(git_url), mind_dir, on_output=emit_log)

log_queue.put("[minds] Setting up branch and parent tracking...")
setup_mind_branch_and_parent(mind_dir, AgentName(agent_name), GitUrl(git_url), on_output=emit_log)

settings = load_creation_settings(mind_dir)

mng_repo_root = find_mng_repo_root()
Expand Down Expand Up @@ -304,7 +308,7 @@ def _create_agent_background(
self._statuses[aid] = AgentCreationStatus.DONE
self._redirect_urls[aid] = "/agents/{}/".format(agent_id)

except (GitCloneError, MngCommandError, VendorError, ValueError, OSError) as e:
except (GitCloneError, MngCommandError, GitOperationError, ValueError, OSError) as e:
logger.error("Failed to create agent {}: {}", agent_id, e)
log_queue.put("[minds] ERROR: {}".format(e))
with self._lock:
Expand Down
Loading
Loading