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
|
@@ -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 @@
|
|
|
1
|
+
"""Pluggable tool system with built-in bash and filesystem implementations."""
|