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,133 @@
1
+ """Self-improvement seam (design §13, Phase 7).
2
+
3
+ The kernel records structured telemetry from turn one. ``SelfImprover`` reads a
4
+ session trace, asks the configured provider to suggest one concise rule or
5
+ system-prompt addition, and writes the result as a markdown note in
6
+ ``.agentkernel/improvements``. It is intentionally lightweight — enough to close
7
+ the loop, with room for a future richer analyzer.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import time
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ if TYPE_CHECKING:
19
+ from agentkernel.providers import Provider
20
+
21
+
22
+ _REFLECTION_SYSTEM_PROMPT = (
23
+ "You are a self-improvement analyst for an agent kernel. "
24
+ "Given a session trace, propose one concise rule, instruction, or "
25
+ "system-prompt addition that would improve future runs. "
26
+ "Return only the rule text followed by a brief rationale."
27
+ )
28
+
29
+
30
+ @dataclass
31
+ class Improvement:
32
+ suggestion: str
33
+ rule: str
34
+ trace_path: str
35
+ output_path: str | None = None
36
+
37
+
38
+ def _load_trace(path: Path) -> list[dict[str, Any]]:
39
+ """Load the JSONL trace written by ``JsonlTelemetry``."""
40
+ records: list[dict[str, Any]] = []
41
+ with path.open("r", encoding="utf-8") as handle:
42
+ for line in handle:
43
+ line = line.strip()
44
+ if not line:
45
+ continue
46
+ try:
47
+ records.append(json.loads(line))
48
+ except json.JSONDecodeError:
49
+ continue
50
+ return records
51
+
52
+
53
+ def _summarize_trace(records: list[dict[str, Any]]) -> str:
54
+ """Build a compact textual summary suitable for an LLM prompt."""
55
+ lines: list[str] = []
56
+ for i, record in enumerate(records):
57
+ lines.append(f"--- turn {i} ---")
58
+ lines.append(f"model: {record.get('model', 'unknown')}")
59
+ lines.append(f"stop_reason: {record.get('stop_reason', 'unknown')}")
60
+ for call in record.get("tool_calls", []):
61
+ lines.append(
62
+ f"tool: {call.get('name')} approved={call.get('approved')} "
63
+ f"error={call.get('is_error')}"
64
+ )
65
+ # Note: the redacted JSONL trace (design §12) does not carry assistant
66
+ # text or raw tool args, so reflection works from the structural signal
67
+ # — tools used, errors, stop reasons, and token/cost figures.
68
+ cost = record.get("estimated_cost_usd")
69
+ if cost is not None:
70
+ lines.append(f"cost_usd: {cost}")
71
+ return "\n".join(lines)
72
+
73
+
74
+ class SelfImprover:
75
+ """Reflect on a completed session and emit a proposed improvement note."""
76
+
77
+ def __init__(self, provider: Provider, output_dir: str | Path) -> None:
78
+ self.provider = provider
79
+ self.output_dir = Path(output_dir)
80
+
81
+ def analyze_trace(self, trace_path: str | Path) -> Improvement:
82
+ trace_path = Path(trace_path)
83
+ records = _load_trace(trace_path)
84
+ summary = _summarize_trace(records)
85
+ prompt = (
86
+ f"Session trace summary:\n{summary}\n\n"
87
+ "Propose one concise improvement rule for the agent. "
88
+ "Start with the rule, then a one-line rationale."
89
+ )
90
+
91
+ from agentkernel.types import Message
92
+
93
+ messages = [Message(role="user", content=prompt)]
94
+ response = self.provider.complete(
95
+ messages,
96
+ [],
97
+ max_tokens=1024,
98
+ system=_REFLECTION_SYSTEM_PROMPT,
99
+ )
100
+ suggestion = response.message.content.strip()
101
+ rule = suggestion.splitlines()[0] if suggestion else suggestion
102
+
103
+ self.output_dir.mkdir(parents=True, exist_ok=True)
104
+ timestamp = time.strftime("%Y%m%d-%H%M%S")
105
+ output_path = self.output_dir / f"improvement-{timestamp}.md"
106
+ output_path.write_text(
107
+ f"---\n"
108
+ f"type: improvement\n"
109
+ f"trace: {trace_path}\n"
110
+ f"timestamp: {timestamp}\n"
111
+ f"---\n\n"
112
+ f"{suggestion}\n",
113
+ encoding="utf-8",
114
+ )
115
+
116
+ return Improvement(
117
+ suggestion=suggestion,
118
+ rule=rule,
119
+ trace_path=str(trace_path),
120
+ output_path=str(output_path),
121
+ )
122
+
123
+ def latest_trace(self, log_dir: str | Path) -> Path | None:
124
+ """Return the most recent ``*.jsonl`` trace in ``log_dir``."""
125
+ log_dir = Path(log_dir)
126
+ if not log_dir.is_dir():
127
+ return None
128
+ traces = sorted(
129
+ (p for p in log_dir.iterdir() if p.suffix == ".jsonl"),
130
+ key=lambda p: p.stat().st_mtime,
131
+ reverse=True,
132
+ )
133
+ return traces[0] if traces else None
@@ -0,0 +1,141 @@
1
+ """Usage insights from session traces (design §18.7).
2
+
3
+ Aggregates the JSONL telemetry under ``log_dir`` — the stable per-turn schema
4
+ from §12 — into a usage/cost/tool-frequency report. Pure reading: it never calls
5
+ a provider, so it works offline and costs nothing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections import defaultdict
12
+ from dataclasses import dataclass, field
13
+ from datetime import UTC, datetime, timedelta
14
+ from pathlib import Path
15
+
16
+
17
+ @dataclass
18
+ class ModelStats:
19
+ turns: int = 0
20
+ input_tokens: int = 0
21
+ output_tokens: int = 0
22
+ cost: float = 0.0
23
+
24
+
25
+ @dataclass
26
+ class ToolStats:
27
+ calls: int = 0
28
+ errors: int = 0
29
+
30
+
31
+ @dataclass
32
+ class Insights:
33
+ sessions: int = 0
34
+ turns: int = 0
35
+ input_tokens: int = 0
36
+ output_tokens: int = 0
37
+ cache_read_tokens: int = 0
38
+ cache_write_tokens: int = 0
39
+ total_cost: float = 0.0
40
+ compactions: int = 0
41
+ models: dict[str, ModelStats] = field(default_factory=lambda: defaultdict(ModelStats))
42
+ tools: dict[str, ToolStats] = field(default_factory=lambda: defaultdict(ToolStats))
43
+ models_without_price: set[str] = field(default_factory=set)
44
+
45
+
46
+ def _within(ts: str, cutoff: datetime | None) -> bool:
47
+ if cutoff is None:
48
+ return True
49
+ try:
50
+ return datetime.fromisoformat(ts) >= cutoff
51
+ except (ValueError, TypeError):
52
+ return True # undated records are kept rather than silently dropped
53
+
54
+
55
+ def aggregate_traces(log_dir: str | Path, *, days: int | None = None) -> Insights:
56
+ """Aggregate every ``*.jsonl`` trace under ``log_dir`` into one ``Insights``."""
57
+ directory = Path(log_dir)
58
+ cutoff = datetime.now(UTC) - timedelta(days=days) if days else None
59
+ ins = Insights()
60
+ if not directory.is_dir():
61
+ return ins
62
+
63
+ for trace in sorted(directory.glob("*.jsonl")):
64
+ counted_session = False
65
+ for line in trace.read_text(encoding="utf-8").splitlines():
66
+ line = line.strip()
67
+ if not line:
68
+ continue
69
+ try:
70
+ rec = json.loads(line)
71
+ except json.JSONDecodeError:
72
+ continue
73
+ if not _within(rec.get("ts", ""), cutoff):
74
+ continue
75
+ if not counted_session:
76
+ ins.sessions += 1
77
+ counted_session = True
78
+
79
+ ins.turns += 1
80
+ ins.input_tokens += rec.get("input_tokens", 0)
81
+ ins.output_tokens += rec.get("output_tokens", 0)
82
+ ins.cache_read_tokens += rec.get("cache_read_tokens", 0)
83
+ ins.cache_write_tokens += rec.get("cache_write_tokens", 0)
84
+ if rec.get("compaction"):
85
+ ins.compactions += 1
86
+
87
+ model = rec.get("model", "unknown")
88
+ ms = ins.models[model]
89
+ ms.turns += 1
90
+ ms.input_tokens += rec.get("input_tokens", 0)
91
+ ms.output_tokens += rec.get("output_tokens", 0)
92
+
93
+ cost = rec.get("estimated_cost_usd")
94
+ if cost is None:
95
+ ins.models_without_price.add(model)
96
+ else:
97
+ ins.total_cost += cost
98
+ ms.cost += cost
99
+
100
+ for call in rec.get("tool_calls", []) or []:
101
+ ts_ = ins.tools[call.get("name", "?")]
102
+ ts_.calls += 1
103
+ if call.get("is_error"):
104
+ ts_.errors += 1
105
+ return ins
106
+
107
+
108
+ def format_insights(ins: Insights, *, days: int | None = None) -> str:
109
+ """Render an ``Insights`` as a readable text report."""
110
+ scope = f" (last {days} day(s))" if days else ""
111
+ if ins.turns == 0:
112
+ return f"No trace records found{scope}."
113
+
114
+ lines = [f"Usage insights{scope}", ""]
115
+ lines.append(f" sessions: {ins.sessions}")
116
+ lines.append(f" turns: {ins.turns}")
117
+ lines.append(
118
+ f" tokens: in={ins.input_tokens:,} out={ins.output_tokens:,} "
119
+ f"cache_read={ins.cache_read_tokens:,} cache_write={ins.cache_write_tokens:,}"
120
+ )
121
+ cost_note = "" if not ins.models_without_price else (
122
+ f" (excludes {len(ins.models_without_price)} model(s) with no price table)"
123
+ )
124
+ lines.append(f" est. cost: ${ins.total_cost:.4f}{cost_note}")
125
+ lines.append(f" compactions: {ins.compactions}")
126
+
127
+ lines.append("")
128
+ lines.append("By model:")
129
+ for name, ms in sorted(ins.models.items(), key=lambda kv: kv[1].turns, reverse=True):
130
+ lines.append(
131
+ f" {name}: {ms.turns} turns, in={ms.input_tokens:,} out={ms.output_tokens:,}, "
132
+ f"${ms.cost:.4f}"
133
+ )
134
+
135
+ if ins.tools:
136
+ lines.append("")
137
+ lines.append("Tool usage (most used first):")
138
+ for name, ts_ in sorted(ins.tools.items(), key=lambda kv: kv[1].calls, reverse=True):
139
+ err = f", {ts_.errors} error(s)" if ts_.errors else ""
140
+ lines.append(f" {name}: {ts_.calls} call(s){err}")
141
+ return "\n".join(lines)
agentkernel/kanban.py ADDED
@@ -0,0 +1,114 @@
1
+ """A lightweight work-queue board for multi-agent coordination (design §18.3).
2
+
3
+ A durable JSON board of tasks that a long mission can fan out across — the parent
4
+ (or a human) files tasks, and workers (often spawned sub-agents) claim, work, and
5
+ complete or block them. Deliberately "lite": one JSON file, whole-file
6
+ read-modify-write, no daemon or dispatcher. For heavy multi-worker contention a
7
+ real SQLite board (cf. Hermes) would be the next step; this covers the common
8
+ case with a fraction of the surface area.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import uuid
15
+ from dataclasses import asdict, dataclass, field
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+
19
+ STATUSES = ("todo", "in_progress", "done", "blocked")
20
+
21
+
22
+ def _now() -> str:
23
+ return datetime.now(UTC).isoformat(timespec="seconds")
24
+
25
+
26
+ @dataclass
27
+ class Task:
28
+ id: str
29
+ title: str
30
+ status: str = "todo"
31
+ assignee: str | None = None
32
+ notes: list[str] = field(default_factory=list)
33
+ created: str = ""
34
+ updated: str = ""
35
+
36
+
37
+ class Board:
38
+ """JSON-backed kanban board. Each mutation rewrites the whole file."""
39
+
40
+ def __init__(self, path: str | Path) -> None:
41
+ self._path = Path(path)
42
+
43
+ # --- persistence ------------------------------------------------------
44
+ def _read(self) -> list[Task]:
45
+ if not self._path.is_file():
46
+ return []
47
+ try:
48
+ raw = json.loads(self._path.read_text(encoding="utf-8"))
49
+ except (json.JSONDecodeError, OSError):
50
+ return []
51
+ return [Task(**t) for t in raw if isinstance(t, dict)]
52
+
53
+ def _write(self, tasks: list[Task]) -> None:
54
+ self._path.parent.mkdir(parents=True, exist_ok=True)
55
+ self._path.write_text(
56
+ json.dumps([asdict(t) for t in tasks], indent=2), encoding="utf-8"
57
+ )
58
+
59
+ def _update(self, task_id: str, mutate) -> Task | None:
60
+ tasks = self._read()
61
+ for t in tasks:
62
+ if t.id == task_id:
63
+ mutate(t)
64
+ t.updated = _now()
65
+ self._write(tasks)
66
+ return t
67
+ return None
68
+
69
+ # --- operations -------------------------------------------------------
70
+ def list(self, status: str | None = None) -> list[Task]:
71
+ tasks = self._read()
72
+ return [t for t in tasks if status is None or t.status == status]
73
+
74
+ def get(self, task_id: str) -> Task | None:
75
+ return next((t for t in self._read() if t.id == task_id), None)
76
+
77
+ def add(self, title: str) -> Task:
78
+ task = Task(id=uuid.uuid4().hex[:8], title=title.strip(), created=_now(), updated=_now())
79
+ tasks = self._read()
80
+ tasks.append(task)
81
+ self._write(tasks)
82
+ return task
83
+
84
+ def claim(self, task_id: str, assignee: str) -> Task | None:
85
+ def _claim(t: Task) -> None:
86
+ t.assignee = assignee
87
+ t.status = "in_progress"
88
+ return self._update(task_id, _claim)
89
+
90
+ def complete(self, task_id: str) -> Task | None:
91
+ return self._update(task_id, lambda t: setattr(t, "status", "done"))
92
+
93
+ def block(self, task_id: str, reason: str) -> Task | None:
94
+ def _block(t: Task) -> None:
95
+ t.status = "blocked"
96
+ if reason:
97
+ t.notes.append(f"blocked: {reason}")
98
+ return self._update(task_id, _block)
99
+
100
+ def comment(self, task_id: str, text: str) -> Task | None:
101
+ return self._update(task_id, lambda t: t.notes.append(text))
102
+
103
+ def next_todo(self) -> Task | None:
104
+ """The first unclaimed task, for a worker pulling work off the board."""
105
+ return next((t for t in self._read() if t.status == "todo"), None)
106
+
107
+
108
+ _MARKS = {"todo": "[ ]", "in_progress": "[~]", "done": "[x]", "blocked": "[!]"}
109
+
110
+
111
+ def render_task(t: Task) -> str:
112
+ mark = _MARKS.get(t.status, "[ ]")
113
+ who = f" @{t.assignee}" if t.assignee else ""
114
+ return f"{mark} {t.id}{who} {t.title}"