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/config.py ADDED
@@ -0,0 +1,260 @@
1
+ """Agent configuration from environment variables and optional YAML agent definition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+
9
+
10
+ import yaml
11
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
12
+ from pydantic_settings import BaseSettings, SettingsConfigDict
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class MemoryConfig(BaseModel):
18
+ """Memory subsystem configuration.
19
+
20
+ Attributes:
21
+ token_budget: Maximum tokens for the memory block injected into the system prompt.
22
+ injection_prompt: Override template for the memory injection block.
23
+ Receives ``{entries}`` placeholder. ``None`` uses the built-in default.
24
+ inject_into_prompt: When ``False``, the memory store is still active and
25
+ the ``memory_*`` tools are still callable, but no memory block is
26
+ stapled into the system prompt. The agent must read memory
27
+ explicitly via the tool. Useful for small/local models where the
28
+ prompt budget is precious.
29
+ """
30
+
31
+ model_config = ConfigDict(extra="ignore")
32
+
33
+ token_budget: int = 2000
34
+ injection_prompt: str | None = None
35
+ inject_into_prompt: bool = True
36
+
37
+
38
+ class SleepConfig(BaseModel):
39
+ """Nightly sleep cycle configuration.
40
+
41
+ Attributes:
42
+ enabled: When ``False``, the sleep cycle is not scheduled even if
43
+ the block is present. Set this for backends that lack the
44
+ Anthropic batches API (e.g. Ollama's compatibility layer).
45
+ schedule: Cron expression for when to run (default 2am daily).
46
+ journal_retention_days: How long to keep journal files.
47
+ conversation_retention_days: How long to keep JSONL conversation files.
48
+ memory_max_entries: Hard cap on memory entries after consolidation.
49
+ model: Model override for sleep LLM calls (``None`` uses agent default).
50
+ summary_prompt: Override for the per-conversation summary prompt.
51
+ consolidation_prompt: Override for the REM consolidation prompt.
52
+ """
53
+
54
+ model_config = ConfigDict(extra="ignore")
55
+
56
+ enabled: bool = True
57
+ schedule: str = "0 2 * * *"
58
+ journal_retention_days: int = 30
59
+ conversation_retention_days: int = 14
60
+ memory_max_entries: int = 50
61
+ model: str | None = None
62
+ summary_prompt: str | None = None
63
+ consolidation_prompt: str | None = None
64
+
65
+
66
+ class TelemetryConfig(BaseModel):
67
+ """OpenTelemetry configuration.
68
+
69
+ Attributes:
70
+ enabled: Whether telemetry is active.
71
+ endpoint: OTLP collector endpoint URL.
72
+ protocol: Collector protocol (``"http"`` or ``"grpc"``).
73
+ service_name: Service name for spans and metrics.
74
+ insecure: Disable TLS for the collector connection.
75
+ """
76
+
77
+ model_config = ConfigDict(extra="ignore")
78
+
79
+ enabled: bool = False
80
+ endpoint: str = "http://localhost:4318"
81
+ protocol: str = "http"
82
+ service_name: str = "agentling"
83
+ insecure: bool = True
84
+ headers: dict[str, str] = Field(default_factory=dict)
85
+
86
+
87
+ class SkillConfig(BaseModel):
88
+ """A skill advertised in the Agent Card.
89
+
90
+ Attributes:
91
+ id: Unique skill identifier.
92
+ name: Human-readable skill name.
93
+ description: What this skill does.
94
+ tags: Searchable tags for discovery.
95
+ """
96
+
97
+ id: str
98
+ name: str
99
+ description: str
100
+ tags: list[str] = Field(default_factory=list)
101
+
102
+
103
+ class AgentDefinition(BaseModel):
104
+ """Agent identity and behaviour loaded from YAML.
105
+
106
+ Attributes:
107
+ name: Agent name used in the Agent Card and MCP tool.
108
+ description: Agent description for discovery.
109
+ tools: Tool names or groups to enable (e.g. ``["bash", "filesystem"]``).
110
+ skills: Skills to advertise in the Agent Card.
111
+ system_prompt: The system prompt sent to the LLM.
112
+ bash_timeout: Default timeout in seconds for bash tool commands.
113
+ data_dir_awareness: When ``True`` (default), append a system-prompt
114
+ block telling the agent where its data directory lives and how
115
+ to read journals and conversation logs. Set to ``False`` for
116
+ agents without filesystem tools or when the prompt budget is
117
+ tight (e.g. small local models).
118
+ """
119
+
120
+ model_config = ConfigDict(extra="ignore")
121
+
122
+ name: str = "agentling"
123
+ description: str = "A lightweight AI agent"
124
+ tools: list[str] = Field(default_factory=list)
125
+ skills: list[SkillConfig] = Field(default_factory=list)
126
+ system_prompt: str | None = None
127
+ bash_timeout: int = Field(ge=1, default=30)
128
+ data_dir_awareness: bool = True
129
+ memory: MemoryConfig | None = None
130
+ sleep: SleepConfig | None = None
131
+ telemetry: TelemetryConfig | None = None
132
+
133
+
134
+ class AgentConfig(BaseSettings):
135
+ """Runtime configuration for an agentling instance.
136
+
137
+ Secrets and runtime settings come from environment variables.
138
+ Agent identity (name, description, skills, tools, system prompt) comes
139
+ from a YAML file pointed to by ``AGENT_CONFIG``.
140
+ """
141
+
142
+ model_config = SettingsConfigDict(
143
+ env_file=".env",
144
+ env_file_encoding="utf-8",
145
+ extra="ignore",
146
+ )
147
+
148
+ anthropic_api_key: str = ""
149
+ anthropic_base_url: str | None = None
150
+ agent_api_key: str = ""
151
+ agent_model: str = "claude-sonnet-4-6"
152
+ agent_max_tokens: int = 4096
153
+ agent_host: str = "0.0.0.0"
154
+ agent_port: int = 8420
155
+ agent_data_dir: Path = Path("./data")
156
+ agent_log_level: str = "INFO"
157
+ agent_llm_backend: Literal["anthropic", "mock"] = "anthropic"
158
+ agent_external_url: str | None = None
159
+ agent_config: str | None = None
160
+ agent_otel_endpoint: str | None = None
161
+ agent_otel_protocol: str = "http"
162
+ agent_otel_insecure: bool = True
163
+ agent_otel_headers: str = ""
164
+ agent_task_await_seconds: int = 60
165
+
166
+ _definition: AgentDefinition = AgentDefinition()
167
+
168
+ @model_validator(mode="after")
169
+ def _init(self) -> AgentConfig:
170
+ self.agent_data_dir.mkdir(parents=True, exist_ok=True)
171
+ if self.agent_config:
172
+ self._definition = _load_definition(self.agent_config)
173
+ return self
174
+
175
+ @property
176
+ def definition(self) -> AgentDefinition:
177
+ """The agent definition loaded from YAML (or defaults)."""
178
+ return self._definition
179
+
180
+ @property
181
+ def agent_name(self) -> str:
182
+ """Agent name from the YAML definition."""
183
+ return self._definition.name
184
+
185
+ @property
186
+ def agent_description(self) -> str:
187
+ """Agent description from the YAML definition."""
188
+ return self._definition.description
189
+
190
+ @property
191
+ def enabled_tools(self) -> list[str]:
192
+ """Tool names/groups to activate from the YAML definition."""
193
+ return self._definition.tools
194
+
195
+ @property
196
+ def system_prompt(self) -> str | None:
197
+ """System prompt from the YAML definition."""
198
+ return self._definition.system_prompt
199
+
200
+ @property
201
+ def skills(self) -> list[SkillConfig]:
202
+ """Skills to advertise from the YAML definition."""
203
+ return self._definition.skills
204
+
205
+ @property
206
+ def memory_config(self) -> MemoryConfig | None:
207
+ """Memory configuration from the YAML definition."""
208
+ return self._definition.memory
209
+
210
+ @property
211
+ def sleep_config(self) -> SleepConfig | None:
212
+ """Sleep cycle configuration from the YAML definition."""
213
+ return self._definition.sleep
214
+
215
+ @property
216
+ def telemetry_config(self) -> TelemetryConfig | None:
217
+ """Telemetry configuration, with env vars overriding YAML values."""
218
+ base = self._definition.telemetry
219
+ if self.agent_otel_endpoint:
220
+ if base is None:
221
+ base = TelemetryConfig(enabled=True)
222
+ updates: dict[str, Any] = {
223
+ "enabled": True,
224
+ "endpoint": self.agent_otel_endpoint,
225
+ "protocol": self.agent_otel_protocol,
226
+ "insecure": self.agent_otel_insecure,
227
+ }
228
+ if self.agent_otel_headers:
229
+ updates["headers"] = _parse_headers(self.agent_otel_headers)
230
+ base = base.model_copy(update=updates)
231
+ return base
232
+
233
+
234
+ def _parse_headers(raw: str) -> dict[str, str]:
235
+ """Parse a comma-separated ``key=value`` string into a headers dict.
236
+
237
+ Example: ``"Authorization=Bearer tok,X-Custom=val"``
238
+ """
239
+ headers: dict[str, str] = {}
240
+ for pair in raw.split(","):
241
+ pair = pair.strip()
242
+ if "=" in pair:
243
+ k, v = pair.split("=", 1)
244
+ headers[k.strip()] = v.strip()
245
+ return headers
246
+
247
+
248
+ def _load_definition(path: str) -> AgentDefinition:
249
+ """Load an ``AgentDefinition`` from a YAML file.
250
+
251
+ Args:
252
+ path: Path to the YAML configuration file.
253
+
254
+ Returns:
255
+ A validated ``AgentDefinition``.
256
+ """
257
+ raw = Path(path).read_text(encoding="utf-8")
258
+ data = yaml.safe_load(raw) or {}
259
+ logger.info("loaded agent definition from %s", path)
260
+ return AgentDefinition.model_validate(data)
@@ -0,0 +1 @@
1
+ """Core engine: message loop, LLM client, conversation store, and system prompt."""
@@ -0,0 +1,219 @@
1
+ """Reusable LLM completion cycle: call model, execute tools, repeat until terminal response."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import functools
7
+ import logging
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Awaitable, Callable
11
+
12
+ from agentlings.core.llm import BaseLLMClient, LLMResponse
13
+ from agentlings.core.telemetry import get_meter, otel_span
14
+ from agentlings.tools.registry import ToolRegistry
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class CancellationRequested(Exception):
20
+ """Raised inside ``run_completion`` when a cancel callback returns ``True``.
21
+
22
+ Callers are expected to catch this and apply their own cancel terminal
23
+ semantics (e.g. writing a ``TaskCancelled`` marker). The ``partial``
24
+ attribute carries a ``CompletionResult`` with turns completed so far.
25
+ """
26
+
27
+ def __init__(self, message: str, partial: "CompletionResult") -> None:
28
+ super().__init__(message)
29
+ self.partial = partial
30
+
31
+ @functools.lru_cache(maxsize=1)
32
+ def _get_metrics() -> dict[str, Any]:
33
+ m = get_meter()
34
+ return {
35
+ "duration": m.create_histogram(
36
+ "agentling.completion.duration_seconds",
37
+ description="End-to-end completion cycle duration",
38
+ ),
39
+ "turns": m.create_histogram(
40
+ "agentling.completion.turns",
41
+ description="Number of LLM turns per completion cycle",
42
+ ),
43
+ "tool_calls": m.create_counter(
44
+ "agentling.tool.calls",
45
+ description="Total tool invocations",
46
+ ),
47
+ "tool_errors": m.create_counter(
48
+ "agentling.tool.errors",
49
+ description="Tool execution errors",
50
+ ),
51
+ }
52
+
53
+
54
+ @dataclass
55
+ class CompletionTurn:
56
+ """A single assistant response and its corresponding tool results (if any).
57
+
58
+ Attributes:
59
+ response: The LLM response for this turn.
60
+ tool_results: Tool result content blocks returned to the LLM, empty for terminal turns.
61
+ """
62
+
63
+ response: LLMResponse
64
+ tool_results: list[dict[str, Any]] = field(default_factory=list)
65
+
66
+
67
+ @dataclass
68
+ class CompletionResult:
69
+ """Result of running the LLM completion cycle.
70
+
71
+ Attributes:
72
+ content: Anthropic-format content blocks from the final LLM response.
73
+ stop_reason: Why the model stopped generating.
74
+ turns: Every turn in the cycle, each pairing an LLM response with its tool results.
75
+ """
76
+
77
+ content: list[dict[str, Any]]
78
+ stop_reason: str | None = None
79
+ turns: list[CompletionTurn] = field(default_factory=list)
80
+
81
+
82
+ async def run_completion(
83
+ llm: BaseLLMClient,
84
+ system: list[dict[str, Any]],
85
+ messages: list[dict[str, Any]],
86
+ tools: ToolRegistry,
87
+ turn_callback: Callable[[CompletionTurn], Awaitable[None]] | None = None,
88
+ should_cancel: Callable[[], bool] | None = None,
89
+ ) -> CompletionResult:
90
+ """Run the LLM in a loop, executing tool calls until a terminal text response.
91
+
92
+ This is the core interaction cycle extracted from the message loop so that
93
+ both conversation handling and the sleep cycle can reuse it.
94
+
95
+ Args:
96
+ llm: The LLM client to use for completions.
97
+ system: System prompt blocks.
98
+ messages: Conversation messages (mutated in place with tool results).
99
+ tools: Registry of available tools.
100
+ turn_callback: Optional async callback invoked after each completed turn
101
+ (with the turn that just concluded). Used by the task engine to
102
+ journal per-turn progress into the sub-journal.
103
+ should_cancel: Optional synchronous predicate checked at cooperative
104
+ checkpoints — after each LLM call, before each tool batch, and
105
+ after each tool batch. When it returns ``True``,
106
+ ``CancellationRequested`` is raised and all partial progress so
107
+ far is available in ``CompletionResult`` via a raised exception
108
+ attribute.
109
+
110
+ Returns:
111
+ The final response content and all intermediate responses.
112
+
113
+ Raises:
114
+ CancellationRequested: If ``should_cancel`` returns ``True`` at a
115
+ checkpoint. The exception carries a ``partial`` attribute with a
116
+ ``CompletionResult`` containing turns completed so far.
117
+ """
118
+ tool_schemas = tools.list_schemas()
119
+ turns: list[CompletionTurn] = []
120
+ cycle_start = time.monotonic()
121
+ metrics = _get_metrics()
122
+
123
+ def _check_cancel() -> None:
124
+ if should_cancel is not None and should_cancel():
125
+ partial = CompletionResult(
126
+ content=turns[-1].response.content if turns else [],
127
+ stop_reason="cancelled",
128
+ turns=turns,
129
+ )
130
+ raise CancellationRequested("cancellation requested", partial)
131
+
132
+ with otel_span("agentling.completion") as cycle_span:
133
+ while True:
134
+ turn_number = len(turns) + 1
135
+
136
+ with otel_span("agentling.completion.llm_call", {"completion.turn": turn_number}):
137
+ response = await llm.complete(system, messages, tool_schemas)
138
+
139
+ _check_cancel()
140
+
141
+ has_tool_use = any(
142
+ block.get("type") == "tool_use" for block in response.content
143
+ )
144
+
145
+ if not has_tool_use:
146
+ terminal_turn = CompletionTurn(response=response)
147
+ turns.append(terminal_turn)
148
+ if turn_callback is not None:
149
+ await turn_callback(terminal_turn)
150
+ elapsed = time.monotonic() - cycle_start
151
+ logger.info(
152
+ "completion finished: %d turn(s) in %.1fs, stop_reason=%s",
153
+ len(turns), elapsed, response.stop_reason,
154
+ )
155
+ cycle_span.set_attribute("completion.turns", len(turns))
156
+ cycle_span.set_attribute("completion.stop_reason", response.stop_reason or "unknown")
157
+ cycle_span.set_attribute("completion.duration_s", round(elapsed, 2))
158
+ metrics["duration"].record(elapsed)
159
+ metrics["turns"].record(len(turns))
160
+ return CompletionResult(
161
+ content=response.content,
162
+ stop_reason=response.stop_reason,
163
+ turns=turns,
164
+ )
165
+
166
+ messages.append({"role": "assistant", "content": response.content})
167
+
168
+ tool_calls = [b for b in response.content if b.get("type") == "tool_use"]
169
+ logger.info(
170
+ "turn %d: executing %d tool(s): %s",
171
+ turn_number, len(tool_calls),
172
+ ", ".join(b["name"] for b in tool_calls),
173
+ )
174
+
175
+ _check_cancel()
176
+
177
+ async def _exec_tool(block: dict[str, Any]) -> dict[str, Any]:
178
+ try:
179
+ with otel_span("agentling.completion.tool_exec", {
180
+ "tool.name": block["name"],
181
+ "completion.turn": turn_number,
182
+ }) as tool_span:
183
+ result = await tools.execute(block["name"], block.get("input", {}))
184
+ tool_span.set_attribute("tool.is_error", result.is_error)
185
+
186
+ tool_attrs = {"tool.name": block["name"]}
187
+ metrics["tool_calls"].add(1, tool_attrs)
188
+ if result.is_error:
189
+ metrics["tool_errors"].add(1, tool_attrs)
190
+ logger.warning("tool %s returned error: %.200s", block["name"], result.output)
191
+
192
+ return {
193
+ "type": "tool_result",
194
+ "tool_use_id": block["id"],
195
+ "content": result.output,
196
+ "is_error": result.is_error,
197
+ }
198
+ except asyncio.CancelledError:
199
+ raise
200
+ except Exception:
201
+ logger.exception("unhandled exception in tool %s", block["name"])
202
+ metrics["tool_calls"].add(1, {"tool.name": block["name"]})
203
+ metrics["tool_errors"].add(1, {"tool.name": block["name"]})
204
+ return {
205
+ "type": "tool_result",
206
+ "tool_use_id": block["id"],
207
+ "content": f"Tool {block['name']} failed with an internal error",
208
+ "is_error": True,
209
+ }
210
+
211
+ tool_results = list(await asyncio.gather(*(_exec_tool(b) for b in tool_calls)))
212
+
213
+ _check_cancel()
214
+
215
+ turn = CompletionTurn(response=response, tool_results=tool_results)
216
+ turns.append(turn)
217
+ if turn_callback is not None:
218
+ await turn_callback(turn)
219
+ messages.append({"role": "user", "content": tool_results})