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
agentkernel/skills.py ADDED
@@ -0,0 +1,268 @@
1
+ """Skills, Anthropic-style (design §13, Phase 4).
2
+
3
+ A skill is a reusable bundle of instructions the model can apply. This follows
4
+ the Anthropic Agent Skills shape: a ``SKILL.md`` with YAML frontmatter
5
+ (``name`` + ``description``) and a markdown body, optionally alongside bundled
6
+ scripts/resources in the same folder.
7
+
8
+ Disclosure is **progressive** (hybrid): only each skill's name + description
9
+ sits in the cacheable prefix (a lightweight catalog, always present), and the
10
+ model loads a skill's full body + file listing on demand via the ``use_skill``
11
+ tool. A skill may additionally be *pinned* (``active``) so its full body is
12
+ injected into the prefix up front.
13
+
14
+ Discovery accepts three layouts so existing skills keep working:
15
+ * ``<dir>/<skill>/SKILL.md`` — Anthropic-style folder (bundled files allowed)
16
+ * ``<dir>/<skill>.md`` — a loose markdown file (optional ``---`` frontmatter)
17
+ * ``<dir>/<skill>.toml`` — frontmatter-only (``name`` + ``system_prompt``)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import tomllib
24
+ from collections.abc import Sequence
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Protocol, runtime_checkable
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Skill:
32
+ """A named skill: a description (for disclosure) + a body (the instructions)."""
33
+
34
+ name: str
35
+ description: str
36
+ body: str
37
+ source: Path | None = None
38
+ resources: tuple[str, ...] = () # bundled file paths, relative to cwd
39
+
40
+ @property
41
+ def system_prompt(self) -> str: # back-compat alias
42
+ return self.body
43
+
44
+
45
+ @runtime_checkable
46
+ class ContextSource(Protocol):
47
+ """Anything that contributes extra system-prompt text per run."""
48
+
49
+ def system_additions(self) -> list[str]: ...
50
+
51
+
52
+ def _split_frontmatter(text: str) -> tuple[dict[str, object], str]:
53
+ """Split a ``---`` delimited frontmatter block from the body.
54
+
55
+ Returns ``(metadata, body)``. The frontmatter is parsed as a small YAML
56
+ subset (flat ``key: value`` plus ``- item`` lists) — enough for SKILL.md,
57
+ without taking a YAML dependency.
58
+ """
59
+ if not text.startswith("---"):
60
+ return {}, text
61
+ lines = text.splitlines()
62
+ # Find the closing '---' after the opening one.
63
+ close = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
64
+ if close is None:
65
+ return {}, text
66
+ meta = _parse_yaml_subset(lines[1:close])
67
+ body = "\n".join(lines[close + 1 :]).strip()
68
+ return meta, body
69
+
70
+
71
+ def _parse_yaml_subset(lines: Sequence[str]) -> dict[str, object]:
72
+ meta: dict[str, object] = {}
73
+ list_key: str | None = None
74
+ for raw in lines:
75
+ if not raw.strip() or raw.lstrip().startswith("#"):
76
+ continue
77
+ if list_key is not None and raw.lstrip().startswith("- "):
78
+ if not isinstance(meta.get(list_key), list):
79
+ meta[list_key] = [] # convert the empty-scalar placeholder to a list
80
+ meta[list_key].append(_unquote(raw.lstrip()[2:].strip())) # type: ignore[union-attr]
81
+ continue
82
+ list_key = None
83
+ key, sep, value = raw.partition(":")
84
+ if not sep:
85
+ continue
86
+ key = key.strip()
87
+ value = value.strip()
88
+ if value == "":
89
+ list_key = key # a block list may follow
90
+ meta[key] = ""
91
+ else:
92
+ meta[key] = _unquote(value)
93
+ return meta
94
+
95
+
96
+ def _unquote(value: str) -> str:
97
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
98
+ return value[1:-1]
99
+ return value
100
+
101
+
102
+ class SkillLibrary:
103
+ """Discovers skills in a directory and exposes them as a ContextSource.
104
+
105
+ ``active_skills`` are *pinned*: their full bodies join the prefix in addition
106
+ to the always-present catalog.
107
+ """
108
+
109
+ def __init__(self, directory: str | Path, active_skills: Sequence[str] | None = None) -> None:
110
+ self.directory = Path(directory) if directory else Path("skills")
111
+ self.active_skills: set[str] = set(active_skills or [])
112
+ self._skills: dict[str, Skill] = {}
113
+ if self.directory.is_dir():
114
+ self._load()
115
+
116
+ # --- discovery ---------------------------------------------------------
117
+
118
+ def _load(self) -> None:
119
+ for path in sorted(self.directory.iterdir()):
120
+ skill: Skill | None = None
121
+ if path.is_dir() and (path / "SKILL.md").is_file():
122
+ skill = self._load_skill_md(path / "SKILL.md", bundle_dir=path)
123
+ elif path.suffix.lower() == ".md":
124
+ skill = self._load_skill_md(path, bundle_dir=None)
125
+ elif path.suffix.lower() == ".toml":
126
+ skill = self._load_toml(path)
127
+ if skill is not None:
128
+ self._skills[skill.name] = skill
129
+
130
+ def _load_skill_md(self, path: Path, *, bundle_dir: Path | None) -> Skill | None:
131
+ text = path.read_text(encoding="utf-8")
132
+ meta, body = _split_frontmatter(text)
133
+ name = str(
134
+ meta.get("name") or path.stem
135
+ if not bundle_dir
136
+ else meta.get("name") or bundle_dir.name
137
+ )
138
+ description = str(meta.get("description") or _first_line(body))
139
+ resources: tuple[str, ...] = ()
140
+ if bundle_dir is not None:
141
+ resources = tuple(
142
+ _relpath(p)
143
+ for p in sorted(bundle_dir.rglob("*"))
144
+ if p.is_file() and p.name != "SKILL.md"
145
+ )
146
+ return Skill(
147
+ name=name, description=description, body=body, source=path, resources=resources
148
+ )
149
+
150
+ @staticmethod
151
+ def _load_toml(path: Path) -> Skill | None:
152
+ try:
153
+ values = tomllib.loads(path.read_text(encoding="utf-8"))
154
+ except (tomllib.TOMLDecodeError, OSError):
155
+ return None
156
+ body = str(values.get("system_prompt", ""))
157
+ return Skill(
158
+ name=str(values.get("name", path.stem)),
159
+ description=str(values.get("description", _first_line(body))),
160
+ body=body,
161
+ source=path,
162
+ )
163
+
164
+ # --- queries -----------------------------------------------------------
165
+
166
+ def available_skills(self) -> list[str]:
167
+ return sorted(self._skills)
168
+
169
+ def get(self, name: str) -> Skill | None:
170
+ return self._skills.get(name)
171
+
172
+ def describe(self) -> list[tuple[str, str]]:
173
+ return [(s.name, s.description) for s in (self._skills[n] for n in sorted(self._skills))]
174
+
175
+ # --- activation (pinning) ---------------------------------------------
176
+
177
+ def set_active(self, names: Sequence[str]) -> None:
178
+ self.active_skills = set(names)
179
+
180
+ def activate(self, name: str) -> bool:
181
+ """Toggle a skill's pin. Returns the resulting pinned state."""
182
+ if name in self.active_skills:
183
+ self.active_skills.discard(name)
184
+ return False
185
+ if name in self._skills:
186
+ self.active_skills.add(name)
187
+ return True
188
+ return False
189
+
190
+ # --- disclosure --------------------------------------------------------
191
+
192
+ def catalog_text(self) -> str:
193
+ entries = "\n".join(f"- {name}: {desc}" for name, desc in self.describe())
194
+ return (
195
+ "# Available skills\n"
196
+ "You have skills you can load on demand. When a task matches a "
197
+ "skill's purpose, call the `use_skill` tool with its name to load "
198
+ "the full instructions.\n\n" + entries
199
+ )
200
+
201
+ def system_additions(self) -> list[str]:
202
+ if not self._skills:
203
+ return []
204
+ additions = [self.catalog_text()]
205
+ for name in sorted(self.active_skills):
206
+ skill = self._skills.get(name)
207
+ if skill and skill.body:
208
+ additions.append(skill.body)
209
+ return additions
210
+
211
+ def use(self, name: str) -> str:
212
+ """Full disclosure for one skill (the ``use_skill`` tool result)."""
213
+ skill = self._skills.get(name)
214
+ if skill is None:
215
+ return f"Unknown skill: {name!r}. Available: {', '.join(self.available_skills())}"
216
+ out = [f"# Skill: {skill.name}\n", skill.body]
217
+ if skill.resources:
218
+ out.append("\nBundled files (read with read_file):")
219
+ out.extend(f" {r}" for r in skill.resources)
220
+ return "\n".join(out)
221
+
222
+
223
+ # Backward-compatible alias used elsewhere in the kernel.
224
+ DirectorySkillStore = SkillLibrary
225
+ SkillStore = SkillLibrary
226
+
227
+
228
+ def _first_line(text: str) -> str:
229
+ for line in text.splitlines():
230
+ stripped = line.strip().lstrip("# ").strip()
231
+ if stripped:
232
+ return stripped
233
+ return ""
234
+
235
+
236
+ def _relpath(path: Path) -> str:
237
+ try:
238
+ return os.path.relpath(path, Path.cwd())
239
+ except ValueError: # pragma: no cover - different drive on Windows
240
+ return str(path)
241
+
242
+
243
+ def make_skill_tool(library: SkillLibrary):
244
+ """Build the ``use_skill`` tool: load a skill's full body on demand."""
245
+ from agentkernel.tools.base import ToolSpec
246
+ from agentkernel.types import ToolResult
247
+
248
+ def handler(arguments: dict) -> ToolResult:
249
+ name = arguments["name"]
250
+ text = library.use(name)
251
+ is_error = text.startswith("Unknown skill:")
252
+ return ToolResult("", text, is_error=is_error)
253
+
254
+ return ToolSpec(
255
+ name="use_skill",
256
+ description=(
257
+ "Load the full instructions for one of your available skills by name. "
258
+ "Call this when a task matches a skill listed in 'Available skills'."
259
+ ),
260
+ parameters={
261
+ "type": "object",
262
+ "properties": {"name": {"type": "string", "description": "The skill name."}},
263
+ "required": ["name"],
264
+ "additionalProperties": False,
265
+ },
266
+ handler=handler,
267
+ category="skills",
268
+ )
@@ -0,0 +1,161 @@
1
+ """Sub-agent delegation via a ``spawn`` tool (design §13).
2
+
3
+ Re-entrancy (a tool handler calling ``Agent.run``) is already a kernel property;
4
+ this exposes it to the *model* as an ordinary registered tool. The handler builds
5
+ a child ``Agent`` with its own fresh context — optionally a focused system prompt
6
+ and a subset of tools — runs the subtask, and returns the child's final answer.
7
+
8
+ A depth limit prevents unbounded recursion: each child receives a ``spawn`` tool
9
+ with one less depth budget, and at ``max_depth`` the child gets no ``spawn`` at
10
+ all. Because the budget is captured per construction (not a shared counter), this
11
+ stays correct under the loop's re-entrancy.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import replace
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from agentkernel.agent import Agent
20
+ from agentkernel.context import ContextManager
21
+ from agentkernel.profiles import Profile
22
+ from agentkernel.telemetry import NullTelemetry
23
+ from agentkernel.tools.base import ToolRegistry, ToolSpec
24
+ from agentkernel.types import ToolResult
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Callable
28
+
29
+ from agentkernel.approval import Approver
30
+ from agentkernel.config import Config
31
+ from agentkernel.providers import Provider
32
+
33
+ ToolFactory = Callable[[str], list[ToolSpec]]
34
+
35
+ _SPAWN_SCHEMA = {
36
+ "type": "object",
37
+ "properties": {
38
+ "task": {
39
+ "type": "string",
40
+ "description": "The self-contained subtask for the sub-agent to complete.",
41
+ },
42
+ "system": {
43
+ "type": "string",
44
+ "description": "Optional system prompt focusing the sub-agent.",
45
+ },
46
+ "tools": {
47
+ "type": "array",
48
+ "items": {"type": "string"},
49
+ "description": "Optional subset of tool names the sub-agent may use.",
50
+ },
51
+ "worktree": {
52
+ "type": "boolean",
53
+ "description": (
54
+ "Run the sub-agent in an isolated throwaway git worktree so its "
55
+ "file edits don't collide with other work. Use for parallel agents "
56
+ "that edit code. Requires a git repo."
57
+ ),
58
+ },
59
+ },
60
+ "required": ["task"],
61
+ "additionalProperties": False,
62
+ }
63
+
64
+
65
+ def make_spawn_tool(
66
+ *,
67
+ provider: Provider,
68
+ base_specs: list[ToolSpec],
69
+ approver: Approver,
70
+ config: Config,
71
+ max_depth: int = 2,
72
+ depth: int = 1,
73
+ tool_factory: ToolFactory | None = None,
74
+ ) -> ToolSpec:
75
+ """Build a ``spawn`` tool. ``base_specs`` are the tools a child may use
76
+ (must NOT include a spawn tool — deeper spawns are added here recursively).
77
+
78
+ ``tool_factory(working_dir)`` rebuilds a fresh toolset bound to a directory;
79
+ it is required for ``worktree`` isolation (the child's file/shell tools must
80
+ point at the worktree, not the original working dir)."""
81
+
82
+ def _isolate(arguments: dict[str, Any]) -> tuple[Config, list[ToolSpec], object]:
83
+ """Return (child_config, child_specs, cleanup) honoring a worktree request."""
84
+ if not arguments.get("worktree") or tool_factory is None:
85
+ return config, base_specs, None
86
+ from agentkernel.worktree import WorktreeError, WorktreeManager
87
+
88
+ wm = WorktreeManager(config.working_dir)
89
+ if not wm.is_git_repo():
90
+ return config, base_specs, None
91
+ try:
92
+ path, branch = wm.create()
93
+ except WorktreeError:
94
+ return config, base_specs, None
95
+ return replace(config, working_dir=str(path)), tool_factory(str(path)), (wm, path, branch)
96
+
97
+ def handler(arguments: dict[str, Any]) -> ToolResult:
98
+ task = arguments["task"]
99
+ child_config, specs, cleanup = _isolate(arguments)
100
+
101
+ wanted = arguments.get("tools")
102
+ if wanted:
103
+ allowed = set(wanted)
104
+ specs = [s for s in specs if s.name in allowed]
105
+
106
+ child_registry = ToolRegistry()
107
+ for spec in specs:
108
+ child_registry.register(spec)
109
+ # Give the child its own (shallower) spawn ability until the limit.
110
+ if depth < max_depth:
111
+ child_registry.register(
112
+ make_spawn_tool(
113
+ provider=provider,
114
+ base_specs=base_specs,
115
+ approver=approver,
116
+ config=config,
117
+ max_depth=max_depth,
118
+ depth=depth + 1,
119
+ tool_factory=tool_factory,
120
+ )
121
+ )
122
+
123
+ context = ContextManager(
124
+ budget=provider.context_window - child_config.output_reserve,
125
+ keep_recent_turns=child_config.keep_recent_turns,
126
+ )
127
+ child = Agent(
128
+ provider, child_registry, context, approver, NullTelemetry(), child_config
129
+ )
130
+ system = arguments.get("system")
131
+ profile = Profile(name="subagent", system_prompt=system) if system else None
132
+ try:
133
+ answer = child.run(task, profile=profile)
134
+ except Exception as exc: # noqa: BLE001 - a child failure is a tool result
135
+ return ToolResult("", f"sub-agent error: {exc}", is_error=True)
136
+ return ToolResult("", answer + _finish_worktree(cleanup), data={"depth": depth})
137
+
138
+ return ToolSpec(
139
+ name="spawn",
140
+ description=(
141
+ "Delegate a self-contained subtask to a focused sub-agent and return "
142
+ "its final answer. Optionally restrict the sub-agent's tools, give it a "
143
+ "system prompt, or set worktree=true to isolate its file edits in a "
144
+ "throwaway git worktree. Use this to isolate a side investigation or to "
145
+ "parallelize independent work."
146
+ ),
147
+ parameters=_SPAWN_SCHEMA,
148
+ handler=handler,
149
+ category="agent",
150
+ )
151
+
152
+
153
+ def _finish_worktree(cleanup: object) -> str:
154
+ """Remove a clean worktree, or keep one with changes and report where it is."""
155
+ if cleanup is None:
156
+ return ""
157
+ wm, path, branch = cleanup # type: ignore[misc]
158
+ if wm.has_changes(path):
159
+ return f"\n\n[worktree kept at {path} on branch {branch} — it has changes to review]"
160
+ wm.remove(path)
161
+ return "\n\n[worktree removed — the sub-agent made no file changes]"
@@ -0,0 +1,199 @@
1
+ """Per-turn telemetry (design §12).
2
+
3
+ Writes one JSONL file per session under ``config.log_dir``. The record schema
4
+ (below) is a stable interface for later phases — cost dashboards and the Phase-7
5
+ self-improvement loop read it — so treat it as a contract, not throwaway logs.
6
+
7
+ Redaction is the default: tool arguments are logged as a hash + length, never
8
+ raw, and file contents never enter a record at all. ``verbose=True`` (the
9
+ ``--verbose-trace`` flag) includes raw arguments for local debugging only.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import uuid
17
+ from collections.abc import Sequence
18
+ from dataclasses import dataclass
19
+ from datetime import UTC, datetime
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, Any, Protocol
22
+
23
+ if TYPE_CHECKING:
24
+ from agentkernel.context import CompactionEvent
25
+ from agentkernel.types import CompletionResponse
26
+
27
+
28
+ @dataclass
29
+ class ToolOutcome:
30
+ """What happened to one tool call this turn (for the trace)."""
31
+
32
+ name: str
33
+ arguments: dict[str, Any]
34
+ approved: bool | None # True executed, False denied, None never reached the gate
35
+ is_error: bool
36
+
37
+
38
+ @dataclass
39
+ class Price:
40
+ """USD per million tokens for one model."""
41
+
42
+ input: float
43
+ output: float
44
+ cache_read: float = 0.0
45
+ cache_write: float = 0.0
46
+
47
+
48
+ # TODO(owner): verify these seed prices against current provider pricing.
49
+ DEFAULT_PRICES: dict[str, Price] = {
50
+ "claude-opus-4-8": Price(15.0, 75.0, 1.50, 18.75),
51
+ "claude-sonnet-4-6": Price(3.0, 15.0, 0.30, 3.75),
52
+ "claude-haiku-4-5-20251001": Price(1.0, 5.0, 0.10, 1.25),
53
+ "gpt-4o": Price(2.5, 10.0, 1.25, 0.0),
54
+ "gpt-4o-mini": Price(0.15, 0.60, 0.075, 0.0),
55
+ }
56
+
57
+
58
+ def estimate_cost(model: str, usage, prices: dict[str, Price]) -> float | None:
59
+ """Cost in USD, or None for an unknown model (tokens still logged)."""
60
+ price = prices.get(model)
61
+ if price is None:
62
+ return None
63
+ total = (
64
+ usage.input_tokens * price.input
65
+ + usage.output_tokens * price.output
66
+ + usage.cache_read_tokens * price.cache_read
67
+ + usage.cache_write_tokens * price.cache_write
68
+ )
69
+ return round(total / 1_000_000, 6)
70
+
71
+
72
+ class Telemetry(Protocol):
73
+ """Minimal telemetry interface for the agent loop."""
74
+
75
+ @property
76
+ def model(self) -> str:
77
+ """Model name used for cost estimates."""
78
+ ...
79
+
80
+ @property
81
+ def prices(self) -> dict[str, Price]:
82
+ """Price table used for cost estimates."""
83
+ ...
84
+
85
+ def record_turn(
86
+ self,
87
+ iteration: int,
88
+ response: CompletionResponse,
89
+ *,
90
+ tool_outcomes: Sequence[ToolOutcome] = (),
91
+ compaction: CompactionEvent | None = None,
92
+ ) -> None:
93
+ ...
94
+
95
+
96
+ class NullTelemetry:
97
+ """Records nothing. Used where tracing is not configured (and by tests)."""
98
+
99
+ def __init__(self, session_id: str | None = None) -> None:
100
+ self.session_id = session_id or str(uuid.uuid4())
101
+
102
+ @property
103
+ def model(self) -> str:
104
+ return "null"
105
+
106
+ @property
107
+ def prices(self) -> dict[str, Price]:
108
+ return DEFAULT_PRICES.copy()
109
+
110
+ def record_turn(
111
+ self,
112
+ iteration: int,
113
+ response: CompletionResponse,
114
+ *,
115
+ tool_outcomes: Sequence[ToolOutcome] = (),
116
+ compaction: CompactionEvent | None = None,
117
+ ) -> None:
118
+ return None
119
+
120
+
121
+ def _redact(outcome: ToolOutcome, verbose: bool) -> dict[str, Any]:
122
+ entry: dict[str, Any] = {
123
+ "name": outcome.name,
124
+ "approved": outcome.approved,
125
+ "is_error": outcome.is_error,
126
+ }
127
+ serialized = json.dumps(outcome.arguments, sort_keys=True, default=str)
128
+ if verbose:
129
+ entry["arguments"] = outcome.arguments # raw, local debugging only
130
+ else:
131
+ entry["args_sha256"] = hashlib.sha256(serialized.encode()).hexdigest()[:12]
132
+ entry["args_len"] = len(serialized)
133
+ return entry
134
+
135
+
136
+ class JsonlTelemetry:
137
+ """Appends one JSON object per turn to ``<log_dir>/<session_id>.jsonl``."""
138
+
139
+ def __init__(
140
+ self,
141
+ log_dir: str,
142
+ model: str,
143
+ *,
144
+ session_id: str | None = None,
145
+ prices: dict[str, Price] | None = None,
146
+ verbose: bool = False,
147
+ ) -> None:
148
+ self.session_id = session_id or str(uuid.uuid4())
149
+ self._model = model
150
+ self._prices = DEFAULT_PRICES if prices is None else prices
151
+ self._verbose = verbose
152
+ directory = Path(log_dir)
153
+ directory.mkdir(parents=True, exist_ok=True)
154
+ self.path = directory / f"{self.session_id}.jsonl"
155
+ self._fh = self.path.open("a", encoding="utf-8")
156
+
157
+ @property
158
+ def model(self) -> str:
159
+ return self._model
160
+
161
+ @property
162
+ def prices(self) -> dict[str, Price]:
163
+ return self._prices
164
+
165
+ def record_turn(
166
+ self,
167
+ iteration: int,
168
+ response: CompletionResponse,
169
+ *,
170
+ tool_outcomes: Sequence[ToolOutcome] = (),
171
+ compaction: CompactionEvent | None = None,
172
+ ) -> None:
173
+ usage = response.usage
174
+ record = {
175
+ "ts": datetime.now(UTC).isoformat(),
176
+ "session_id": self.session_id,
177
+ "iteration": iteration,
178
+ "model": self._model,
179
+ "input_tokens": usage.input_tokens,
180
+ "output_tokens": usage.output_tokens,
181
+ "cache_read_tokens": usage.cache_read_tokens,
182
+ "cache_write_tokens": usage.cache_write_tokens,
183
+ "estimated_cost_usd": estimate_cost(self._model, usage, self._prices),
184
+ "tool_calls": [_redact(o, self._verbose) for o in tool_outcomes],
185
+ "stop_reason": response.stop_reason,
186
+ "compaction": None
187
+ if compaction is None
188
+ else {
189
+ "turns_collapsed": compaction.turns_collapsed,
190
+ "tokens_before": compaction.tokens_before,
191
+ "tokens_after": compaction.tokens_after,
192
+ },
193
+ }
194
+ self._fh.write(json.dumps(record) + "\n")
195
+ self._fh.flush()
196
+
197
+ def close(self) -> None:
198
+ if not self._fh.closed:
199
+ self._fh.close()
@@ -0,0 +1,35 @@
1
+ # Templates
2
+
3
+ Copy-paste starting points for the things you author repeatedly. Each file is an
4
+ annotated skeleton — read the comments, fill in the blanks, drop it in the right
5
+ place.
6
+
7
+ | Template | Create with | Lives in | Loaded by |
8
+ |---|---|---|---|
9
+ | [`SKILL.md`](SKILL.md) | `agentkernel new skill <name>` | `skills/<name>/SKILL.md` | discovered from `skills_dir` |
10
+ | [`profile.toml`](profile.toml) | `agentkernel new profile <name>` | `profiles/<name>.toml` | `--profile <name>` |
11
+ | [`loop.toml`](loop.toml) | `agentkernel new loop <name>` | `loops/<name>.toml` | `loop --file …` |
12
+ | [`eval-suite.toml`](eval-suite.toml) | `agentkernel new eval <name>` | `evals/<name>.toml` | `eval --suite …` |
13
+ | [`mcp-servers.toml`](mcp-servers.toml) | (copy by hand) | your `agentkernel.toml` | MCP client at startup |
14
+ | [`tool_module.py`](tool_module.py) | (copy by hand) | wherever you load tools | your runtime / plugin loader |
15
+
16
+ ## Scaffolding command
17
+
18
+ `agentkernel new <kind> <name>` copies the matching template into place with the
19
+ name filled in (the `{{name}}` placeholder is substituted):
20
+
21
+ ```bash
22
+ agentkernel new skill commit-helper # -> skills/commit-helper/SKILL.md
23
+ agentkernel new profile reviewer-strict # -> profiles/reviewer-strict.toml
24
+ agentkernel new loop until-docs-build # -> loops/until-docs-build.toml
25
+ agentkernel new eval smoke # -> evals/smoke.toml
26
+ ```
27
+
28
+ It refuses to overwrite an existing file unless you pass `--force`, and rejects
29
+ names containing path separators. `new` finds this `templates/` directory by
30
+ walking up from the current directory, so run it from inside the project.
31
+
32
+ ## See also
33
+
34
+ The bundled starter content built from these templates: [`skills/`](../skills),
35
+ [`profiles/`](../profiles), and [`loops/`](../loops).
@@ -0,0 +1,28 @@
1
+ ---
2
+ name: {{name}}
3
+ # One line that does double duty: WHAT this skill does AND WHEN to use it.
4
+ # Be specific about trigger phrases — this line is all the model sees until it
5
+ # decides to load the skill, so it's the whole basis for that decision.
6
+ description: <what this does>. Use when <specific situations / phrases that should trigger it>.
7
+ ---
8
+
9
+ # {{name}}
10
+
11
+ <!--
12
+ Progressive disclosure: only the name + description above sit in the prompt until
13
+ the model loads this skill with the use_skill tool. Keep the body focused and
14
+ actionable — instructions the model follows, not background prose. A few hundred
15
+ lines max; if it's growing, split detail into bundled files next to this one and
16
+ point at them.
17
+ -->
18
+
19
+ ## When to use
20
+ - <concrete situation 1>
21
+ - <concrete situation 2>
22
+
23
+ ## Steps
24
+ 1. <first thing to do>
25
+ 2. <next thing>
26
+
27
+ ## Output format
28
+ <How the result should be structured, if it matters.>