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
@@ -0,0 +1,83 @@
1
+ """Agent Card generation from configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from a2a.types import (
6
+ AgentCapabilities,
7
+ AgentCard,
8
+ AgentInterface,
9
+ AgentSkill,
10
+ APIKeySecurityScheme,
11
+ SecurityRequirement,
12
+ SecurityScheme,
13
+ StringList,
14
+ )
15
+ from a2a.utils.constants import PROTOCOL_VERSION_1_0, TransportProtocol
16
+
17
+ from agentlings.config import AgentConfig
18
+
19
+
20
+ def generate_agent_card(config: AgentConfig) -> AgentCard:
21
+ """Generate an A2A Agent Card from the agent's configuration.
22
+
23
+ Skills are taken from the YAML definition if provided, otherwise a
24
+ single default skill is generated from the agent's name and description.
25
+
26
+ Args:
27
+ config: The agent configuration.
28
+
29
+ Returns:
30
+ A fully populated ``AgentCard`` instance.
31
+ """
32
+ if config.agent_external_url:
33
+ url = config.agent_external_url.rstrip("/") + "/a2a"
34
+ else:
35
+ url = f"http://{config.agent_host}:{config.agent_port}/a2a"
36
+
37
+ if config.skills:
38
+ skills = [
39
+ AgentSkill(
40
+ id=s.id,
41
+ name=s.name,
42
+ description=s.description,
43
+ tags=list(s.tags),
44
+ )
45
+ for s in config.skills
46
+ ]
47
+ else:
48
+ skills = [
49
+ AgentSkill(
50
+ id=config.agent_name,
51
+ name=config.agent_name,
52
+ description=config.agent_description,
53
+ tags=[],
54
+ )
55
+ ]
56
+
57
+ return AgentCard(
58
+ name=config.agent_name,
59
+ description=config.agent_description,
60
+ version="0.1.0",
61
+ supported_interfaces=[
62
+ AgentInterface(
63
+ url=url,
64
+ protocol_binding=TransportProtocol.JSONRPC,
65
+ protocol_version=PROTOCOL_VERSION_1_0,
66
+ ),
67
+ ],
68
+ skills=skills,
69
+ capabilities=AgentCapabilities(streaming=False, push_notifications=False),
70
+ default_input_modes=["text"],
71
+ default_output_modes=["text"],
72
+ security_schemes={
73
+ "apiKey": SecurityScheme(
74
+ api_key_security_scheme=APIKeySecurityScheme(
75
+ location="header",
76
+ name="X-API-Key",
77
+ )
78
+ )
79
+ },
80
+ security_requirements=[
81
+ SecurityRequirement(schemes={"apiKey": StringList(list=[])})
82
+ ],
83
+ )
@@ -0,0 +1,232 @@
1
+ """MCP protocol server exposing the agentling as a single task-aware tool.
2
+
3
+ The tool has four input fields:
4
+
5
+ - ``contextId`` (optional) — which conversation to append to or continue.
6
+ - ``message`` (optional) — a new request to the agent.
7
+ - ``taskId`` (optional) — an existing task to poll.
8
+ - ``waitSeconds`` (optional) — when polling, how long to block for completion.
9
+
10
+ ``message`` and ``taskId`` are mutually exclusive. Sending ``message`` starts a
11
+ task; sending ``taskId`` polls an existing one. In either case the response is
12
+ a ``CallToolResult`` whose ``structuredContent`` tells the caller whether the
13
+ task completed (and the final response) or is still working (with the taskId
14
+ to poll later).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ from typing import Any
22
+
23
+ from a2a.types import AgentCard
24
+ from mcp.server.lowlevel import Server
25
+ from mcp.types import TextContent, Tool
26
+
27
+ from agentlings.config import AgentConfig
28
+ from agentlings.core.loop import MessageLoop
29
+ from agentlings.core.task import (
30
+ ContextBusyError,
31
+ InvalidTaskInputError,
32
+ TaskContextMismatchError,
33
+ TaskNotFoundError,
34
+ TaskState,
35
+ TaskStatus,
36
+ )
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ def create_mcp_server(
42
+ loop: MessageLoop,
43
+ agent_card: AgentCard,
44
+ config: AgentConfig,
45
+ ) -> Server:
46
+ """Create an MCP server with a single task-aware tool.
47
+
48
+ Args:
49
+ loop: The message loop exposing the shared task engine.
50
+ agent_card: The agent card whose name and description define the tool.
51
+ config: Agent configuration (for the await timeout cap).
52
+
53
+ Returns:
54
+ A configured MCP ``Server`` instance.
55
+ """
56
+ server = Server(agent_card.name)
57
+ engine = loop.engine
58
+ await_seconds = float(getattr(config, "agent_task_await_seconds", 60))
59
+
60
+ tool = Tool(
61
+ name=agent_card.name,
62
+ description=(
63
+ f"{agent_card.description}\n\n"
64
+ "Exactly one of `message` or `taskId` must be provided:\n"
65
+ "- `message`: start a new task (natural-language request).\n"
66
+ "- `taskId`: poll an existing task returned by a prior call "
67
+ "whose status was `working`.\n\n"
68
+ "The response's `structuredContent.status` is either `completed` "
69
+ "(final response in `message`) or `working` (retry with `taskId`). "
70
+ "`waitSeconds` applies only to polls and is capped at "
71
+ f"{int(await_seconds)} seconds."
72
+ ),
73
+ inputSchema={
74
+ "type": "object",
75
+ "properties": {
76
+ "contextId": {
77
+ "type": "string",
78
+ "description": (
79
+ "Context ID from a previous response. Optional. Include "
80
+ "to continue an existing conversation."
81
+ ),
82
+ },
83
+ "message": {
84
+ "type": "string",
85
+ "description": (
86
+ "Natural language request to the agent. Mutually "
87
+ "exclusive with `taskId`."
88
+ ),
89
+ },
90
+ "taskId": {
91
+ "type": "string",
92
+ "description": (
93
+ "Task ID returned by a prior call that yielded a working "
94
+ "status. Mutually exclusive with `message`."
95
+ ),
96
+ },
97
+ "waitSeconds": {
98
+ "type": "number",
99
+ "minimum": 0,
100
+ "description": (
101
+ "Maximum seconds to block on a poll, capped server-side. "
102
+ "Ignored when `message` is provided."
103
+ ),
104
+ },
105
+ },
106
+ "additionalProperties": False,
107
+ },
108
+ )
109
+
110
+ @server.list_tools()
111
+ async def list_tools() -> list[Tool]:
112
+ return [tool]
113
+
114
+ @server.call_tool()
115
+ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
116
+ if name != agent_card.name:
117
+ return _error_payload("unknown_tool", f"Unknown tool: {name}")
118
+
119
+ message = arguments.get("message")
120
+ task_id = arguments.get("taskId")
121
+ context_id = arguments.get("contextId")
122
+ wait_seconds = arguments.get("waitSeconds", 0.0)
123
+
124
+ if (message is None or message == "") and not task_id:
125
+ return _error_payload(
126
+ "invalid_input",
127
+ "Either `message` or `taskId` must be provided.",
128
+ )
129
+ if message and task_id:
130
+ return _error_payload(
131
+ "invalid_input",
132
+ "`message` and `taskId` are mutually exclusive.",
133
+ )
134
+
135
+ try:
136
+ if task_id:
137
+ state = await engine.poll(
138
+ task_id=task_id,
139
+ context_id=context_id,
140
+ wait_seconds=wait_seconds,
141
+ cap_seconds=await_seconds,
142
+ )
143
+ else:
144
+ assert message is not None
145
+ state = await engine.spawn(
146
+ message=message,
147
+ context_id=context_id,
148
+ via="mcp",
149
+ await_seconds=await_seconds,
150
+ )
151
+ except ContextBusyError as e:
152
+ return _error_payload(
153
+ "context_busy",
154
+ str(e),
155
+ active_task_id=e.active_task_id,
156
+ context_id=e.context_id,
157
+ )
158
+ except TaskNotFoundError as e:
159
+ return _error_payload("task_not_found", str(e), task_id=e.task_id)
160
+ except TaskContextMismatchError as e:
161
+ return _error_payload(
162
+ "task_context_mismatch",
163
+ str(e),
164
+ task_id=e.task_id,
165
+ )
166
+ except InvalidTaskInputError as e:
167
+ return _error_payload("invalid_input", str(e))
168
+
169
+ return _format_state(state, await_seconds)
170
+
171
+ return server
172
+
173
+
174
+ def _format_state(state: TaskState, await_seconds: float) -> list[TextContent]:
175
+ """Turn a ``TaskState`` into an MCP ``CallToolResult`` payload."""
176
+ structured: dict[str, Any] = {
177
+ "taskId": state.task_id,
178
+ "contextId": state.context_id,
179
+ "status": state.status.value,
180
+ }
181
+ if state.status == TaskStatus.COMPLETED:
182
+ response_text = _extract_text(state.content)
183
+ structured["message"] = response_text
184
+ envelope = {
185
+ "contextId": state.context_id,
186
+ "taskId": state.task_id,
187
+ "message": response_text,
188
+ "status": state.status.value,
189
+ }
190
+ return [TextContent(
191
+ type="text",
192
+ text=json.dumps(envelope),
193
+ )]
194
+
195
+ if state.status == TaskStatus.WORKING:
196
+ structured["pollAfterMs"] = 10_000
197
+ human = (
198
+ f"Task {state.task_id} still running. "
199
+ f"Call again with taskId={state.task_id} to poll "
200
+ f"(waitSeconds up to {int(await_seconds)})."
201
+ )
202
+ envelope = {
203
+ "contextId": state.context_id,
204
+ "taskId": state.task_id,
205
+ "status": state.status.value,
206
+ "message": human,
207
+ "pollAfterMs": 10_000,
208
+ }
209
+ return [TextContent(type="text", text=json.dumps(envelope))]
210
+
211
+ # Failed or cancelled — return an error-shaped payload.
212
+ envelope = {
213
+ "contextId": state.context_id,
214
+ "taskId": state.task_id,
215
+ "status": state.status.value,
216
+ "error": state.error or state.status.value,
217
+ }
218
+ return [TextContent(type="text", text=json.dumps(envelope))]
219
+
220
+
221
+ def _error_payload(error: str, message: str, **extra: Any) -> list[TextContent]:
222
+ """Return an MCP tool result envelope for an application-level error."""
223
+ payload: dict[str, Any] = {"error": error, "message": message, **extra}
224
+ return [TextContent(type="text", text=json.dumps(payload))]
225
+
226
+
227
+ def _extract_text(content: list[dict[str, Any]]) -> str:
228
+ parts = []
229
+ for block in content:
230
+ if block.get("type") == "text":
231
+ parts.append(block.get("text", ""))
232
+ return "\n".join(parts)
agentlings/server.py ADDED
@@ -0,0 +1,247 @@
1
+ """Starlette application wiring A2A and MCP protocol endpoints on a single HTTP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from contextlib import asynccontextmanager
8
+ from typing import Any, AsyncIterator
9
+
10
+ import uvicorn
11
+ from a2a.server.request_handlers import DefaultRequestHandler
12
+ from a2a.server.routes.agent_card_routes import create_agent_card_routes
13
+ from a2a.server.routes.jsonrpc_routes import create_jsonrpc_routes
14
+ from mcp.server.streamable_http import StreamableHTTPServerTransport
15
+ from starlette.applications import Starlette
16
+ from starlette.middleware import Middleware
17
+ from starlette.middleware.base import BaseHTTPMiddleware
18
+ from starlette.requests import Request
19
+ from starlette.responses import JSONResponse, Response
20
+ from starlette.routing import Route
21
+
22
+ from agentlings.config import AgentConfig
23
+ from agentlings.core.llm import create_llm_client
24
+ from agentlings.core.loop import MessageLoop
25
+ from agentlings.core.memory_store import MemoryFileStore
26
+ from agentlings.core.scheduler import run_scheduler
27
+ from agentlings.core.sleep import SleepCycle
28
+ from agentlings.core.store import JournalStore
29
+ from agentlings.core.telemetry import init_telemetry
30
+ from agentlings.log import setup_logging
31
+ from agentlings.protocol.a2a import AgentlingExecutor
32
+ from agentlings.protocol.a2a_task_store import EngineTaskStore
33
+ from agentlings.protocol.agent_card import generate_agent_card
34
+ from agentlings.protocol.mcp import create_mcp_server
35
+ from agentlings.tools.memory import init_memory_tool
36
+ from agentlings.tools.registry import ToolRegistry
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ _PUBLIC_PATHS = {
41
+ "/.well-known/agent.json",
42
+ "/.well-known/agent-card.json",
43
+ }
44
+
45
+
46
+ class APIKeyMiddleware(BaseHTTPMiddleware):
47
+ """Middleware that enforces ``X-API-Key`` header authentication on non-public paths."""
48
+
49
+ def __init__(self, app: Any, api_key: str) -> None:
50
+ super().__init__(app)
51
+ self._api_key = api_key
52
+
53
+ async def dispatch(self, request: Request, call_next: Any) -> Response:
54
+ """Check the API key header and reject unauthorized requests.
55
+
56
+ Args:
57
+ request: The incoming HTTP request.
58
+ call_next: Callable to pass the request to the next middleware or route.
59
+
60
+ Returns:
61
+ The downstream response, or a 401 JSON response if unauthorized.
62
+ """
63
+ if request.url.path in _PUBLIC_PATHS:
64
+ return await call_next(request)
65
+
66
+ key = request.headers.get("x-api-key", "")
67
+ if key != self._api_key:
68
+ return JSONResponse({"error": "Unauthorized"}, status_code=401)
69
+
70
+ return await call_next(request)
71
+
72
+
73
+ def _create_app(config: AgentConfig | None = None) -> Starlette:
74
+ if config is None:
75
+ config = AgentConfig()
76
+
77
+ setup_logging(config.agent_log_level)
78
+
79
+ if config.telemetry_config:
80
+ init_telemetry(config.telemetry_config)
81
+
82
+ store = JournalStore(config.agent_data_dir)
83
+
84
+ memory_store: MemoryFileStore | None = None
85
+ if "memory" in config.enabled_tools or (
86
+ config.definition.memory is not None
87
+ ):
88
+ memory_store = MemoryFileStore(config.agent_data_dir)
89
+ init_memory_tool(memory_store)
90
+
91
+ tools = ToolRegistry()
92
+ tools.register_tools(config.enabled_tools, bash_timeout=config.definition.bash_timeout)
93
+
94
+ if not tools.tool_names():
95
+ logger.warning(
96
+ "no tools enabled — add tools to agent.yaml or set AGENT_CONFIG "
97
+ "(run 'agentling --list-tools' to see available options)"
98
+ )
99
+
100
+ llm = create_llm_client(
101
+ backend=config.agent_llm_backend,
102
+ api_key=config.anthropic_api_key,
103
+ model=config.agent_model,
104
+ max_tokens=config.agent_max_tokens,
105
+ tool_names=tools.tool_names(),
106
+ base_url=config.anthropic_base_url,
107
+ )
108
+
109
+ loop = MessageLoop(
110
+ config=config, store=store, llm=llm, tools=tools,
111
+ memory_store=memory_store,
112
+ )
113
+ # Startup crash recovery: reconcile orphaned task sub-journals and partial
114
+ # merge-backs before accepting new traffic.
115
+ try:
116
+ loop.engine.recover_on_startup()
117
+ except Exception: # noqa: BLE001 — recovery is best-effort
118
+ logger.exception("crash recovery pass failed")
119
+
120
+ agent_card = generate_agent_card(config)
121
+
122
+ executor = AgentlingExecutor(loop, config)
123
+ request_handler = DefaultRequestHandler(
124
+ agent_executor=executor,
125
+ task_store=EngineTaskStore(loop.engine),
126
+ agent_card=agent_card,
127
+ )
128
+
129
+ mcp_server = create_mcp_server(loop=loop, agent_card=agent_card, config=config)
130
+ transport: StreamableHTTPServerTransport | None = None
131
+
132
+ async def mcp_endpoint(request: Request) -> Response:
133
+ assert transport is not None
134
+ await transport.handle_request(
135
+ request.scope, request.receive, request._send # type: ignore[attr-defined]
136
+ )
137
+ return Response()
138
+
139
+ sleep_cycle: SleepCycle | None = None
140
+ if config.sleep_config and config.sleep_config.enabled and memory_store:
141
+ sleep_cycle = SleepCycle(
142
+ config=config, llm=llm, memory_store=memory_store, store=store,
143
+ )
144
+ elif config.sleep_config and not config.sleep_config.enabled:
145
+ logger.info("sleep cycle disabled by config (sleep.enabled=false)")
146
+
147
+ @asynccontextmanager
148
+ async def lifespan(app: Starlette) -> AsyncIterator[None]:
149
+ nonlocal transport
150
+ transport = StreamableHTTPServerTransport(mcp_session_id=None)
151
+
152
+ background_tasks: list[asyncio.Task[None]] = []
153
+
154
+ async with transport.connect() as (read_stream, write_stream):
155
+ mcp_task = asyncio.create_task(
156
+ mcp_server.run(
157
+ read_stream,
158
+ write_stream,
159
+ mcp_server.create_initialization_options(),
160
+ )
161
+ )
162
+ background_tasks.append(mcp_task)
163
+
164
+ if sleep_cycle and config.sleep_config:
165
+ scheduler_task = asyncio.create_task(
166
+ run_scheduler(
167
+ expression=config.sleep_config.schedule,
168
+ callback=sleep_cycle.run,
169
+ )
170
+ )
171
+ background_tasks.append(scheduler_task)
172
+ logger.info(
173
+ "sleep scheduler started: %s", config.sleep_config.schedule,
174
+ )
175
+
176
+ logger.info(
177
+ "agentling '%s' started on %s:%s",
178
+ config.agent_name,
179
+ config.agent_host,
180
+ config.agent_port,
181
+ )
182
+ try:
183
+ yield
184
+ finally:
185
+ # Drain live task workers before background services so
186
+ # merge-backs in flight complete against a valid store.
187
+ try:
188
+ await loop.engine.shutdown()
189
+ except Exception: # noqa: BLE001 — shutdown is best-effort
190
+ logger.exception("task engine shutdown raised")
191
+
192
+ for t in background_tasks:
193
+ t.cancel()
194
+ try:
195
+ await t
196
+ except asyncio.CancelledError:
197
+ pass
198
+
199
+ jsonrpc_routes = create_jsonrpc_routes(
200
+ request_handler=request_handler,
201
+ rpc_url="/a2a",
202
+ enable_v0_3_compat=True,
203
+ )
204
+ agent_card_routes = create_agent_card_routes(agent_card=agent_card)
205
+ # The 0.3 well-known path is ``/.well-known/agent.json``; 1.0 uses
206
+ # ``/.well-known/agent-card.json``. Expose both so existing 0.3 clients
207
+ # continue to discover the card.
208
+ legacy_card_routes = create_agent_card_routes(
209
+ agent_card=agent_card, card_url="/.well-known/agent.json"
210
+ )
211
+
212
+ routes = [
213
+ Route("/mcp", mcp_endpoint, methods=["GET", "POST", "DELETE"]),
214
+ *jsonrpc_routes,
215
+ *agent_card_routes,
216
+ *legacy_card_routes,
217
+ ]
218
+
219
+ middleware = [
220
+ Middleware(APIKeyMiddleware, api_key=config.agent_api_key),
221
+ ]
222
+
223
+ app = Starlette(
224
+ routes=routes,
225
+ middleware=middleware,
226
+ lifespan=lifespan,
227
+ )
228
+
229
+ return app
230
+
231
+
232
+ def run(config: AgentConfig | None = None) -> None:
233
+ """Build the application and start the uvicorn server.
234
+
235
+ Args:
236
+ config: Optional agent configuration; defaults are loaded from the environment.
237
+ """
238
+ if config is None:
239
+ config = AgentConfig()
240
+
241
+ app = _create_app(config)
242
+ uvicorn.run(
243
+ app,
244
+ host=config.agent_host,
245
+ port=config.agent_port,
246
+ log_level=config.agent_log_level.lower(),
247
+ )
@@ -0,0 +1 @@
1
+ """Bundled agent templates used by ``agentling init --template <name>``."""
@@ -0,0 +1,16 @@
1
+ # Required: your Anthropic API key. Leave empty if pointing at an
2
+ # Anthropic-compatible backend (e.g. Ollama) that ignores the header.
3
+ ANTHROPIC_API_KEY=
4
+
5
+ # Optional: override the Messages endpoint. Set to e.g. http://localhost:11434
6
+ # to talk to Ollama's Anthropic-compatible API.
7
+ # ANTHROPIC_BASE_URL=
8
+
9
+ # Auto-generated by `agentling init`. This is the API key clients send
10
+ # in the X-API-Key header to talk to this agent. Rotate by replacing.
11
+ AGENT_API_KEY=
12
+
13
+ # Optional overrides
14
+ # AGENT_MODEL=claude-sonnet-4-6
15
+ # AGENT_PORT=8420
16
+ # AGENT_LOG_LEVEL=INFO
@@ -0,0 +1,14 @@
1
+ name: {{NAME}}
2
+ description: A lightweight AI agent
3
+
4
+ tools: []
5
+
6
+ skills: []
7
+
8
+ memory:
9
+ token_budget: 2000
10
+ inject_into_prompt: true
11
+
12
+ sleep:
13
+ enabled: true
14
+ schedule: "0 2 * * *"
@@ -0,0 +1 @@
1
+ """Pluggable tool system with built-in bash and filesystem implementations."""