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.
- agentlings/__init__.py +3 -0
- agentlings/__main__.py +238 -0
- agentlings/cli/__init__.py +1 -0
- agentlings/cli/_migrations.py +78 -0
- agentlings/cli/_templates.py +57 -0
- agentlings/cli/_version.py +33 -0
- agentlings/cli/init.py +122 -0
- agentlings/cli/upgrade.py +89 -0
- agentlings/config.py +260 -0
- agentlings/core/__init__.py +1 -0
- agentlings/core/completion.py +219 -0
- agentlings/core/llm.py +509 -0
- agentlings/core/loop.py +134 -0
- agentlings/core/memory_models.py +97 -0
- agentlings/core/memory_store.py +109 -0
- agentlings/core/models.py +231 -0
- agentlings/core/prompt.py +122 -0
- agentlings/core/scheduler.py +141 -0
- agentlings/core/sleep.py +393 -0
- agentlings/core/store.py +318 -0
- agentlings/core/task.py +1087 -0
- agentlings/core/telemetry.py +181 -0
- agentlings/log.py +23 -0
- agentlings/migrations/__init__.py +37 -0
- agentlings/migrations/m0001_seed.py +17 -0
- agentlings/protocol/__init__.py +1 -0
- agentlings/protocol/a2a.py +220 -0
- agentlings/protocol/a2a_task_store.py +150 -0
- agentlings/protocol/agent_card.py +83 -0
- agentlings/protocol/mcp.py +232 -0
- agentlings/server.py +247 -0
- agentlings/templates/__init__.py +1 -0
- agentlings/templates/default/.env.example +16 -0
- agentlings/templates/default/agent.yaml +14 -0
- agentlings/tools/__init__.py +1 -0
- agentlings/tools/builtins.py +307 -0
- agentlings/tools/memory.py +104 -0
- agentlings/tools/registry.py +154 -0
- agentlings-0.2.0.dist-info/METADATA +406 -0
- agentlings-0.2.0.dist-info/RECORD +43 -0
- agentlings-0.2.0.dist-info/WHEEL +4 -0
- agentlings-0.2.0.dist-info/entry_points.txt +2 -0
- agentlings-0.2.0.dist-info/licenses/LICENSE +21 -0
agentlings/__init__.py
ADDED
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
|