agentlings 0.2.4__tar.gz → 0.4.0__tar.gz

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 (104) hide show
  1. {agentlings-0.2.4 → agentlings-0.4.0}/PKG-INFO +112 -6
  2. {agentlings-0.2.4 → agentlings-0.4.0}/README.md +111 -5
  3. agentlings-0.4.0/media/skills.png +0 -0
  4. agentlings-0.4.0/media/tools.png +0 -0
  5. {agentlings-0.2.4 → agentlings-0.4.0}/pyproject.toml +1 -1
  6. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/cli/init.py +4 -0
  7. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/config.py +11 -0
  8. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/core/loop.py +3 -0
  9. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/core/prompt.py +18 -5
  10. agentlings-0.4.0/src/agentlings/core/skills.py +156 -0
  11. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/core/task.py +7 -0
  12. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/server.py +9 -0
  13. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/templates/default/.env.example +5 -0
  14. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_cli_init.py +29 -0
  15. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_config.py +29 -0
  16. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_prompt.py +34 -1
  17. agentlings-0.4.0/tests/unit/test_skills.py +467 -0
  18. agentlings-0.2.4/DESIGN-memory-sleep.md +0 -505
  19. {agentlings-0.2.4 → agentlings-0.4.0}/.env.example +0 -0
  20. {agentlings-0.2.4 → agentlings-0.4.0}/.github/workflows/ci.yml +0 -0
  21. {agentlings-0.2.4 → agentlings-0.4.0}/.github/workflows/publish.yml +0 -0
  22. {agentlings-0.2.4 → agentlings-0.4.0}/.gitignore +0 -0
  23. {agentlings-0.2.4 → agentlings-0.4.0}/CLAUDE.md +0 -0
  24. {agentlings-0.2.4 → agentlings-0.4.0}/Dockerfile +0 -0
  25. {agentlings-0.2.4 → agentlings-0.4.0}/LICENSE +0 -0
  26. {agentlings-0.2.4 → agentlings-0.4.0}/agent.example.yaml +0 -0
  27. {agentlings-0.2.4 → agentlings-0.4.0}/docker-compose.test.yml +0 -0
  28. {agentlings-0.2.4 → agentlings-0.4.0/media}/logo.png +0 -0
  29. {agentlings-0.2.4 → agentlings-0.4.0/media}/sleep.png +0 -0
  30. {agentlings-0.2.4 → agentlings-0.4.0}/scripts/release.sh +0 -0
  31. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/__init__.py +0 -0
  32. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/__main__.py +0 -0
  33. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/cli/__init__.py +0 -0
  34. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/cli/_migrations.py +0 -0
  35. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/cli/_templates.py +0 -0
  36. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/cli/_version.py +0 -0
  37. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/cli/upgrade.py +0 -0
  38. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/core/__init__.py +0 -0
  39. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/core/completion.py +0 -0
  40. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/core/llm.py +0 -0
  41. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/core/memory_models.py +0 -0
  42. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/core/memory_store.py +0 -0
  43. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/core/models.py +0 -0
  44. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/core/scheduler.py +0 -0
  45. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/core/sleep.py +0 -0
  46. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/core/store.py +0 -0
  47. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/core/telemetry.py +0 -0
  48. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/log.py +0 -0
  49. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/migrations/__init__.py +0 -0
  50. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/migrations/m0001_seed.py +0 -0
  51. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/protocol/__init__.py +0 -0
  52. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/protocol/a2a.py +0 -0
  53. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/protocol/a2a_task_store.py +0 -0
  54. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/protocol/agent_card.py +0 -0
  55. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/protocol/mcp.py +0 -0
  56. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/templates/__init__.py +0 -0
  57. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/templates/default/agent.yaml +0 -0
  58. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/tools/__init__.py +0 -0
  59. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/tools/builtins.py +0 -0
  60. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/tools/decorator.py +0 -0
  61. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/tools/examples/__init__.py +0 -0
  62. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/tools/examples/echo.py +0 -0
  63. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/tools/examples/geocode.py +0 -0
  64. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/tools/examples/http_get.py +0 -0
  65. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/tools/examples/set_severity.py +0 -0
  66. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/tools/loader.py +0 -0
  67. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/tools/memory.py +0 -0
  68. {agentlings-0.2.4 → agentlings-0.4.0}/src/agentlings/tools/registry.py +0 -0
  69. {agentlings-0.2.4 → agentlings-0.4.0}/tests/Dockerfile +0 -0
  70. {agentlings-0.2.4 → agentlings-0.4.0}/tests/__init__.py +0 -0
  71. {agentlings-0.2.4 → agentlings-0.4.0}/tests/agent.test.yaml +0 -0
  72. {agentlings-0.2.4 → agentlings-0.4.0}/tests/integration/__init__.py +0 -0
  73. {agentlings-0.2.4 → agentlings-0.4.0}/tests/integration/a2a_client.py +0 -0
  74. {agentlings-0.2.4 → agentlings-0.4.0}/tests/integration/conftest.py +0 -0
  75. {agentlings-0.2.4 → agentlings-0.4.0}/tests/integration/mcp_client.py +0 -0
  76. {agentlings-0.2.4 → agentlings-0.4.0}/tests/integration/test_a2a.py +0 -0
  77. {agentlings-0.2.4 → agentlings-0.4.0}/tests/integration/test_agent_card.py +0 -0
  78. {agentlings-0.2.4 → agentlings-0.4.0}/tests/integration/test_mcp.py +0 -0
  79. {agentlings-0.2.4 → agentlings-0.4.0}/tests/integration/test_ollama.py +0 -0
  80. {agentlings-0.2.4 → agentlings-0.4.0}/tests/integration/test_task_flow.py +0 -0
  81. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/__init__.py +0 -0
  82. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/conftest.py +0 -0
  83. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_a2a_executor.py +0 -0
  84. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_a2a_task_store.py +0 -0
  85. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_agent_card.py +0 -0
  86. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_cli_upgrade.py +0 -0
  87. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_completion.py +0 -0
  88. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_live_api.py +0 -0
  89. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_llm.py +0 -0
  90. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_logging.py +0 -0
  91. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_loop.py +0 -0
  92. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_mcp_handler.py +0 -0
  93. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_memory_models.py +0 -0
  94. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_memory_store.py +0 -0
  95. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_memory_tool.py +0 -0
  96. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_models.py +0 -0
  97. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_scheduler.py +0 -0
  98. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_sleep.py +0 -0
  99. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_store.py +0 -0
  100. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_task.py +0 -0
  101. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_telemetry.py +0 -0
  102. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_tool_decorator.py +0 -0
  103. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_tool_loader.py +0 -0
  104. {agentlings-0.2.4 → agentlings-0.4.0}/tests/unit/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentlings
3
- Version: 0.2.4
3
+ Version: 0.4.0
4
4
  Summary: Lightweight A2A + MCP single-process agent framework
5
5
  Project-URL: Homepage, https://github.com/andyjmorgan/DonkeyWork-Agentlings
6
6
  Project-URL: Repository, https://github.com/andyjmorgan/DonkeyWork-Agentlings
@@ -37,7 +37,7 @@ Requires-Dist: pytest>=8.0; extra == 'dev'
37
37
  Description-Content-Type: text/markdown
38
38
 
39
39
  <p align="center">
40
- <img src="logo.png" alt="Agentlings" width="256">
40
+ <img src="media/logo.png" alt="Agentlings" width="256">
41
41
  </p>
42
42
 
43
43
  <h1 align="center">Agentlings</h1>
@@ -82,8 +82,10 @@ my-agent/
82
82
  ├── .env # AGENT_API_KEY auto-generated; ANTHROPIC_API_KEY blank for you
83
83
  ├── .env.example # checked into source control as a template
84
84
  ├── .framework-version # the framework version that scaffolded this dir
85
- └── data/ # journals, memory, conversations
86
- └── .migrations # applied-migrations log
85
+ ├── data/ # journals, memory, conversations
86
+ └── .migrations # applied-migrations log
87
+ ├── skills/ # drop SKILL.md bundles here; uncomment AGENT_SKILLS_DIR to enable
88
+ └── tools/ # drop @tool-decorated .py files here; uncomment AGENT_TOOLS_DIR to enable
87
89
  ```
88
90
 
89
91
  `agentling run` reads `agent.yaml`, `.env`, and `data/` from the current directory. To operate on a different dir without `cd`-ing in: `agentling run --dir /path/to/agent`.
@@ -95,6 +97,8 @@ The running agent serves:
95
97
  - `POST /a2a` — A2A JSON-RPC endpoint
96
98
  - `POST /mcp` — MCP Streamable HTTP endpoint
97
99
 
100
+ Both protocols are task-aware. Each request becomes a task; the HTTP handler awaits up to `AGENT_TASK_AWAIT_SECONDS` (default 60) and either returns the final answer inline or yields a task handle the caller polls. A2A clients can opt out of the wait per-request via `configuration.return_immediately = true` on `message/send` (A2A v1.0 JSON name `returnImmediately`) — the handler then enqueues a `Task` object immediately and the caller polls via `tasks/get`.
101
+
98
102
  ## CLI
99
103
 
100
104
  | Command | Purpose |
@@ -222,7 +226,106 @@ Point to it with `AGENT_CONFIG=./agent.yaml`.
222
226
  | `filesystem` | `read_file`, `write_file`, `edit_file`, `list_directory`, `search_files` | File operations with offset/limit, find-and-replace, glob search |
223
227
  | `memory` | `memory_edit` | Read and write the agent's persistent long-term memory |
224
228
 
225
- Tools are off by default. Run `agentling --list-tools` for details.
229
+ Tools are off by default. Run `agentling list-tools` for details.
230
+
231
+ ## Custom tools
232
+
233
+ <p align="center">
234
+ <img src="media/tools.png" alt="Custom tools" width="256">
235
+ </p>
236
+
237
+ Beyond the built-ins, you can author your own tools as plain typed Python functions. Decorate them with `@tool`, drop the file in a directory, and point `AGENT_TOOLS_DIR` at it — the agentling scans the directory at startup and registers every `Tool` it finds.
238
+
239
+ ```python
240
+ # tools/weather.py
241
+ import os
242
+ from typing import Annotated, Literal
243
+ from pydantic import Field
244
+ from agentlings.tools import tool
245
+
246
+
247
+ @tool
248
+ async def weather(
249
+ city: Annotated[str, Field(description="City name, e.g. 'Dublin'.")],
250
+ units: Literal["metric", "imperial"] = "metric",
251
+ ) -> str:
252
+ """Look up current weather for a city."""
253
+ api_key = os.environ["WEATHER_API_KEY"]
254
+ # ...fetch and return a string the LLM can read...
255
+ ```
256
+
257
+ Then run with `AGENT_TOOLS_DIR=./tools agentling run`. No registration step, no schema dict — the JSON Schema the LLM sees is derived from the function signature via Pydantic.
258
+
259
+ ### How discovery works
260
+
261
+ - The loader scans the top level of `AGENT_TOOLS_DIR` for `.py` files (no recursion).
262
+ - Files whose name begins with `_` are skipped (use them for shared helpers).
263
+ - Each file is imported in isolation — the directory is never added to `sys.path`, so a file named `json.py` cannot shadow the stdlib.
264
+ - Every module-level `Tool` instance (i.e. anything you decorated with `@tool`) is registered.
265
+ - An import or registration failure on one file is logged and the scan continues — one broken tool cannot brick the agent.
266
+
267
+ ### Authoring contract
268
+
269
+ | Concept | How to express it |
270
+ |---|---|
271
+ | Tool name | `func.__name__` (or `@tool(name="...")`) |
272
+ | Tool description | The function's docstring (or `@tool(description="...")`) |
273
+ | Parameter description / constraints | `Annotated[T, Field(description="...", ge=..., le=...)]` |
274
+ | Allowed values | `Literal["a", "b"]` or a `str`/`int` `Enum` |
275
+ | Optional / defaults | A normal Python default (`x: int = 30`) |
276
+ | Async I/O | `async def` — sync functions are fine too; both are awaited uniformly |
277
+ | Per-tool secrets | Read your own env vars inside the function (the framework stays out of secret plumbing) |
278
+
279
+ Untyped parameters, `*args`, `**kwargs`, and positional-only parameters are rejected at decoration time — `@tool` raises `ToolDefinitionError` so misuse fails loudly at startup, not in production.
280
+
281
+ Reference tools showcasing each pattern live in `agentlings.tools.examples` (`echo`, `http_get`, `set_severity`, `geocode`).
282
+
283
+ ## Skills
284
+
285
+ <p align="center">
286
+ <img src="media/skills.png" alt="Skills" width="256">
287
+ </p>
288
+
289
+ Skills are bundled instructions the agent activates on demand. Each skill is a directory containing a `SKILL.md` whose YAML frontmatter (`name`, `description`) is loaded into the system prompt at startup; the body — and any sibling `scripts/`, `references/`, or `assets/` — stays on disk until the agent decides the task needs it. This is the **progressive disclosure** model from the [Open Skills specification](https://agentskills.io/specification): metadata is cheap, instructions are loaded on activation, resources are loaded on demand.
290
+
291
+ ```
292
+ skills/
293
+ ├── pdf-processing/
294
+ │ ├── SKILL.md
295
+ │ ├── scripts/extract.py
296
+ │ └── references/FORMS.md
297
+ └── data-analysis/
298
+ └── SKILL.md
299
+ ```
300
+
301
+ A minimal `SKILL.md`:
302
+
303
+ ```markdown
304
+ ---
305
+ name: pdf-processing
306
+ description: Extract text and tables from PDFs, fill PDF forms, merge files. Use when the user mentions PDFs, forms, or document extraction.
307
+ ---
308
+
309
+ Step-by-step instructions for the agent go below the frontmatter.
310
+ Reference companion files with relative paths, e.g. `scripts/extract.py`.
311
+ ```
312
+
313
+ Skills are opt-in: set `AGENT_SKILLS_DIR=./skills` (or any path) and drop skill directories there. On startup the agentling discovers them and prepends a single block to the system prompt explaining progressive disclosure and listing each skill's name, absolute path, and description. The agent reads `SKILL.md` itself when a task calls for the skill.
314
+
315
+ ### Frontmatter constraints
316
+
317
+ Per the Open Skills spec:
318
+
319
+ | Field | Required | Constraint |
320
+ |---|---|---|
321
+ | `name` | Yes | 1–64 chars, lowercase `a-z`, digits, hyphens; no leading/trailing/consecutive hyphens; must match the parent directory name |
322
+ | `description` | Yes | 1–1024 chars, non-empty |
323
+
324
+ Optional fields (`license`, `compatibility`, `metadata`, `allowed-tools`) are accepted but currently ignored at the runtime layer. Malformed skills (missing fields, invalid names, broken YAML) are logged at `WARNING` and skipped — one bad skill does not prevent the agent from booting.
325
+
326
+ Discovery is strictly read-only — the agentling never writes to, deletes from, or modifies anything under `AGENT_SKILLS_DIR`. `AGENT_SKILLS_DIR` and `AGENT_TOOLS_DIR` share the same opt-in semantics: unset means "don't scan." `AGENT_TOOLS_DIR` additionally never adds the user-tools directory to `sys.path`, so a file named `json.py` cannot shadow the stdlib.
327
+
328
+ > **Naming note:** the `skills:` array in `agent.yaml` is unrelated — those are A2A Agent Card capabilities advertised on the wire. Runtime skills (this section) live on disk under `AGENT_SKILLS_DIR`.
226
329
 
227
330
  ## Docker
228
331
 
@@ -260,6 +363,9 @@ Secrets and runtime settings stay in env vars or, more commonly, the `.env` file
260
363
  | `AGENT_HOST` | `0.0.0.0` | Bind address |
261
364
  | `AGENT_PORT` | `8420` | Bind port |
262
365
  | `AGENT_DATA_DIR` | `./data` | JSONL journal storage directory |
366
+ | `AGENT_TOOLS_DIR` | — | Directory of `@tool`-decorated `.py` files to load at startup |
367
+ | `AGENT_SKILLS_DIR` | — | Directory of Open Skills `SKILL.md` bundles to advertise to the agent |
368
+ | `AGENT_TASK_AWAIT_SECONDS` | `60` | How long the HTTP handler blocks for task completion before returning a working task handle |
263
369
  | `AGENT_LOG_LEVEL` | `INFO` | Log level |
264
370
  | `AGENT_LLM_BACKEND` | `anthropic` | `anthropic` or `mock` |
265
371
  | `AGENT_EXTERNAL_URL` | — | Public URL for Agent Card (needed in Docker/k8s) |
@@ -320,7 +426,7 @@ memory:
320
426
  ## Sleep cycle
321
427
 
322
428
  <p align="center">
323
- <img src="sleep.png" alt="Sleep Cycle" width="256">
429
+ <img src="media/sleep.png" alt="Sleep Cycle" width="256">
324
430
  </p>
325
431
 
326
432
  The sleep cycle is a nightly process that journals the day's activity, consolidates new knowledge into memory, prunes stale entries, and cleans up old files. It maps to biological sleep phases.
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="logo.png" alt="Agentlings" width="256">
2
+ <img src="media/logo.png" alt="Agentlings" width="256">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">Agentlings</h1>
@@ -44,8 +44,10 @@ my-agent/
44
44
  ├── .env # AGENT_API_KEY auto-generated; ANTHROPIC_API_KEY blank for you
45
45
  ├── .env.example # checked into source control as a template
46
46
  ├── .framework-version # the framework version that scaffolded this dir
47
- └── data/ # journals, memory, conversations
48
- └── .migrations # applied-migrations log
47
+ ├── data/ # journals, memory, conversations
48
+ └── .migrations # applied-migrations log
49
+ ├── skills/ # drop SKILL.md bundles here; uncomment AGENT_SKILLS_DIR to enable
50
+ └── tools/ # drop @tool-decorated .py files here; uncomment AGENT_TOOLS_DIR to enable
49
51
  ```
50
52
 
51
53
  `agentling run` reads `agent.yaml`, `.env`, and `data/` from the current directory. To operate on a different dir without `cd`-ing in: `agentling run --dir /path/to/agent`.
@@ -57,6 +59,8 @@ The running agent serves:
57
59
  - `POST /a2a` — A2A JSON-RPC endpoint
58
60
  - `POST /mcp` — MCP Streamable HTTP endpoint
59
61
 
62
+ Both protocols are task-aware. Each request becomes a task; the HTTP handler awaits up to `AGENT_TASK_AWAIT_SECONDS` (default 60) and either returns the final answer inline or yields a task handle the caller polls. A2A clients can opt out of the wait per-request via `configuration.return_immediately = true` on `message/send` (A2A v1.0 JSON name `returnImmediately`) — the handler then enqueues a `Task` object immediately and the caller polls via `tasks/get`.
63
+
60
64
  ## CLI
61
65
 
62
66
  | Command | Purpose |
@@ -184,7 +188,106 @@ Point to it with `AGENT_CONFIG=./agent.yaml`.
184
188
  | `filesystem` | `read_file`, `write_file`, `edit_file`, `list_directory`, `search_files` | File operations with offset/limit, find-and-replace, glob search |
185
189
  | `memory` | `memory_edit` | Read and write the agent's persistent long-term memory |
186
190
 
187
- Tools are off by default. Run `agentling --list-tools` for details.
191
+ Tools are off by default. Run `agentling list-tools` for details.
192
+
193
+ ## Custom tools
194
+
195
+ <p align="center">
196
+ <img src="media/tools.png" alt="Custom tools" width="256">
197
+ </p>
198
+
199
+ Beyond the built-ins, you can author your own tools as plain typed Python functions. Decorate them with `@tool`, drop the file in a directory, and point `AGENT_TOOLS_DIR` at it — the agentling scans the directory at startup and registers every `Tool` it finds.
200
+
201
+ ```python
202
+ # tools/weather.py
203
+ import os
204
+ from typing import Annotated, Literal
205
+ from pydantic import Field
206
+ from agentlings.tools import tool
207
+
208
+
209
+ @tool
210
+ async def weather(
211
+ city: Annotated[str, Field(description="City name, e.g. 'Dublin'.")],
212
+ units: Literal["metric", "imperial"] = "metric",
213
+ ) -> str:
214
+ """Look up current weather for a city."""
215
+ api_key = os.environ["WEATHER_API_KEY"]
216
+ # ...fetch and return a string the LLM can read...
217
+ ```
218
+
219
+ Then run with `AGENT_TOOLS_DIR=./tools agentling run`. No registration step, no schema dict — the JSON Schema the LLM sees is derived from the function signature via Pydantic.
220
+
221
+ ### How discovery works
222
+
223
+ - The loader scans the top level of `AGENT_TOOLS_DIR` for `.py` files (no recursion).
224
+ - Files whose name begins with `_` are skipped (use them for shared helpers).
225
+ - Each file is imported in isolation — the directory is never added to `sys.path`, so a file named `json.py` cannot shadow the stdlib.
226
+ - Every module-level `Tool` instance (i.e. anything you decorated with `@tool`) is registered.
227
+ - An import or registration failure on one file is logged and the scan continues — one broken tool cannot brick the agent.
228
+
229
+ ### Authoring contract
230
+
231
+ | Concept | How to express it |
232
+ |---|---|
233
+ | Tool name | `func.__name__` (or `@tool(name="...")`) |
234
+ | Tool description | The function's docstring (or `@tool(description="...")`) |
235
+ | Parameter description / constraints | `Annotated[T, Field(description="...", ge=..., le=...)]` |
236
+ | Allowed values | `Literal["a", "b"]` or a `str`/`int` `Enum` |
237
+ | Optional / defaults | A normal Python default (`x: int = 30`) |
238
+ | Async I/O | `async def` — sync functions are fine too; both are awaited uniformly |
239
+ | Per-tool secrets | Read your own env vars inside the function (the framework stays out of secret plumbing) |
240
+
241
+ Untyped parameters, `*args`, `**kwargs`, and positional-only parameters are rejected at decoration time — `@tool` raises `ToolDefinitionError` so misuse fails loudly at startup, not in production.
242
+
243
+ Reference tools showcasing each pattern live in `agentlings.tools.examples` (`echo`, `http_get`, `set_severity`, `geocode`).
244
+
245
+ ## Skills
246
+
247
+ <p align="center">
248
+ <img src="media/skills.png" alt="Skills" width="256">
249
+ </p>
250
+
251
+ Skills are bundled instructions the agent activates on demand. Each skill is a directory containing a `SKILL.md` whose YAML frontmatter (`name`, `description`) is loaded into the system prompt at startup; the body — and any sibling `scripts/`, `references/`, or `assets/` — stays on disk until the agent decides the task needs it. This is the **progressive disclosure** model from the [Open Skills specification](https://agentskills.io/specification): metadata is cheap, instructions are loaded on activation, resources are loaded on demand.
252
+
253
+ ```
254
+ skills/
255
+ ├── pdf-processing/
256
+ │ ├── SKILL.md
257
+ │ ├── scripts/extract.py
258
+ │ └── references/FORMS.md
259
+ └── data-analysis/
260
+ └── SKILL.md
261
+ ```
262
+
263
+ A minimal `SKILL.md`:
264
+
265
+ ```markdown
266
+ ---
267
+ name: pdf-processing
268
+ description: Extract text and tables from PDFs, fill PDF forms, merge files. Use when the user mentions PDFs, forms, or document extraction.
269
+ ---
270
+
271
+ Step-by-step instructions for the agent go below the frontmatter.
272
+ Reference companion files with relative paths, e.g. `scripts/extract.py`.
273
+ ```
274
+
275
+ Skills are opt-in: set `AGENT_SKILLS_DIR=./skills` (or any path) and drop skill directories there. On startup the agentling discovers them and prepends a single block to the system prompt explaining progressive disclosure and listing each skill's name, absolute path, and description. The agent reads `SKILL.md` itself when a task calls for the skill.
276
+
277
+ ### Frontmatter constraints
278
+
279
+ Per the Open Skills spec:
280
+
281
+ | Field | Required | Constraint |
282
+ |---|---|---|
283
+ | `name` | Yes | 1–64 chars, lowercase `a-z`, digits, hyphens; no leading/trailing/consecutive hyphens; must match the parent directory name |
284
+ | `description` | Yes | 1–1024 chars, non-empty |
285
+
286
+ Optional fields (`license`, `compatibility`, `metadata`, `allowed-tools`) are accepted but currently ignored at the runtime layer. Malformed skills (missing fields, invalid names, broken YAML) are logged at `WARNING` and skipped — one bad skill does not prevent the agent from booting.
287
+
288
+ Discovery is strictly read-only — the agentling never writes to, deletes from, or modifies anything under `AGENT_SKILLS_DIR`. `AGENT_SKILLS_DIR` and `AGENT_TOOLS_DIR` share the same opt-in semantics: unset means "don't scan." `AGENT_TOOLS_DIR` additionally never adds the user-tools directory to `sys.path`, so a file named `json.py` cannot shadow the stdlib.
289
+
290
+ > **Naming note:** the `skills:` array in `agent.yaml` is unrelated — those are A2A Agent Card capabilities advertised on the wire. Runtime skills (this section) live on disk under `AGENT_SKILLS_DIR`.
188
291
 
189
292
  ## Docker
190
293
 
@@ -222,6 +325,9 @@ Secrets and runtime settings stay in env vars or, more commonly, the `.env` file
222
325
  | `AGENT_HOST` | `0.0.0.0` | Bind address |
223
326
  | `AGENT_PORT` | `8420` | Bind port |
224
327
  | `AGENT_DATA_DIR` | `./data` | JSONL journal storage directory |
328
+ | `AGENT_TOOLS_DIR` | — | Directory of `@tool`-decorated `.py` files to load at startup |
329
+ | `AGENT_SKILLS_DIR` | — | Directory of Open Skills `SKILL.md` bundles to advertise to the agent |
330
+ | `AGENT_TASK_AWAIT_SECONDS` | `60` | How long the HTTP handler blocks for task completion before returning a working task handle |
225
331
  | `AGENT_LOG_LEVEL` | `INFO` | Log level |
226
332
  | `AGENT_LLM_BACKEND` | `anthropic` | `anthropic` or `mock` |
227
333
  | `AGENT_EXTERNAL_URL` | — | Public URL for Agent Card (needed in Docker/k8s) |
@@ -282,7 +388,7 @@ memory:
282
388
  ## Sleep cycle
283
389
 
284
390
  <p align="center">
285
- <img src="sleep.png" alt="Sleep Cycle" width="256">
391
+ <img src="media/sleep.png" alt="Sleep Cycle" width="256">
286
392
  </p>
287
393
 
288
394
  The sleep cycle is a nightly process that journals the day's activity, consolidates new knowledge into memory, prunes stale entries, and cleans up old files. It maps to biological sleep phases.
Binary file
Binary file
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agentlings"
7
- version = "0.2.4"
7
+ version = "0.4.0"
8
8
  description = "Lightweight A2A + MCP single-process agent framework"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -18,6 +18,8 @@ from agentlings.cli import _migrations, _templates, _version
18
18
  logger = logging.getLogger(__name__)
19
19
 
20
20
  DATA_DIRNAME = "data"
21
+ SKILLS_DIRNAME = "skills"
22
+ TOOLS_DIRNAME = "tools"
21
23
  ENV_FILENAME = ".env"
22
24
  ENV_EXAMPLE_FILENAME = ".env.example"
23
25
  YAML_FILENAME = "agent.yaml"
@@ -64,6 +66,8 @@ def init_agent(
64
66
 
65
67
  target.mkdir(parents=True, exist_ok=True)
66
68
  (target / DATA_DIRNAME).mkdir(exist_ok=True)
69
+ (target / SKILLS_DIRNAME).mkdir(exist_ok=True)
70
+ (target / TOOLS_DIRNAME).mkdir(exist_ok=True)
67
71
 
68
72
  yaml_path = target / YAML_FILENAME
69
73
  if not yaml_path.exists() or force:
@@ -153,6 +153,7 @@ class AgentConfig(BaseSettings):
153
153
  agent_host: str = "0.0.0.0"
154
154
  agent_port: int = 8420
155
155
  agent_data_dir: Path = Path("./data")
156
+ agent_skills_dir: Path | None = None
156
157
  agent_log_level: str = "INFO"
157
158
  agent_llm_backend: Literal["anthropic", "mock"] = "anthropic"
158
159
  agent_external_url: str | None = None
@@ -203,6 +204,16 @@ class AgentConfig(BaseSettings):
203
204
  """Skills to advertise from the YAML definition."""
204
205
  return self._definition.skills
205
206
 
207
+ @property
208
+ def skills_dir(self) -> Path | None:
209
+ """Filesystem root for runtime instruction-skills (Open Skills spec).
210
+
211
+ Opt-in: when ``AGENT_SKILLS_DIR`` is unset, the agent does not scan
212
+ anywhere. This matches ``AGENT_TOOLS_DIR`` so the two folder-scan
213
+ env vars share one mental model.
214
+ """
215
+ return self.agent_skills_dir
216
+
206
217
  @property
207
218
  def memory_config(self) -> MemoryConfig | None:
208
219
  """Memory configuration from the YAML definition."""
@@ -17,6 +17,7 @@ from typing import Any
17
17
  from agentlings.config import AgentConfig
18
18
  from agentlings.core.llm import BaseLLMClient
19
19
  from agentlings.core.memory_store import MemoryFileStore
20
+ from agentlings.core.skills import SkillRef
20
21
  from agentlings.core.store import JournalStore
21
22
  from agentlings.core.task import (
22
23
  TaskEngine,
@@ -79,6 +80,7 @@ class MessageLoop:
79
80
  llm: BaseLLMClient,
80
81
  tools: ToolRegistry,
81
82
  memory_store: MemoryFileStore | None = None,
83
+ skills: list[SkillRef] | None = None,
82
84
  ) -> None:
83
85
  self._config = config
84
86
  self._store = store
@@ -88,6 +90,7 @@ class MessageLoop:
88
90
  llm=llm,
89
91
  tools=tools,
90
92
  memory_store=memory_store,
93
+ skills=skills,
91
94
  )
92
95
 
93
96
  @property
@@ -8,6 +8,7 @@ from typing import Any
8
8
 
9
9
  from agentlings.config import AgentConfig
10
10
  from agentlings.core.memory_models import MemoryStore
11
+ from agentlings.core.skills import SkillRef, format_skills_block
11
12
 
12
13
  logger = logging.getLogger(__name__)
13
14
 
@@ -40,6 +41,7 @@ def build_system_prompt(
40
41
  data_dir: Path | None = None,
41
42
  injection_prompt: str | None = None,
42
43
  token_budget: int = 2000,
44
+ skills: list[SkillRef] | None = None,
43
45
  ) -> list[dict[str, Any]]:
44
46
  """Build the system prompt blocks for the Anthropic Messages API.
45
47
 
@@ -49,6 +51,9 @@ def build_system_prompt(
49
51
  data_dir: Path to the agent's data directory for the awareness block.
50
52
  injection_prompt: Override for the memory injection template.
51
53
  Uses ``DEFAULT_MEMORY_INJECTION`` if not provided.
54
+ skills: Discovered runtime skills (Open Skills spec). Prepended ahead
55
+ of the identity block when non-empty so the agent sees them
56
+ before any operator-defined instructions.
52
57
 
53
58
  Returns:
54
59
  A list of text blocks with ``cache_control`` set to ephemeral.
@@ -58,13 +63,21 @@ def build_system_prompt(
58
63
  else:
59
64
  text = _default_prompt(config)
60
65
 
61
- blocks = [
62
- {
66
+ blocks: list[dict[str, Any]] = []
67
+
68
+ skills_block = format_skills_block(skills or [])
69
+ if skills_block is not None:
70
+ blocks.append({
63
71
  "type": "text",
64
- "text": text,
72
+ "text": skills_block,
65
73
  "cache_control": {"type": "ephemeral"},
66
- },
67
- ]
74
+ })
75
+
76
+ blocks.append({
77
+ "type": "text",
78
+ "text": text,
79
+ "cache_control": {"type": "ephemeral"},
80
+ })
68
81
 
69
82
  if memory and memory.entries:
70
83
  template = injection_prompt or DEFAULT_MEMORY_INJECTION
@@ -0,0 +1,156 @@
1
+ """Skill discovery and prompt rendering.
2
+
3
+ A skill is a directory under the configured skills root containing a
4
+ ``SKILL.md`` file with YAML frontmatter (``name``, ``description``) followed by
5
+ a Markdown body. Only the metadata is loaded at startup and injected into the
6
+ system prompt; the body and any sibling resources (``scripts/``, ``references/``,
7
+ ``assets/``) are loaded by the agent on demand — the *progressive disclosure*
8
+ pattern from the Open Skills specification (https://agentskills.io/specification).
9
+
10
+ Discovery is intentionally lenient: malformed entries are logged and skipped so
11
+ one broken skill does not prevent the agent from booting. It is also strictly
12
+ read-only — no mkdir, no writes, no imports, no ``sys.path`` mutation — so an
13
+ existing skills root in the user's filesystem (e.g. from an unrelated project)
14
+ cannot be clobbered by the agent. Discovery itself only runs when the operator
15
+ sets ``AGENT_SKILLS_DIR``; an unset value is a no-op, matching the opt-in
16
+ semantics of ``AGENT_TOOLS_DIR``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import re
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ import yaml
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ NAME_MAX_CHARS = 64
31
+ DESCRIPTION_MAX_CHARS = 1024
32
+
33
+ _FRONTMATTER_RE = re.compile(
34
+ r"\A---\s*\n(?P<body>.*?)\n---\s*(?:\n|\Z)", re.DOTALL
35
+ )
36
+ _NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
37
+
38
+ SKILL_FILENAME = "SKILL.md"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class SkillRef:
43
+ """Metadata for one discovered skill.
44
+
45
+ Attributes:
46
+ name: Validated skill name (matches ``[a-z0-9]+(?:-[a-z0-9]+)*``,
47
+ capped at ``NAME_MAX_CHARS``). Equal to the parent directory name.
48
+ description: Free-form description, capped at ``DESCRIPTION_MAX_CHARS``.
49
+ path: Absolute path to the skill's ``SKILL.md``.
50
+ """
51
+
52
+ name: str
53
+ description: str
54
+ path: Path
55
+
56
+
57
+ def discover_skills(skills_dir: Path) -> list[SkillRef]:
58
+ """Walk ``skills_dir`` and return one ``SkillRef`` per valid skill.
59
+
60
+ Returns an empty list if the directory does not exist or contains no
61
+ valid skills. Skills are returned sorted by name for deterministic
62
+ prompt ordering across restarts.
63
+ """
64
+ if not skills_dir.exists() or not skills_dir.is_dir():
65
+ return []
66
+
67
+ refs: list[SkillRef] = []
68
+ for entry in sorted(skills_dir.iterdir()):
69
+ if not entry.is_dir():
70
+ continue
71
+ skill_md = entry / SKILL_FILENAME
72
+ if not skill_md.is_file():
73
+ logger.debug("skipping %s: no %s", entry, SKILL_FILENAME)
74
+ continue
75
+ ref = _parse_skill(skill_md)
76
+ if ref is not None:
77
+ refs.append(ref)
78
+ return refs
79
+
80
+
81
+ def _parse_skill(skill_md: Path) -> SkillRef | None:
82
+ try:
83
+ text = skill_md.read_text(encoding="utf-8")
84
+ except OSError as exc:
85
+ logger.warning("skill unreadable: %s (%s)", skill_md, exc)
86
+ return None
87
+
88
+ match = _FRONTMATTER_RE.match(text)
89
+ if match is None:
90
+ logger.warning("skill missing YAML frontmatter: %s", skill_md)
91
+ return None
92
+
93
+ try:
94
+ meta = yaml.safe_load(match.group("body")) or {}
95
+ except yaml.YAMLError as exc:
96
+ logger.warning("skill frontmatter not valid YAML: %s (%s)", skill_md, exc)
97
+ return None
98
+
99
+ if not isinstance(meta, dict):
100
+ logger.warning("skill frontmatter is not a mapping: %s", skill_md)
101
+ return None
102
+
103
+ raw_name = meta.get("name")
104
+ raw_desc = meta.get("description")
105
+ if not isinstance(raw_name, str) or not raw_name.strip():
106
+ logger.warning("skill missing required 'name': %s", skill_md)
107
+ return None
108
+ if not isinstance(raw_desc, str) or not raw_desc.strip():
109
+ logger.warning("skill missing required 'description': %s", skill_md)
110
+ return None
111
+
112
+ name = raw_name.strip()[:NAME_MAX_CHARS]
113
+ description = raw_desc.strip()[:DESCRIPTION_MAX_CHARS]
114
+
115
+ if not _NAME_RE.match(name):
116
+ logger.warning(
117
+ "skill name %r is not spec-compliant (a-z, 0-9, hyphens; no leading/"
118
+ "trailing/consecutive hyphens): %s",
119
+ name, skill_md,
120
+ )
121
+ return None
122
+
123
+ parent = skill_md.parent.name
124
+ if name != parent:
125
+ logger.warning(
126
+ "skill name %r does not match parent directory %r: %s",
127
+ name, parent, skill_md,
128
+ )
129
+ return None
130
+
131
+ return SkillRef(name=name, description=description, path=skill_md)
132
+
133
+
134
+ _PROGRESSIVE_DISCLOSURE_PREAMBLE = (
135
+ "## Skills\n"
136
+ "\n"
137
+ "You have access to specialized skills below. Each skill is a self-contained "
138
+ "capability stored on disk. Skills follow **progressive disclosure**: only "
139
+ "the name and description are loaded into this prompt. To activate a skill, "
140
+ "read its `SKILL.md` at the listed path — that loads the full instructions "
141
+ "into context. Skills may also bundle `scripts/`, `references/`, or "
142
+ "`assets/` alongside `SKILL.md`; load those on demand only when the task "
143
+ "requires them.\n"
144
+ "\n"
145
+ "Available skills:"
146
+ )
147
+
148
+
149
+ def format_skills_block(skills: list[SkillRef]) -> str | None:
150
+ """Render the system-prompt skills block, or ``None`` when no skills exist."""
151
+ if not skills:
152
+ return None
153
+ lines = [_PROGRESSIVE_DISCLOSURE_PREAMBLE]
154
+ for s in skills:
155
+ lines.append(f"- **{s.name}** (`{s.path}`): {s.description}")
156
+ return "\n".join(lines)
@@ -42,6 +42,7 @@ from agentlings.core.models import (
42
42
  TaskStarted,
43
43
  )
44
44
  from agentlings.core.prompt import build_system_prompt
45
+ from agentlings.core.skills import SkillRef
45
46
  from agentlings.core.store import JournalStore, TaskJournal
46
47
  from agentlings.core.telemetry import get_meter, otel_span
47
48
  from agentlings.tools.registry import ToolRegistry
@@ -351,6 +352,7 @@ class TaskWorker:
351
352
  registry: TaskRegistry,
352
353
  context_lock: asyncio.Lock,
353
354
  memory_store: MemoryFileStore | None = None,
355
+ skills: list[SkillRef] | None = None,
354
356
  ) -> None:
355
357
  self._record = record
356
358
  self._config = config
@@ -361,6 +363,7 @@ class TaskWorker:
361
363
  self._registry = registry
362
364
  self._context_lock = context_lock
363
365
  self._memory_store = memory_store
366
+ self._skills = skills or []
364
367
 
365
368
  async def run(self) -> None:
366
369
  """Drive the task to a terminal state.
@@ -461,6 +464,7 @@ class TaskWorker:
461
464
  data_dir=self._config.agent_data_dir,
462
465
  injection_prompt=memory_config.injection_prompt if memory_config else None,
463
466
  token_budget=memory_config.token_budget if memory_config else 2000,
467
+ skills=self._skills,
464
468
  )
465
469
 
466
470
  async def _on_turn(turn: Any) -> None:
@@ -573,12 +577,14 @@ class TaskEngine:
573
577
  llm: BaseLLMClient,
574
578
  tools: ToolRegistry,
575
579
  memory_store: MemoryFileStore | None = None,
580
+ skills: list[SkillRef] | None = None,
576
581
  ) -> None:
577
582
  self._config = config
578
583
  self._store = store
579
584
  self._llm = llm
580
585
  self._tools = tools
581
586
  self._memory_store = memory_store
587
+ self._skills = skills or []
582
588
  self._registry = TaskRegistry()
583
589
  self._context_locks: dict[str, asyncio.Lock] = {}
584
590
  self._context_locks_guard = asyncio.Lock()
@@ -689,6 +695,7 @@ class TaskEngine:
689
695
  registry=self._registry,
690
696
  context_lock=ctx_lock,
691
697
  memory_store=self._memory_store,
698
+ skills=self._skills,
692
699
  )
693
700
  asyncio_task = asyncio.create_task(worker.run(), name=f"task:{task_id}")
694
701
  self._workers[task_id] = asyncio_task