agentkernel-cli 0.1.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.
- agentkernel/__init__.py +7 -0
- agentkernel/__main__.py +5 -0
- agentkernel/agent.py +311 -0
- agentkernel/approval/__init__.py +23 -0
- agentkernel/approval/base.py +34 -0
- agentkernel/approval/cli.py +129 -0
- agentkernel/approval/policy.py +58 -0
- agentkernel/approval/risk.py +91 -0
- agentkernel/approval/sandbox.py +201 -0
- agentkernel/budget.py +64 -0
- agentkernel/checkpoint.py +50 -0
- agentkernel/cli.py +1482 -0
- agentkernel/config.py +224 -0
- agentkernel/context/__init__.py +17 -0
- agentkernel/context/manager.py +216 -0
- agentkernel/context/truncate.py +35 -0
- agentkernel/cron.py +146 -0
- agentkernel/curation.py +183 -0
- agentkernel/doctor.py +141 -0
- agentkernel/embeddings.py +132 -0
- agentkernel/evaluation.py +186 -0
- agentkernel/improvement.py +133 -0
- agentkernel/insights.py +141 -0
- agentkernel/kanban.py +114 -0
- agentkernel/knowledge.py +383 -0
- agentkernel/loops.py +145 -0
- agentkernel/mcp/__init__.py +23 -0
- agentkernel/mcp/client.py +181 -0
- agentkernel/mcp/config.py +59 -0
- agentkernel/mcp/tools.py +96 -0
- agentkernel/memory.py +1208 -0
- agentkernel/paths.py +73 -0
- agentkernel/plugins.py +76 -0
- agentkernel/profiles.py +70 -0
- agentkernel/progress.py +89 -0
- agentkernel/providers/__init__.py +35 -0
- agentkernel/providers/_http.py +157 -0
- agentkernel/providers/anthropic.py +282 -0
- agentkernel/providers/base.py +38 -0
- agentkernel/providers/credentials.py +65 -0
- agentkernel/providers/local.py +34 -0
- agentkernel/providers/openai.py +260 -0
- agentkernel/redaction.py +77 -0
- agentkernel/semantic_index.py +139 -0
- agentkernel/semantic_memory.py +253 -0
- agentkernel/skills.py +268 -0
- agentkernel/subagent.py +161 -0
- agentkernel/telemetry.py +199 -0
- agentkernel/templates/README.md +35 -0
- agentkernel/templates/SKILL.md +28 -0
- agentkernel/templates/eval-suite.toml +22 -0
- agentkernel/templates/loop.toml +29 -0
- agentkernel/templates/mcp-servers.toml +22 -0
- agentkernel/templates/profile.toml +29 -0
- agentkernel/templates/tool_module.py +64 -0
- agentkernel/tools/__init__.py +5 -0
- agentkernel/tools/base.py +100 -0
- agentkernel/tools/builtin/__init__.py +37 -0
- agentkernel/tools/builtin/checkpoint_tool.py +33 -0
- agentkernel/tools/builtin/clarify.py +60 -0
- agentkernel/tools/builtin/files.py +221 -0
- agentkernel/tools/builtin/kanban_tool.py +100 -0
- agentkernel/tools/builtin/search.py +225 -0
- agentkernel/tools/builtin/shell.py +67 -0
- agentkernel/tools/builtin/todo.py +106 -0
- agentkernel/tui/__init__.py +50 -0
- agentkernel/tui/app.py +594 -0
- agentkernel/types.py +127 -0
- agentkernel/worktree.py +64 -0
- agentkernel_cli-0.1.0.dist-info/METADATA +426 -0
- agentkernel_cli-0.1.0.dist-info/RECORD +74 -0
- agentkernel_cli-0.1.0.dist-info/WHEEL +4 -0
- agentkernel_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agentkernel_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
agentkernel/knowledge.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Knowledge graph seam (design §13, Phase 6).
|
|
2
|
+
|
|
3
|
+
A tiny, file-backed triple store. It is exposed to the loop as ordinary
|
|
4
|
+
registered tools so the kernel itself does not need any special state for it.
|
|
5
|
+
|
|
6
|
+
The feature set is intentionally minimal but no longer a stub: exact and
|
|
7
|
+
substring queries, one-hop neighbors, shortest-path traversal, and basic stats
|
|
8
|
+
are exposed as ordinary tools.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Fact:
|
|
21
|
+
subject: str
|
|
22
|
+
predicate: str
|
|
23
|
+
object: str
|
|
24
|
+
source: str | None = None
|
|
25
|
+
|
|
26
|
+
def matches(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
subject: str | None = None,
|
|
30
|
+
predicate: str | None = None,
|
|
31
|
+
object: str | None = None,
|
|
32
|
+
like: str | None = None,
|
|
33
|
+
) -> bool:
|
|
34
|
+
if subject is not None and self.subject != subject:
|
|
35
|
+
return False
|
|
36
|
+
if predicate is not None and self.predicate != predicate:
|
|
37
|
+
return False
|
|
38
|
+
if object is not None and self.object != object:
|
|
39
|
+
return False
|
|
40
|
+
if like is not None:
|
|
41
|
+
needle = like.lower()
|
|
42
|
+
haystack = f"{self.subject} {self.predicate} {self.object}".lower()
|
|
43
|
+
if needle not in haystack:
|
|
44
|
+
return False
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict[str, Any]:
|
|
48
|
+
return {
|
|
49
|
+
"subject": self.subject,
|
|
50
|
+
"predicate": self.predicate,
|
|
51
|
+
"object": self.object,
|
|
52
|
+
"source": self.source,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class KnowledgeGraph:
|
|
57
|
+
"""Append-only triple store backed by a JSONL file.
|
|
58
|
+
|
|
59
|
+
Exact (subject, predicate, object) triples are deduplicated on add so the
|
|
60
|
+
graph cannot grow without bound when the model repeats a fact. Queries are
|
|
61
|
+
exact by default; pass ``like`` for a case-insensitive substring search.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, path: str | Path | None = None) -> None:
|
|
65
|
+
self.path = Path(path) if path else Path(".agentkernel/graph.jsonl")
|
|
66
|
+
self._facts: list[Fact] = []
|
|
67
|
+
if self.path.exists():
|
|
68
|
+
self._load()
|
|
69
|
+
|
|
70
|
+
def _load(self) -> None:
|
|
71
|
+
facts: list[Fact] = []
|
|
72
|
+
with self.path.open("r", encoding="utf-8") as handle:
|
|
73
|
+
for line in handle:
|
|
74
|
+
line = line.strip()
|
|
75
|
+
if not line:
|
|
76
|
+
continue
|
|
77
|
+
try:
|
|
78
|
+
data = json.loads(line)
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
continue
|
|
81
|
+
facts.append(
|
|
82
|
+
Fact(
|
|
83
|
+
subject=data.get("subject", "").strip(),
|
|
84
|
+
predicate=data.get("predicate", "").strip(),
|
|
85
|
+
object=data.get("object", "").strip(),
|
|
86
|
+
source=data.get("source"),
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
self._facts = facts
|
|
90
|
+
|
|
91
|
+
def _save(self) -> None:
|
|
92
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
with self.path.open("w", encoding="utf-8") as handle:
|
|
94
|
+
for fact in self._facts:
|
|
95
|
+
handle.write(json.dumps(fact.to_dict(), ensure_ascii=False) + "\n")
|
|
96
|
+
|
|
97
|
+
def add(
|
|
98
|
+
self,
|
|
99
|
+
subject: str,
|
|
100
|
+
predicate: str,
|
|
101
|
+
object: str,
|
|
102
|
+
source: str | None = None,
|
|
103
|
+
) -> Fact:
|
|
104
|
+
"""Add a fact. Exact (subject, predicate, object) duplicates are ignored."""
|
|
105
|
+
fact = Fact(subject.strip(), predicate.strip(), object.strip(), source)
|
|
106
|
+
for existing in self._facts:
|
|
107
|
+
if existing == fact:
|
|
108
|
+
return existing
|
|
109
|
+
self._facts.append(fact)
|
|
110
|
+
self._save()
|
|
111
|
+
return fact
|
|
112
|
+
|
|
113
|
+
def query(
|
|
114
|
+
self,
|
|
115
|
+
*,
|
|
116
|
+
subject: str | None = None,
|
|
117
|
+
predicate: str | None = None,
|
|
118
|
+
object: str | None = None,
|
|
119
|
+
like: str | None = None,
|
|
120
|
+
) -> list[Fact]:
|
|
121
|
+
"""Return facts matching all provided exact filters and optional substring."""
|
|
122
|
+
return [
|
|
123
|
+
fact
|
|
124
|
+
for fact in self._facts
|
|
125
|
+
if fact.matches(
|
|
126
|
+
subject=subject, predicate=predicate, object=object, like=like
|
|
127
|
+
)
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
def neighbors(
|
|
131
|
+
self,
|
|
132
|
+
entity: str,
|
|
133
|
+
*,
|
|
134
|
+
predicate: str | None = None,
|
|
135
|
+
direction: str = "out",
|
|
136
|
+
) -> dict[str, Any]:
|
|
137
|
+
"""Return one-hop neighbors of ``entity``.
|
|
138
|
+
|
|
139
|
+
``direction`` is one of ``out`` (subject -> object), ``in`` (object ->
|
|
140
|
+
subject), or ``both``. Both ``incoming`` and ``outgoing`` keys are always
|
|
141
|
+
present so callers have a stable JSON shape.
|
|
142
|
+
"""
|
|
143
|
+
entity = entity.strip()
|
|
144
|
+
outgoing: list[dict[str, Any]] = []
|
|
145
|
+
incoming: list[dict[str, Any]] = []
|
|
146
|
+
|
|
147
|
+
for fact in self._facts:
|
|
148
|
+
if predicate is not None and fact.predicate != predicate:
|
|
149
|
+
continue
|
|
150
|
+
if direction in ("out", "both") and fact.subject == entity:
|
|
151
|
+
outgoing.append({"predicate": fact.predicate, "object": fact.object})
|
|
152
|
+
if direction in ("in", "both") and fact.object == entity:
|
|
153
|
+
incoming.append({"predicate": fact.predicate, "subject": fact.subject})
|
|
154
|
+
|
|
155
|
+
result = {"entity": entity, "outgoing": outgoing, "incoming": incoming}
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
def find_path(
|
|
159
|
+
self,
|
|
160
|
+
from_subject: str,
|
|
161
|
+
to_entity: str,
|
|
162
|
+
*,
|
|
163
|
+
max_depth: int = 5,
|
|
164
|
+
) -> list[Fact]:
|
|
165
|
+
"""Shortest undirected path between two entities, up to ``max_depth`` hops."""
|
|
166
|
+
start = from_subject.strip()
|
|
167
|
+
goal = to_entity.strip()
|
|
168
|
+
if start == goal:
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
# BFS over an undirected view of the graph.
|
|
172
|
+
visited: set[str] = {start}
|
|
173
|
+
queue: list[tuple[str, list[Fact]]] = [(start, [])]
|
|
174
|
+
|
|
175
|
+
while queue:
|
|
176
|
+
current, trail = queue.pop(0)
|
|
177
|
+
if len(trail) >= max_depth:
|
|
178
|
+
continue
|
|
179
|
+
for fact in self._facts:
|
|
180
|
+
if fact.subject == current:
|
|
181
|
+
next_node = fact.object
|
|
182
|
+
elif fact.object == current:
|
|
183
|
+
next_node = fact.subject
|
|
184
|
+
else:
|
|
185
|
+
continue
|
|
186
|
+
if next_node in visited:
|
|
187
|
+
continue
|
|
188
|
+
new_trail = trail + [fact]
|
|
189
|
+
if next_node == goal:
|
|
190
|
+
return new_trail
|
|
191
|
+
visited.add(next_node)
|
|
192
|
+
queue.append((next_node, new_trail))
|
|
193
|
+
|
|
194
|
+
return []
|
|
195
|
+
|
|
196
|
+
def stats(self) -> dict[str, int]:
|
|
197
|
+
"""Basic graph metrics."""
|
|
198
|
+
subjects = {f.subject for f in self._facts}
|
|
199
|
+
objects = {f.object for f in self._facts}
|
|
200
|
+
predicates = {f.predicate for f in self._facts}
|
|
201
|
+
return {
|
|
202
|
+
"facts": len(self._facts),
|
|
203
|
+
"entities": len(subjects | objects),
|
|
204
|
+
"subjects": len(subjects),
|
|
205
|
+
"objects": len(objects),
|
|
206
|
+
"predicates": len(predicates),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
def to_dicts(self) -> list[dict[str, Any]]:
|
|
210
|
+
return [f.to_dict() for f in self._facts]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def make_graph_tools(graph: KnowledgeGraph) -> list[Any]:
|
|
214
|
+
"""Return ToolSpec instances for the knowledge-graph tools.
|
|
215
|
+
|
|
216
|
+
Importing ``ToolSpec`` here avoids a circular import at module load time.
|
|
217
|
+
"""
|
|
218
|
+
from agentkernel.tools import ToolSpec
|
|
219
|
+
from agentkernel.types import ToolResult
|
|
220
|
+
|
|
221
|
+
def _missing_fields(
|
|
222
|
+
arguments: dict[str, Any], required: set[str]
|
|
223
|
+
) -> ToolResult | None:
|
|
224
|
+
"""Return an error ToolResult if any required field is missing or empty."""
|
|
225
|
+
missing = required - arguments.keys()
|
|
226
|
+
if missing:
|
|
227
|
+
return ToolResult(
|
|
228
|
+
"",
|
|
229
|
+
f"Missing required fields: {sorted(missing)}",
|
|
230
|
+
is_error=True,
|
|
231
|
+
)
|
|
232
|
+
empty_required = [
|
|
233
|
+
k for k in required if not str(arguments.get(k, "")).strip()
|
|
234
|
+
]
|
|
235
|
+
if empty_required:
|
|
236
|
+
return ToolResult(
|
|
237
|
+
"",
|
|
238
|
+
f"Empty required fields: {sorted(empty_required)}",
|
|
239
|
+
is_error=True,
|
|
240
|
+
)
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
def _add(arguments: dict[str, Any]) -> ToolResult:
|
|
244
|
+
err = _missing_fields(arguments, {"subject", "predicate", "object"})
|
|
245
|
+
if err is not None:
|
|
246
|
+
return err
|
|
247
|
+
graph.add(
|
|
248
|
+
arguments["subject"],
|
|
249
|
+
arguments["predicate"],
|
|
250
|
+
arguments["object"],
|
|
251
|
+
arguments.get("source"),
|
|
252
|
+
)
|
|
253
|
+
return ToolResult("", "Fact added.")
|
|
254
|
+
|
|
255
|
+
def _query(arguments: dict[str, Any]) -> ToolResult:
|
|
256
|
+
results = graph.query(
|
|
257
|
+
subject=arguments.get("subject"),
|
|
258
|
+
predicate=arguments.get("predicate"),
|
|
259
|
+
object=arguments.get("object"),
|
|
260
|
+
like=arguments.get("like"),
|
|
261
|
+
)
|
|
262
|
+
return ToolResult(
|
|
263
|
+
"",
|
|
264
|
+
json.dumps([f.to_dict() for f in results], ensure_ascii=False),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def _neighbors(arguments: dict[str, Any]) -> ToolResult:
|
|
268
|
+
err = _missing_fields(arguments, {"entity"})
|
|
269
|
+
if err is not None:
|
|
270
|
+
return err
|
|
271
|
+
result = graph.neighbors(
|
|
272
|
+
arguments["entity"],
|
|
273
|
+
predicate=arguments.get("predicate"),
|
|
274
|
+
direction=arguments.get("direction", "out"),
|
|
275
|
+
)
|
|
276
|
+
return ToolResult("", json.dumps(result, ensure_ascii=False))
|
|
277
|
+
|
|
278
|
+
def _path(arguments: dict[str, Any]) -> ToolResult:
|
|
279
|
+
err = _missing_fields(arguments, {"from", "to"})
|
|
280
|
+
if err is not None:
|
|
281
|
+
return err
|
|
282
|
+
trail = graph.find_path(
|
|
283
|
+
arguments["from"],
|
|
284
|
+
arguments["to"],
|
|
285
|
+
max_depth=int(arguments.get("max_depth", 5)),
|
|
286
|
+
)
|
|
287
|
+
return ToolResult(
|
|
288
|
+
"", json.dumps([f.to_dict() for f in trail], ensure_ascii=False)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def _stats(arguments: dict[str, Any]) -> ToolResult:
|
|
292
|
+
return ToolResult("", json.dumps(graph.stats(), ensure_ascii=False))
|
|
293
|
+
|
|
294
|
+
return [
|
|
295
|
+
ToolSpec(
|
|
296
|
+
name="graph_add",
|
|
297
|
+
description=(
|
|
298
|
+
"Add a fact to the knowledge graph as (subject, predicate, object). "
|
|
299
|
+
"Exact duplicates are ignored."
|
|
300
|
+
),
|
|
301
|
+
parameters={
|
|
302
|
+
"type": "object",
|
|
303
|
+
"properties": {
|
|
304
|
+
"subject": {"type": "string", "description": "Entity"},
|
|
305
|
+
"predicate": {"type": "string", "description": "Relationship"},
|
|
306
|
+
"object": {"type": "string", "description": "Entity or value"},
|
|
307
|
+
"source": {"type": "string", "description": "Optional source note"},
|
|
308
|
+
},
|
|
309
|
+
"required": ["subject", "predicate", "object"],
|
|
310
|
+
"additionalProperties": False,
|
|
311
|
+
},
|
|
312
|
+
handler=_add,
|
|
313
|
+
mutates=True,
|
|
314
|
+
),
|
|
315
|
+
ToolSpec(
|
|
316
|
+
name="graph_query",
|
|
317
|
+
description=(
|
|
318
|
+
"Query facts in the knowledge graph. Any field may be omitted to match all. "
|
|
319
|
+
"Use `like` for a case-insensitive substring search across "
|
|
320
|
+
"subjects, predicates, or objects."
|
|
321
|
+
),
|
|
322
|
+
parameters={
|
|
323
|
+
"type": "object",
|
|
324
|
+
"properties": {
|
|
325
|
+
"subject": {"type": "string"},
|
|
326
|
+
"predicate": {"type": "string"},
|
|
327
|
+
"object": {"type": "string"},
|
|
328
|
+
"like": {"type": "string", "description": "Case-insensitive substring"},
|
|
329
|
+
},
|
|
330
|
+
"additionalProperties": False,
|
|
331
|
+
},
|
|
332
|
+
handler=_query,
|
|
333
|
+
),
|
|
334
|
+
ToolSpec(
|
|
335
|
+
name="graph_neighbors",
|
|
336
|
+
description=(
|
|
337
|
+
"List one-hop neighbors of an entity. direction may be 'out' (default), "
|
|
338
|
+
"'in', or 'both'."
|
|
339
|
+
),
|
|
340
|
+
parameters={
|
|
341
|
+
"type": "object",
|
|
342
|
+
"properties": {
|
|
343
|
+
"entity": {"type": "string"},
|
|
344
|
+
"predicate": {"type": "string"},
|
|
345
|
+
"direction": {
|
|
346
|
+
"type": "string",
|
|
347
|
+
"enum": ["out", "in", "both"],
|
|
348
|
+
"default": "out",
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
"required": ["entity"],
|
|
352
|
+
"additionalProperties": False,
|
|
353
|
+
},
|
|
354
|
+
handler=_neighbors,
|
|
355
|
+
),
|
|
356
|
+
ToolSpec(
|
|
357
|
+
name="graph_path",
|
|
358
|
+
description=(
|
|
359
|
+
"Find the shortest undirected path between two entities, up to max_depth hops."
|
|
360
|
+
),
|
|
361
|
+
parameters={
|
|
362
|
+
"type": "object",
|
|
363
|
+
"properties": {
|
|
364
|
+
"from": {"type": "string"},
|
|
365
|
+
"to": {"type": "string"},
|
|
366
|
+
"max_depth": {"type": "integer", "default": 5},
|
|
367
|
+
},
|
|
368
|
+
"required": ["from", "to"],
|
|
369
|
+
"additionalProperties": False,
|
|
370
|
+
},
|
|
371
|
+
handler=_path,
|
|
372
|
+
),
|
|
373
|
+
ToolSpec(
|
|
374
|
+
name="graph_stats",
|
|
375
|
+
description="Return counts of facts, entities, and predicates in the graph.",
|
|
376
|
+
parameters={
|
|
377
|
+
"type": "object",
|
|
378
|
+
"properties": {},
|
|
379
|
+
"additionalProperties": False,
|
|
380
|
+
},
|
|
381
|
+
handler=_stats,
|
|
382
|
+
),
|
|
383
|
+
]
|
agentkernel/loops.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Loop engineering: repeatable workflows with built-in stopping conditions.
|
|
2
|
+
|
|
3
|
+
A "loop" (cf. the Forward Future loop-library) is a workflow the agent runs
|
|
4
|
+
repeatedly until a condition is met — the **action → evaluation → iteration →
|
|
5
|
+
stopping condition** pattern. This is an *outer* loop around ``Agent.run``: it
|
|
6
|
+
re-invokes the agent on the loop's prompt, optionally checks success with a
|
|
7
|
+
shell command, and stops on a streak of successes or when iterations run out.
|
|
8
|
+
|
|
9
|
+
Loops are defined in TOML or sourced from a skill body, so they compose with the
|
|
10
|
+
skills system. The runner builds a fresh agent per iteration (independent
|
|
11
|
+
context) via the injected factory; the success check runs in the injected
|
|
12
|
+
sandbox, so a loop can verify its own work (tests pass, build is green, …).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import tomllib
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from agentkernel.agent import Agent
|
|
25
|
+
from agentkernel.approval import Sandbox
|
|
26
|
+
from agentkernel.skills import SkillLibrary
|
|
27
|
+
|
|
28
|
+
AgentFactory = Callable[[], "Agent"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Loop:
|
|
33
|
+
"""A repeatable workflow with a stopping condition."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
prompt: str # the workflow instructions handed to the agent each iteration
|
|
37
|
+
description: str = ""
|
|
38
|
+
max_iterations: int = 5
|
|
39
|
+
success_check: str | None = None # shell command; exit 0 == success
|
|
40
|
+
success_streak: int = 1 # require this many consecutive successes to stop
|
|
41
|
+
cwd: str = "."
|
|
42
|
+
check_timeout: int = 120
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class LoopIteration:
|
|
47
|
+
index: int
|
|
48
|
+
answer: str
|
|
49
|
+
check_passed: bool | None # None when there is no success_check
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class LoopResult:
|
|
54
|
+
name: str
|
|
55
|
+
iterations: list[LoopIteration] = field(default_factory=list)
|
|
56
|
+
succeeded: bool = False
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def count(self) -> int:
|
|
60
|
+
return len(self.iterations)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class LoopRunner:
|
|
64
|
+
"""Runs a :class:`Loop` until its stopping condition (or max iterations)."""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
agent_factory: AgentFactory,
|
|
69
|
+
*,
|
|
70
|
+
sandbox: Sandbox | None = None,
|
|
71
|
+
output_fn: Callable[[str], None] | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
self._agent_factory = agent_factory
|
|
74
|
+
self._sandbox = sandbox
|
|
75
|
+
self._emit = output_fn or (lambda _msg: None)
|
|
76
|
+
|
|
77
|
+
def run(self, loop: Loop) -> LoopResult:
|
|
78
|
+
result = LoopResult(name=loop.name)
|
|
79
|
+
streak = 0
|
|
80
|
+
for i in range(loop.max_iterations):
|
|
81
|
+
answer = self._agent_factory().run(loop.prompt)
|
|
82
|
+
passed = self._check(loop)
|
|
83
|
+
result.iterations.append(LoopIteration(i, answer, passed))
|
|
84
|
+
status = "ok" if passed else ("fail" if passed is False else "done")
|
|
85
|
+
self._emit(f" iteration {i + 1}/{loop.max_iterations}: {status}")
|
|
86
|
+
|
|
87
|
+
if passed is False:
|
|
88
|
+
streak = 0
|
|
89
|
+
continue
|
|
90
|
+
# passed is True or None (no check) — both count toward the streak.
|
|
91
|
+
streak += 1
|
|
92
|
+
if streak >= loop.success_streak:
|
|
93
|
+
result.succeeded = True
|
|
94
|
+
return result
|
|
95
|
+
result.succeeded = streak >= loop.success_streak
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
def _check(self, loop: Loop) -> bool | None:
|
|
99
|
+
if not loop.success_check:
|
|
100
|
+
return None # no programmatic check; the workflow itself decides
|
|
101
|
+
if self._sandbox is None:
|
|
102
|
+
return None
|
|
103
|
+
code, _out, _err = self._sandbox.run(
|
|
104
|
+
loop.success_check, cwd=loop.cwd, timeout=loop.check_timeout
|
|
105
|
+
)
|
|
106
|
+
return code == 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_loop(path: str | Path) -> Loop:
|
|
110
|
+
"""Load a loop from a TOML file (keys mirror :class:`Loop` fields)."""
|
|
111
|
+
data = tomllib.loads(Path(path).read_text(encoding="utf-8"))
|
|
112
|
+
return Loop(
|
|
113
|
+
name=data.get("name", Path(path).stem),
|
|
114
|
+
prompt=data["prompt"],
|
|
115
|
+
description=data.get("description", ""),
|
|
116
|
+
max_iterations=int(data.get("max_iterations", 5)),
|
|
117
|
+
success_check=data.get("success_check"),
|
|
118
|
+
success_streak=int(data.get("success_streak", 1)),
|
|
119
|
+
cwd=data.get("cwd", "."),
|
|
120
|
+
check_timeout=int(data.get("check_timeout", 120)),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def loop_from_skill(
|
|
125
|
+
library: SkillLibrary,
|
|
126
|
+
name: str,
|
|
127
|
+
*,
|
|
128
|
+
max_iterations: int = 5,
|
|
129
|
+
success_check: str | None = None,
|
|
130
|
+
success_streak: int = 1,
|
|
131
|
+
cwd: str = ".",
|
|
132
|
+
) -> Loop | None:
|
|
133
|
+
"""Build a loop whose prompt is a skill's body, composing the two systems."""
|
|
134
|
+
skill = library.get(name)
|
|
135
|
+
if skill is None:
|
|
136
|
+
return None
|
|
137
|
+
return Loop(
|
|
138
|
+
name=name,
|
|
139
|
+
prompt=skill.body,
|
|
140
|
+
description=skill.description,
|
|
141
|
+
max_iterations=max_iterations,
|
|
142
|
+
success_check=success_check,
|
|
143
|
+
success_streak=success_streak,
|
|
144
|
+
cwd=cwd,
|
|
145
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""MCP client (Phase 2, design §13).
|
|
2
|
+
|
|
3
|
+
An MCP client connects to a server, discovers its tools, and registers each as
|
|
4
|
+
an ordinary ``ToolSpec`` whose handler issues an MCP ``tools/call``. This is the
|
|
5
|
+
test of whether §6 is right: **no loop or registry change is required** — an
|
|
6
|
+
MCP-backed tool and a native builtin register identically.
|
|
7
|
+
|
|
8
|
+
Hand-written over JSON-RPC 2.0 stdio, consistent with the kernel's
|
|
9
|
+
dependency-light, no-frameworks stance (no ``mcp`` SDK dependency).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from agentkernel.mcp.client import MCPClient, MCPError
|
|
13
|
+
from agentkernel.mcp.config import MCPServerConfig, load_mcp_servers
|
|
14
|
+
from agentkernel.mcp.tools import mcp_tool_specs, register_mcp_servers
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"MCPClient",
|
|
18
|
+
"MCPError",
|
|
19
|
+
"MCPServerConfig",
|
|
20
|
+
"load_mcp_servers",
|
|
21
|
+
"mcp_tool_specs",
|
|
22
|
+
"register_mcp_servers",
|
|
23
|
+
]
|