agentlings 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. agentlings/__init__.py +3 -0
  2. agentlings/__main__.py +238 -0
  3. agentlings/cli/__init__.py +1 -0
  4. agentlings/cli/_migrations.py +78 -0
  5. agentlings/cli/_templates.py +57 -0
  6. agentlings/cli/_version.py +33 -0
  7. agentlings/cli/init.py +122 -0
  8. agentlings/cli/upgrade.py +89 -0
  9. agentlings/config.py +260 -0
  10. agentlings/core/__init__.py +1 -0
  11. agentlings/core/completion.py +219 -0
  12. agentlings/core/llm.py +509 -0
  13. agentlings/core/loop.py +134 -0
  14. agentlings/core/memory_models.py +97 -0
  15. agentlings/core/memory_store.py +109 -0
  16. agentlings/core/models.py +231 -0
  17. agentlings/core/prompt.py +122 -0
  18. agentlings/core/scheduler.py +141 -0
  19. agentlings/core/sleep.py +393 -0
  20. agentlings/core/store.py +318 -0
  21. agentlings/core/task.py +1087 -0
  22. agentlings/core/telemetry.py +181 -0
  23. agentlings/log.py +23 -0
  24. agentlings/migrations/__init__.py +37 -0
  25. agentlings/migrations/m0001_seed.py +17 -0
  26. agentlings/protocol/__init__.py +1 -0
  27. agentlings/protocol/a2a.py +220 -0
  28. agentlings/protocol/a2a_task_store.py +150 -0
  29. agentlings/protocol/agent_card.py +83 -0
  30. agentlings/protocol/mcp.py +232 -0
  31. agentlings/server.py +247 -0
  32. agentlings/templates/__init__.py +1 -0
  33. agentlings/templates/default/.env.example +16 -0
  34. agentlings/templates/default/agent.yaml +14 -0
  35. agentlings/tools/__init__.py +1 -0
  36. agentlings/tools/builtins.py +307 -0
  37. agentlings/tools/memory.py +104 -0
  38. agentlings/tools/registry.py +154 -0
  39. agentlings-0.2.0.dist-info/METADATA +406 -0
  40. agentlings-0.2.0.dist-info/RECORD +43 -0
  41. agentlings-0.2.0.dist-info/WHEEL +4 -0
  42. agentlings-0.2.0.dist-info/entry_points.txt +2 -0
  43. agentlings-0.2.0.dist-info/licenses/LICENSE +21 -0
agentlings/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Agentlings — lightweight A2A + MCP single-process agent framework."""
2
+
3
+ __version__ = "0.1.0"
agentlings/__main__.py ADDED
@@ -0,0 +1,238 @@
1
+ """CLI entry point: subcommand router for ``agentling``.
2
+
3
+ Subcommands:
4
+
5
+ - ``run`` (default) — start the agent server using ``agent.yaml``/``.env`` from CWD or ``--dir``
6
+ - ``init <name>`` — scaffold a new agent directory
7
+ - ``upgrade`` — apply pending data migrations, advance the framework-version stamp
8
+ - ``memory show`` — print the current memory store
9
+ - ``sleep [--date]`` — run a sleep cycle for the agent in CWD
10
+ - ``--list-tools`` — print bundled tool registry
11
+
12
+ ``--dir <PATH>`` works on ``run`` and ``upgrade`` to operate on a directory
13
+ other than CWD.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import asyncio
20
+ import os
21
+ import sys
22
+ from pathlib import Path
23
+
24
+
25
+ def main() -> None:
26
+ """Entry-point dispatcher for the ``agentling`` console script."""
27
+ parser = _build_parser()
28
+ args = parser.parse_args()
29
+
30
+ if getattr(args, "legacy_list_tools", False):
31
+ _list_tools()
32
+ return
33
+ if args.command in (None, "run"):
34
+ _run(getattr(args, "dir", None))
35
+ elif args.command == "init":
36
+ _init(args)
37
+ elif args.command == "upgrade":
38
+ _upgrade(args)
39
+ elif args.command == "memory":
40
+ _memory_command(args.subcommand)
41
+ elif args.command == "sleep":
42
+ _sleep_command(args.date)
43
+ elif args.command == "list-tools":
44
+ _list_tools()
45
+ else:
46
+ parser.print_help()
47
+ sys.exit(2)
48
+
49
+
50
+ def _build_parser() -> argparse.ArgumentParser:
51
+ parser = argparse.ArgumentParser(prog="agentling", description=__doc__)
52
+ sub = parser.add_subparsers(dest="command")
53
+
54
+ run_p = sub.add_parser("run", help="start the agent server (default if no subcommand)")
55
+ run_p.add_argument("--dir", type=Path, default=None, help="agent directory (defaults to CWD)")
56
+
57
+ init_p = sub.add_parser("init", help="scaffold a new agent directory")
58
+ init_p.add_argument("name", help="agent name (also default directory name)")
59
+ init_p.add_argument("--dir", type=Path, default=None, help="output directory (default: ./<name>)")
60
+ init_p.add_argument("--template", default="default", help="bundled template to scaffold from")
61
+ init_p.add_argument("--api-key", default=None, help="AGENT_API_KEY value (auto-generated if omitted)")
62
+ init_p.add_argument("--anthropic-api-key", default=None, help="pre-populate ANTHROPIC_API_KEY")
63
+ init_p.add_argument("--anthropic-base-url", default=None, help="pre-populate ANTHROPIC_BASE_URL")
64
+ init_p.add_argument("--force", action="store_true", help="overwrite an existing non-empty directory")
65
+
66
+ upg_p = sub.add_parser("upgrade", help="apply data migrations against the installed framework")
67
+ upg_p.add_argument("--dir", type=Path, default=None, help="agent directory (defaults to CWD)")
68
+ upg_p.add_argument("--dry-run", action="store_true", help="report pending migrations without running them")
69
+
70
+ mem_p = sub.add_parser("memory", help="memory store inspection")
71
+ mem_p.add_argument("subcommand", choices=["show"])
72
+
73
+ slp_p = sub.add_parser("sleep", help="run a one-off sleep cycle")
74
+ slp_p.add_argument("--date", default=None, help="YYYY-MM-DD (defaults to today)")
75
+
76
+ sub.add_parser("list-tools", help="print bundled tool registry")
77
+
78
+ # Back-compat: older docs and habits use `--list-tools` at top level.
79
+ parser.add_argument("--list-tools", dest="legacy_list_tools", action="store_true", help=argparse.SUPPRESS)
80
+
81
+ return parser
82
+
83
+
84
+ def _run(agent_dir: Path | None) -> None:
85
+ """Run the server with optional directory pinning.
86
+
87
+ Sets ``AGENT_CONFIG`` and ``AGENT_DATA_DIR`` to point at the directory's
88
+ artefacts before constructing ``AgentConfig``. The ``.env`` file inside
89
+ the directory is loaded by ``pydantic-settings`` automatically because
90
+ we change CWD before AgentConfig is created.
91
+ """
92
+ if agent_dir is not None:
93
+ target = agent_dir.resolve()
94
+ os.environ.setdefault("AGENT_CONFIG", str(target / "agent.yaml"))
95
+ os.environ.setdefault("AGENT_DATA_DIR", str(target / "data"))
96
+ os.chdir(target)
97
+
98
+ from agentlings.server import run
99
+
100
+ run()
101
+
102
+
103
+ def _init(args: argparse.Namespace) -> None:
104
+ from agentlings.cli.init import init_agent
105
+
106
+ try:
107
+ result = init_agent(
108
+ args.name,
109
+ dir=args.dir,
110
+ template=args.template,
111
+ api_key=args.api_key,
112
+ anthropic_api_key=args.anthropic_api_key,
113
+ anthropic_base_url=args.anthropic_base_url,
114
+ force=args.force,
115
+ )
116
+ except (FileExistsError, ValueError) as exc:
117
+ print(f"error: {exc}", file=sys.stderr)
118
+ sys.exit(1)
119
+
120
+ print(f"created agent at {result.agent_dir}")
121
+ print(f" template: {result.template}")
122
+ print(f" framework version: {result.framework_version}")
123
+ print(f" generated AGENT_API_KEY (rotate by editing .env)")
124
+ print()
125
+ print("next steps:")
126
+ print(f" cd {result.agent_dir}")
127
+ print(" # add ANTHROPIC_API_KEY to .env if you need it")
128
+ print(" agentling run")
129
+
130
+
131
+ def _upgrade(args: argparse.Namespace) -> None:
132
+ from agentlings.cli.upgrade import upgrade_agent
133
+
134
+ try:
135
+ result = upgrade_agent(args.dir, dry_run=args.dry_run)
136
+ except (FileNotFoundError, RuntimeError) as exc:
137
+ print(f"error: {exc}", file=sys.stderr)
138
+ sys.exit(1)
139
+
140
+ print(f"recorded version: {result.recorded_version or '(none)'}")
141
+ print(f"installed version: {result.installed_version}")
142
+ if not result.applied:
143
+ print("no migrations to apply.")
144
+ return
145
+ label = "would apply" if args.dry_run else "applied"
146
+ for m in result.applied:
147
+ print(f" {label} {m.id}: {m.description}")
148
+
149
+
150
+ def _list_tools() -> None:
151
+ from agentlings.tools.builtins import BUILTIN_REGISTRY
152
+ from agentlings.tools.memory import MEMORY_TOOL_DEFINITION
153
+ from agentlings.tools.registry import TOOL_GROUPS
154
+
155
+ print("Available tool groups:\n")
156
+ all_tools = {**BUILTIN_REGISTRY, MEMORY_TOOL_DEFINITION["name"]: MEMORY_TOOL_DEFINITION}
157
+ for group, tools in TOOL_GROUPS.items():
158
+ print(f" {group}")
159
+ for tool in tools:
160
+ desc = all_tools.get(tool, {}).get("description", "")
161
+ print(f" {tool:20s} {desc}")
162
+ print()
163
+
164
+ standalone = set(all_tools.keys())
165
+ for tools in TOOL_GROUPS.values():
166
+ standalone -= set(tools)
167
+ if standalone:
168
+ print("Standalone tools:\n")
169
+ for name in sorted(standalone):
170
+ desc = all_tools.get(name, {}).get("description", "")
171
+ print(f" {name:22s} {desc}")
172
+ print()
173
+
174
+ print("Enable via agent YAML 'tools' list or AGENT_TOOLS env var.")
175
+ print("Examples:")
176
+ print(" tools: [bash, filesystem, memory]")
177
+
178
+
179
+ def _memory_command(subcommand: str) -> None:
180
+ if subcommand != "show":
181
+ print("Usage: agentling memory show", file=sys.stderr)
182
+ sys.exit(1)
183
+
184
+ from agentlings.config import AgentConfig
185
+ from agentlings.core.memory_store import MemoryFileStore
186
+
187
+ config = AgentConfig()
188
+ store = MemoryFileStore(config.agent_data_dir)
189
+ memory = store.load()
190
+
191
+ if not memory.entries:
192
+ print("Memory is empty.")
193
+ return
194
+
195
+ for entry in memory.entries:
196
+ print(f" {entry.key}: {entry.value}")
197
+ print(f" recorded: {entry.recorded.isoformat()}")
198
+
199
+
200
+ def _sleep_command(date_str: str | None) -> None:
201
+ from datetime import datetime, timezone
202
+
203
+ date = None
204
+ if date_str:
205
+ try:
206
+ date = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
207
+ except ValueError:
208
+ print(f"Invalid date format: {date_str} (expected YYYY-MM-DD)", file=sys.stderr)
209
+ sys.exit(1)
210
+
211
+ from agentlings.config import AgentConfig
212
+ from agentlings.core.llm import create_llm_client
213
+ from agentlings.core.memory_store import MemoryFileStore
214
+ from agentlings.core.sleep import SleepCycle
215
+ from agentlings.core.store import JournalStore
216
+ from agentlings.log import setup_logging
217
+
218
+ config = AgentConfig()
219
+ setup_logging(config.agent_log_level)
220
+
221
+ memory_store = MemoryFileStore(config.agent_data_dir)
222
+ journal_store = JournalStore(config.agent_data_dir)
223
+ llm = create_llm_client(
224
+ backend=config.agent_llm_backend,
225
+ api_key=config.anthropic_api_key,
226
+ model=config.agent_model,
227
+ max_tokens=config.agent_max_tokens,
228
+ base_url=config.anthropic_base_url,
229
+ )
230
+
231
+ cycle = SleepCycle(
232
+ config=config, llm=llm, memory_store=memory_store, store=journal_store,
233
+ )
234
+ asyncio.run(cycle.run(date=date))
235
+
236
+
237
+ if __name__ == "__main__":
238
+ main()
@@ -0,0 +1 @@
1
+ """Operator-facing CLI: scaffold, run, upgrade an agent directory."""
@@ -0,0 +1,78 @@
1
+ """Migration log + runner for ``agentling upgrade``.
2
+
3
+ The log lives at ``<data_dir>/.migrations`` — one applied migration ID per
4
+ line. Pending migrations are those discovered on disk that are not yet
5
+ present in the log. A failed migration leaves the log unchanged so the next
6
+ run picks up where the previous left off.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from agentlings import migrations as migrations_pkg
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ LOG_NAME = ".migrations"
20
+
21
+
22
+ @dataclass
23
+ class MigrationResult:
24
+ id: str
25
+ description: str
26
+ status: str # "applied" | "skipped"
27
+
28
+
29
+ def read_log(data_dir: Path) -> list[str]:
30
+ """Return the IDs of migrations already applied to ``data_dir``."""
31
+ path = data_dir / LOG_NAME
32
+ if not path.exists():
33
+ return []
34
+ return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
35
+
36
+
37
+ def append_log(data_dir: Path, migration_id: str) -> None:
38
+ """Atomically append a migration ID to the log."""
39
+ path = data_dir / LOG_NAME
40
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
41
+ path.write_text(existing + migration_id + "\n", encoding="utf-8")
42
+
43
+
44
+ def pending(data_dir: Path) -> list[object]:
45
+ """Return migration modules not yet recorded in the log."""
46
+ applied = set(read_log(data_dir))
47
+ return [m for m in migrations_pkg.discover() if m.ID not in applied]
48
+
49
+
50
+ def run_pending(data_dir: Path, *, dry_run: bool = False) -> list[MigrationResult]:
51
+ """Apply every pending migration, recording each in the log on success.
52
+
53
+ ``dry_run`` reports what would run without executing or modifying the log.
54
+ Raises whatever the migration raises; callers decide how to surface it.
55
+ """
56
+ results: list[MigrationResult] = []
57
+ for migration in pending(data_dir):
58
+ if dry_run:
59
+ results.append(MigrationResult(migration.ID, migration.DESCRIPTION, "skipped"))
60
+ continue
61
+ logger.info("applying migration %s: %s", migration.ID, migration.DESCRIPTION)
62
+ migration.apply(data_dir)
63
+ append_log(data_dir, migration.ID)
64
+ results.append(MigrationResult(migration.ID, migration.DESCRIPTION, "applied"))
65
+ return results
66
+
67
+
68
+ def stamp_all_applied(data_dir: Path) -> None:
69
+ """Record every known migration as already applied without running any.
70
+
71
+ Called by ``init`` so a fresh agent dir starts with the current migration
72
+ set marked complete — only future migrations need to run on later upgrades.
73
+ """
74
+ path = data_dir / LOG_NAME
75
+ if path.exists():
76
+ return
77
+ ids = [m.ID for m in migrations_pkg.discover()]
78
+ path.write_text("\n".join(ids) + ("\n" if ids else ""), encoding="utf-8")
@@ -0,0 +1,57 @@
1
+ """Bundled-template lookup for ``agentling init``.
2
+
3
+ Templates ship as package data under ``src/agentlings/templates/<name>/``.
4
+ Each template directory contains an ``agent.yaml`` (with ``{{NAME}}`` and
5
+ optional ``{{API_KEY}}`` placeholders substituted at scaffold time) and an
6
+ ``.env.example`` file. ``--from-git`` is a future milestone; v1 only loads
7
+ from this bundled directory.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from importlib import resources
13
+ from importlib.resources.abc import Traversable
14
+
15
+ TEMPLATES_PACKAGE = "agentlings.templates"
16
+
17
+
18
+ def list_templates() -> list[str]:
19
+ """Return the names of all bundled templates.
20
+
21
+ A template is any subdirectory of the ``agentlings.templates`` package
22
+ that contains an ``agent.yaml``.
23
+ """
24
+ root = resources.files(TEMPLATES_PACKAGE)
25
+ names = []
26
+ for entry in root.iterdir():
27
+ if entry.is_dir() and (entry / "agent.yaml").is_file():
28
+ names.append(entry.name)
29
+ return sorted(names)
30
+
31
+
32
+ def template_root(name: str) -> Traversable:
33
+ """Return the resource root for a named template, raising if missing."""
34
+ root = resources.files(TEMPLATES_PACKAGE) / name
35
+ if not (root / "agent.yaml").is_file():
36
+ available = ", ".join(list_templates()) or "(none)"
37
+ raise ValueError(
38
+ f"unknown template '{name}'; available templates: {available}"
39
+ )
40
+ return root
41
+
42
+
43
+ def render_yaml(name: str, agent_name: str) -> str:
44
+ """Load ``agent.yaml`` from the named template and substitute placeholders."""
45
+ raw = (template_root(name) / "agent.yaml").read_text(encoding="utf-8")
46
+ return raw.replace("{{NAME}}", agent_name)
47
+
48
+
49
+ def env_example(name: str) -> str:
50
+ """Return the contents of the template's ``.env.example`` file.
51
+
52
+ Templates without an ``.env.example`` return a minimal default.
53
+ """
54
+ path = template_root(name) / ".env.example"
55
+ if path.is_file():
56
+ return path.read_text(encoding="utf-8")
57
+ return "ANTHROPIC_API_KEY=\nAGENT_API_KEY=\n"
@@ -0,0 +1,33 @@
1
+ """Framework version detection and per-agent-dir version stamping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import metadata
6
+ from pathlib import Path
7
+
8
+ VERSION_FILE = ".framework-version"
9
+
10
+
11
+ def installed_version() -> str:
12
+ """Return the version of the currently-installed ``agentlings`` package."""
13
+ return metadata.version("agentlings")
14
+
15
+
16
+ def read_dir_version(agent_dir: Path) -> str | None:
17
+ """Return the framework version recorded in ``<agent_dir>/.framework-version``.
18
+
19
+ Returns ``None`` when the file is missing or empty — callers treat that as
20
+ "this dir was set up before version stamping existed" and fall back to
21
+ running every migration.
22
+ """
23
+ path = agent_dir / VERSION_FILE
24
+ if not path.exists():
25
+ return None
26
+ text = path.read_text(encoding="utf-8").strip()
27
+ return text or None
28
+
29
+
30
+ def write_dir_version(agent_dir: Path, version: str) -> None:
31
+ """Stamp ``version`` into ``<agent_dir>/.framework-version``."""
32
+ path = agent_dir / VERSION_FILE
33
+ path.write_text(version + "\n", encoding="utf-8")
agentlings/cli/init.py ADDED
@@ -0,0 +1,122 @@
1
+ """``agentling init`` — scaffold a new agent directory.
2
+
3
+ Produces a self-contained directory the operator can ``cd`` into and run
4
+ ``agentling run`` against. Does not install the framework — that's already
5
+ installed (it's the binary running). Does not touch the network in v1; only
6
+ bundled templates are supported.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import secrets
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from agentlings.cli import _migrations, _templates, _version
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ DATA_DIRNAME = "data"
21
+ ENV_FILENAME = ".env"
22
+ ENV_EXAMPLE_FILENAME = ".env.example"
23
+ YAML_FILENAME = "agent.yaml"
24
+
25
+
26
+ @dataclass
27
+ class InitResult:
28
+ agent_dir: Path
29
+ template: str
30
+ framework_version: str
31
+ generated_api_key: str
32
+
33
+
34
+ def init_agent(
35
+ name: str,
36
+ *,
37
+ dir: Path | None = None,
38
+ template: str = "default",
39
+ api_key: str | None = None,
40
+ anthropic_api_key: str | None = None,
41
+ anthropic_base_url: str | None = None,
42
+ force: bool = False,
43
+ ) -> InitResult:
44
+ """Scaffold a new agent directory.
45
+
46
+ Args:
47
+ name: Agent name. Used as the default directory name and substituted
48
+ into the template's ``agent.yaml``.
49
+ dir: Output directory. Defaults to ``./<name>``.
50
+ template: Bundled template to scaffold from.
51
+ api_key: ``AGENT_API_KEY`` value to write into ``.env``. Auto-generated
52
+ if not supplied.
53
+ anthropic_api_key: Optional Anthropic key to pre-populate ``.env``.
54
+ anthropic_base_url: Optional Anthropic base URL to pre-populate.
55
+ force: Allow scaffolding into an existing directory. Files that already
56
+ exist are overwritten *except* ``data/`` and ``.env``, which are
57
+ never touched once they exist.
58
+ """
59
+ target = (dir or Path.cwd() / name).resolve()
60
+ if target.exists() and not force and any(target.iterdir()):
61
+ raise FileExistsError(
62
+ f"{target} already exists and is non-empty (pass --force to overwrite)"
63
+ )
64
+
65
+ target.mkdir(parents=True, exist_ok=True)
66
+ (target / DATA_DIRNAME).mkdir(exist_ok=True)
67
+
68
+ yaml_path = target / YAML_FILENAME
69
+ if not yaml_path.exists() or force:
70
+ yaml_path.write_text(_templates.render_yaml(template, name), encoding="utf-8")
71
+
72
+ env_example = _templates.env_example(template)
73
+ (target / ENV_EXAMPLE_FILENAME).write_text(env_example, encoding="utf-8")
74
+
75
+ generated = api_key or _generate_api_key()
76
+ env_path = target / ENV_FILENAME
77
+ if not env_path.exists():
78
+ env_path.write_text(
79
+ _render_env(env_example, generated, anthropic_api_key, anthropic_base_url),
80
+ encoding="utf-8",
81
+ )
82
+
83
+ framework_version = _version.installed_version()
84
+ _version.write_dir_version(target, framework_version)
85
+ _migrations.stamp_all_applied(target / DATA_DIRNAME)
86
+
87
+ return InitResult(
88
+ agent_dir=target,
89
+ template=template,
90
+ framework_version=framework_version,
91
+ generated_api_key=generated,
92
+ )
93
+
94
+
95
+ def _generate_api_key() -> str:
96
+ """Return a 32-byte URL-safe random string for ``AGENT_API_KEY``."""
97
+ return secrets.token_urlsafe(32)
98
+
99
+
100
+ def _render_env(
101
+ env_example: str,
102
+ agent_api_key: str,
103
+ anthropic_api_key: str | None,
104
+ anthropic_base_url: str | None,
105
+ ) -> str:
106
+ """Substitute populated values into the ``.env.example`` template."""
107
+ lines = []
108
+ for line in env_example.splitlines():
109
+ stripped = line.strip()
110
+ if stripped.startswith("AGENT_API_KEY=") and not stripped.startswith("#"):
111
+ lines.append(f"AGENT_API_KEY={agent_api_key}")
112
+ elif stripped.startswith("ANTHROPIC_API_KEY=") and not stripped.startswith("#"):
113
+ value = anthropic_api_key or ""
114
+ lines.append(f"ANTHROPIC_API_KEY={value}")
115
+ elif (
116
+ anthropic_base_url
117
+ and stripped.startswith("# ANTHROPIC_BASE_URL=")
118
+ ):
119
+ lines.append(f"ANTHROPIC_BASE_URL={anthropic_base_url}")
120
+ else:
121
+ lines.append(line)
122
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,89 @@
1
+ """``agentling upgrade`` — reconcile a dir's data against the installed framework.
2
+
3
+ The CLI never installs or upgrades the package itself — that's the package
4
+ manager's job (``uv pip install --upgrade agentlings`` or equivalent). This
5
+ command applies any pending data-layout migrations and bumps the dir's
6
+ ``.framework-version`` stamp once they all succeed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from agentlings.cli import _migrations, _version
16
+ from agentlings.cli.init import DATA_DIRNAME
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class UpgradeResult:
23
+ recorded_version: str | None
24
+ installed_version: str
25
+ applied: list[_migrations.MigrationResult]
26
+ pending_count: int
27
+
28
+
29
+ def upgrade_agent(agent_dir: Path | None = None, *, dry_run: bool = False) -> UpgradeResult:
30
+ """Run pending migrations and stamp the new framework version.
31
+
32
+ Args:
33
+ agent_dir: Directory containing ``agent.yaml`` and ``data/``. Defaults
34
+ to CWD.
35
+ dry_run: Report what would run without executing migrations or
36
+ advancing ``.framework-version``.
37
+
38
+ Raises:
39
+ FileNotFoundError: When the directory has no ``data/``.
40
+ RuntimeError: When the installed framework is older than what
41
+ scaffolded the directory — downgrades are not supported.
42
+ """
43
+ target = (agent_dir or Path.cwd()).resolve()
44
+ data_dir = target / DATA_DIRNAME
45
+ if not data_dir.exists():
46
+ raise FileNotFoundError(
47
+ f"{target} does not look like an agent dir (no {DATA_DIRNAME}/ subdirectory)"
48
+ )
49
+
50
+ recorded = _version.read_dir_version(target)
51
+ installed = _version.installed_version()
52
+
53
+ if recorded and _is_newer(recorded, installed):
54
+ raise RuntimeError(
55
+ f"installed framework ({installed}) is older than what scaffolded "
56
+ f"this directory ({recorded}); downgrades are not supported. "
57
+ f"Run 'pip install agentlings=={recorded}' to restore."
58
+ )
59
+
60
+ pending = _migrations.pending(data_dir)
61
+ applied = _migrations.run_pending(data_dir, dry_run=dry_run)
62
+
63
+ if not dry_run and applied:
64
+ _version.write_dir_version(target, installed)
65
+ elif not dry_run and not applied and recorded != installed:
66
+ # No migrations to run, but the version stamp is stale — bump it so
67
+ # the next upgrade has an accurate baseline.
68
+ _version.write_dir_version(target, installed)
69
+
70
+ return UpgradeResult(
71
+ recorded_version=recorded,
72
+ installed_version=installed,
73
+ applied=applied,
74
+ pending_count=len(pending),
75
+ )
76
+
77
+
78
+ def _is_newer(a: str, b: str) -> bool:
79
+ """Return True when ``a`` parses to a strictly newer semver than ``b``.
80
+
81
+ Falls back to lexicographic compare when either side is unparseable —
82
+ rare in practice (PEP 440 versions always parse) but safe.
83
+ """
84
+ try:
85
+ ta = tuple(int(p) for p in a.split(".")[:3])
86
+ tb = tuple(int(p) for p in b.split(".")[:3])
87
+ return ta > tb
88
+ except ValueError:
89
+ return a > b