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.
Files changed (74) hide show
  1. agentkernel/__init__.py +7 -0
  2. agentkernel/__main__.py +5 -0
  3. agentkernel/agent.py +311 -0
  4. agentkernel/approval/__init__.py +23 -0
  5. agentkernel/approval/base.py +34 -0
  6. agentkernel/approval/cli.py +129 -0
  7. agentkernel/approval/policy.py +58 -0
  8. agentkernel/approval/risk.py +91 -0
  9. agentkernel/approval/sandbox.py +201 -0
  10. agentkernel/budget.py +64 -0
  11. agentkernel/checkpoint.py +50 -0
  12. agentkernel/cli.py +1482 -0
  13. agentkernel/config.py +224 -0
  14. agentkernel/context/__init__.py +17 -0
  15. agentkernel/context/manager.py +216 -0
  16. agentkernel/context/truncate.py +35 -0
  17. agentkernel/cron.py +146 -0
  18. agentkernel/curation.py +183 -0
  19. agentkernel/doctor.py +141 -0
  20. agentkernel/embeddings.py +132 -0
  21. agentkernel/evaluation.py +186 -0
  22. agentkernel/improvement.py +133 -0
  23. agentkernel/insights.py +141 -0
  24. agentkernel/kanban.py +114 -0
  25. agentkernel/knowledge.py +383 -0
  26. agentkernel/loops.py +145 -0
  27. agentkernel/mcp/__init__.py +23 -0
  28. agentkernel/mcp/client.py +181 -0
  29. agentkernel/mcp/config.py +59 -0
  30. agentkernel/mcp/tools.py +96 -0
  31. agentkernel/memory.py +1208 -0
  32. agentkernel/paths.py +73 -0
  33. agentkernel/plugins.py +76 -0
  34. agentkernel/profiles.py +70 -0
  35. agentkernel/progress.py +89 -0
  36. agentkernel/providers/__init__.py +35 -0
  37. agentkernel/providers/_http.py +157 -0
  38. agentkernel/providers/anthropic.py +282 -0
  39. agentkernel/providers/base.py +38 -0
  40. agentkernel/providers/credentials.py +65 -0
  41. agentkernel/providers/local.py +34 -0
  42. agentkernel/providers/openai.py +260 -0
  43. agentkernel/redaction.py +77 -0
  44. agentkernel/semantic_index.py +139 -0
  45. agentkernel/semantic_memory.py +253 -0
  46. agentkernel/skills.py +268 -0
  47. agentkernel/subagent.py +161 -0
  48. agentkernel/telemetry.py +199 -0
  49. agentkernel/templates/README.md +35 -0
  50. agentkernel/templates/SKILL.md +28 -0
  51. agentkernel/templates/eval-suite.toml +22 -0
  52. agentkernel/templates/loop.toml +29 -0
  53. agentkernel/templates/mcp-servers.toml +22 -0
  54. agentkernel/templates/profile.toml +29 -0
  55. agentkernel/templates/tool_module.py +64 -0
  56. agentkernel/tools/__init__.py +5 -0
  57. agentkernel/tools/base.py +100 -0
  58. agentkernel/tools/builtin/__init__.py +37 -0
  59. agentkernel/tools/builtin/checkpoint_tool.py +33 -0
  60. agentkernel/tools/builtin/clarify.py +60 -0
  61. agentkernel/tools/builtin/files.py +221 -0
  62. agentkernel/tools/builtin/kanban_tool.py +100 -0
  63. agentkernel/tools/builtin/search.py +225 -0
  64. agentkernel/tools/builtin/shell.py +67 -0
  65. agentkernel/tools/builtin/todo.py +106 -0
  66. agentkernel/tui/__init__.py +50 -0
  67. agentkernel/tui/app.py +594 -0
  68. agentkernel/types.py +127 -0
  69. agentkernel/worktree.py +64 -0
  70. agentkernel_cli-0.1.0.dist-info/METADATA +426 -0
  71. agentkernel_cli-0.1.0.dist-info/RECORD +74 -0
  72. agentkernel_cli-0.1.0.dist-info/WHEEL +4 -0
  73. agentkernel_cli-0.1.0.dist-info/entry_points.txt +2 -0
  74. agentkernel_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -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
+ ]