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/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
|
+
)
|
agentkernel/subagent.py
ADDED
|
@@ -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]"
|
agentkernel/telemetry.py
ADDED
|
@@ -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.>
|