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/paths.py ADDED
@@ -0,0 +1,73 @@
1
+ """Location policy for running the agent outside its own project folder.
2
+
3
+ Two roots:
4
+
5
+ * **Agent home** — the user-global "brain & library" (memory notebook, knowledge
6
+ graph, skills, profiles, improvements, scheduled jobs). Shared across every
7
+ project. ``$AGENTKERNEL_HOME`` overrides the default ``~/.agentkernel``.
8
+ * **Project root** — the directory the agent operates on, found by walking up
9
+ from the target directory for a marker (``agentkernel.toml`` / ``.agentkernel``
10
+ / ``.git``). Project-local state (session traces, kanban board, checkpoints)
11
+ lives under ``<project>/.agentkernel``.
12
+
13
+ ``anchor_path`` resolves a configured path against the right root, leaving
14
+ absolute paths untouched, so "global brain, project sessions" works no matter
15
+ where you launch ``agentkernel``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from pathlib import Path
22
+
23
+ HOME_ENV = "AGENTKERNEL_HOME"
24
+ DEFAULT_HOME_DIRNAME = ".agentkernel"
25
+ _CONFIG_MARKERS = ("agentkernel.toml", ".agentkernel")
26
+ _FALLBACK_MARKERS = (".git",)
27
+
28
+
29
+ def agent_home(env: dict[str, str] | None = None) -> Path:
30
+ """The user-global agent home (``$AGENTKERNEL_HOME`` or ``~/.agentkernel``)."""
31
+ source = os.environ if env is None else env
32
+ override = source.get(HOME_ENV)
33
+ if override:
34
+ return Path(override).expanduser()
35
+ return Path.home() / DEFAULT_HOME_DIRNAME
36
+
37
+
38
+ def find_project_root(start: str | os.PathLike[str] = ".") -> Path:
39
+ """Walk up from ``start`` to the nearest project root.
40
+
41
+ Prefers a directory containing ``agentkernel.toml`` or ``.agentkernel``; falls
42
+ back to a ``.git`` root; otherwise returns ``start`` itself.
43
+ """
44
+ here = Path(start).expanduser().resolve()
45
+ if here.is_file():
46
+ here = here.parent
47
+ candidates = [here, *here.parents]
48
+ for directory in candidates:
49
+ if any((directory / marker).exists() for marker in _CONFIG_MARKERS):
50
+ return directory
51
+ for directory in candidates:
52
+ if any((directory / marker).exists() for marker in _FALLBACK_MARKERS):
53
+ return directory
54
+ return here
55
+
56
+
57
+ def find_project_config(project_root: Path) -> Path | None:
58
+ """The project's ``agentkernel.toml`` if present."""
59
+ candidate = project_root / "agentkernel.toml"
60
+ return candidate if candidate.is_file() else None
61
+
62
+
63
+ def global_config_path(home: Path | None = None) -> Path:
64
+ """The user-global config file (``<home>/config.toml``)."""
65
+ return (home or agent_home()) / "config.toml"
66
+
67
+
68
+ def anchor_path(value: str | os.PathLike[str], *, base: Path) -> str:
69
+ """Resolve ``value`` against ``base``; absolute and ``~`` paths are honored."""
70
+ path = Path(value).expanduser()
71
+ if path.is_absolute():
72
+ return str(path)
73
+ return str((base / path).resolve())
agentkernel/plugins.py ADDED
@@ -0,0 +1,76 @@
1
+ """Plugin tool discovery (design §18.7).
2
+
3
+ Loads user-authored tools from a ``plugins/`` directory and registers them
4
+ exactly like builtins — the same seam MCP uses (§13). A plugin is a ``.py`` file
5
+ that exposes either a ``tools()`` callable returning ``list[ToolSpec]`` (optionally
6
+ taking ``working_dir``) or a module-level ``TOOLS`` list.
7
+
8
+ SECURITY: importing a plugin executes its module code. Discovery is opt-in
9
+ (``enable_plugins``), and only files you place in ``plugins_dir`` are loaded.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib.util
15
+ import inspect
16
+ from pathlib import Path
17
+
18
+ from agentkernel.tools.base import ToolSpec
19
+
20
+
21
+ def _import_file(path: Path):
22
+ spec = importlib.util.spec_from_file_location(f"agentkernel_plugin_{path.stem}", path)
23
+ if spec is None or spec.loader is None:
24
+ return None
25
+ module = importlib.util.module_from_spec(spec)
26
+ spec.loader.exec_module(module)
27
+ return module
28
+
29
+
30
+ def _call_tools_fn(fn, working_dir: str) -> list[ToolSpec]:
31
+ """Call a plugin's ``tools`` entrypoint, passing working_dir if it accepts it."""
32
+ try:
33
+ params = inspect.signature(fn).parameters
34
+ except (TypeError, ValueError):
35
+ params = {}
36
+ result = fn(working_dir) if params else fn()
37
+ return [s for s in (result or []) if isinstance(s, ToolSpec)]
38
+
39
+
40
+ def _extract(module, working_dir: str) -> list[ToolSpec]:
41
+ specs: list[ToolSpec] = []
42
+ fn = getattr(module, "tools", None)
43
+ if callable(fn):
44
+ specs.extend(_call_tools_fn(fn, working_dir))
45
+ table = getattr(module, "TOOLS", None)
46
+ if isinstance(table, (list, tuple)):
47
+ specs.extend(s for s in table if isinstance(s, ToolSpec))
48
+ return specs
49
+
50
+
51
+ def load_plugin_tools(
52
+ plugins_dir: str | Path,
53
+ *,
54
+ working_dir: str = ".",
55
+ on_error=None,
56
+ ) -> list[ToolSpec]:
57
+ """Discover and return tools from every ``*.py`` in ``plugins_dir``.
58
+
59
+ A module that fails to import is skipped; ``on_error(path, exc)`` is called if
60
+ provided so the caller can warn. Files starting with ``_`` are ignored.
61
+ """
62
+ directory = Path(plugins_dir)
63
+ specs: list[ToolSpec] = []
64
+ if not directory.is_dir():
65
+ return specs
66
+ for path in sorted(directory.glob("*.py")):
67
+ if path.name.startswith("_"):
68
+ continue
69
+ try:
70
+ module = _import_file(path)
71
+ if module is not None:
72
+ specs.extend(_extract(module, working_dir))
73
+ except Exception as exc: # noqa: BLE001 - a bad plugin must not crash startup
74
+ if on_error is not None:
75
+ on_error(path, exc)
76
+ return specs
@@ -0,0 +1,70 @@
1
+ """Profile seam (Phase 5, design §13).
2
+
3
+ A ``Profile`` parameterizes a run: system prompt, tool filter, optional model
4
+ override, and an optional rubric for evaluation. The kernel honors
5
+ ``system_prompt`` and ``tool_filter``; ``model_override`` and ``rubric`` are
6
+ extension points for later phases / the CLI.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import tomllib
12
+ from collections.abc import Sequence
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ @dataclass
19
+ class Profile:
20
+ """A parameterization of one run (design §13)."""
21
+
22
+ name: str
23
+ system_prompt: str | None = None
24
+ tool_filter: list[str] | None = None
25
+ model_override: str | None = None
26
+ rubric: str | None = None
27
+ reasoning: str | None = None # "low" | "medium" | "high" (§18.5); ignored where unsupported
28
+
29
+
30
+ def _normalize(raw: dict[str, Any]) -> dict[str, Any]:
31
+ """Accept snake_case keys plus the older-style aliases in TOML files."""
32
+ out: dict[str, Any] = {}
33
+ for key, value in raw.items():
34
+ out[key] = value
35
+ return out
36
+
37
+
38
+ def load_profile(name: str, *, search_dirs: Sequence[Path] | None = None) -> Profile | None:
39
+ """Load a profile by name from the first matching TOML file.
40
+
41
+ Searches, in order:
42
+ - ``<search_dir>/<name>.toml`` for each directory in ``search_dirs``
43
+ - ``profiles/<name>.toml``
44
+ - ``.agentkernel/profiles/<name>.toml``
45
+
46
+ Returns ``None`` if no file is found.
47
+ """
48
+ if search_dirs is None:
49
+ search_dirs = []
50
+ search_dirs = [
51
+ *search_dirs,
52
+ Path("profiles"),
53
+ Path(".agentkernel/profiles"),
54
+ ]
55
+
56
+ for directory in search_dirs:
57
+ path = Path(directory) / f"{name}.toml"
58
+ if path.is_file():
59
+ with path.open("rb") as fh:
60
+ data = tomllib.load(fh)
61
+ values = _normalize(data)
62
+ return Profile(
63
+ name=name,
64
+ system_prompt=values.get("system_prompt"),
65
+ tool_filter=values.get("tool_filter"),
66
+ model_override=values.get("model_override"),
67
+ rubric=values.get("rubric"),
68
+ reasoning=values.get("reasoning"),
69
+ )
70
+ return None
@@ -0,0 +1,89 @@
1
+ """Per-turn progress display for the REPL.
2
+
3
+ Wraps a ``JsonlTelemetry`` so every recorded turn is also printed as a concise,
4
+ one-line status line. The progress wrapper tracks cumulative usage and cost for
5
+ the current REPL session without changing the underlying telemetry interface or
6
+ the agent loop.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable, Sequence
12
+ from dataclasses import dataclass, field
13
+
14
+ from agentkernel.budget import BudgetGuard
15
+ from agentkernel.context import CompactionEvent
16
+ from agentkernel.telemetry import JsonlTelemetry, ToolOutcome, estimate_cost
17
+ from agentkernel.types import CompletionResponse, Usage
18
+
19
+
20
+ @dataclass
21
+ class ProgressTelemetry:
22
+ """Prints a status line for each turn and keeps cumulative totals."""
23
+
24
+ telemetry: JsonlTelemetry
25
+ output_fn: Callable[[str], None]
26
+ _cumulative: BudgetGuard = field(init=False)
27
+
28
+ def __post_init__(self) -> None:
29
+ self._cumulative = BudgetGuard(
30
+ model=self.telemetry.model,
31
+ prices=self.telemetry.prices.copy(),
32
+ )
33
+
34
+ @property
35
+ def path(self) -> str:
36
+ return str(self.telemetry.path)
37
+
38
+ @property
39
+ def cumulative_cost(self) -> float | None:
40
+ return self._cumulative.total_cost
41
+
42
+ @property
43
+ def cumulative_usage(self) -> Usage:
44
+ return self._cumulative.total_usage
45
+
46
+ def record_turn(
47
+ self,
48
+ iteration: int,
49
+ response: CompletionResponse,
50
+ *,
51
+ tool_outcomes: Sequence[ToolOutcome] = (),
52
+ compaction: CompactionEvent | None = None,
53
+ ) -> None:
54
+ self._cumulative.add(response.usage)
55
+ self.output_fn(_format_line(iteration, response, tool_outcomes, self._cumulative))
56
+ self.telemetry.record_turn(
57
+ iteration,
58
+ response,
59
+ tool_outcomes=tool_outcomes,
60
+ compaction=compaction,
61
+ )
62
+
63
+ def close(self) -> None:
64
+ self.telemetry.close()
65
+
66
+
67
+ def _format_line(
68
+ iteration: int,
69
+ response: CompletionResponse,
70
+ tool_outcomes: Sequence[ToolOutcome],
71
+ cumulative: BudgetGuard,
72
+ ) -> str:
73
+ usage = response.usage
74
+ parts = [f"[{iteration}]"]
75
+ if response.message.tool_calls:
76
+ names = [o.name for o in tool_outcomes]
77
+ parts.append(f"tool_use: {', '.join(names) if names else '...'}")
78
+ else:
79
+ parts.append(response.stop_reason)
80
+ parts.append(f"in={usage.input_tokens} out={usage.output_tokens}")
81
+ if usage.cache_read_tokens or usage.cache_write_tokens:
82
+ parts.append(f"cache={usage.cache_read_tokens}/{usage.cache_write_tokens}")
83
+ turn_cost = estimate_cost(cumulative.model, usage, cumulative.prices)
84
+ if turn_cost is not None:
85
+ parts.append(f"this=${turn_cost:.6f}")
86
+ total_cost = cumulative.total_cost
87
+ if total_cost is not None:
88
+ parts.append(f"total=${total_cost:.6f}")
89
+ return " ".join(parts)
@@ -0,0 +1,35 @@
1
+ """Provider abstraction and adapters (design §5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from agentkernel.providers._http import ProviderError
8
+ from agentkernel.providers.anthropic import AnthropicProvider
9
+ from agentkernel.providers.base import Provider
10
+ from agentkernel.providers.local import LocalProvider
11
+ from agentkernel.providers.openai import OpenAIProvider
12
+
13
+ if TYPE_CHECKING:
14
+ from agentkernel.config import Config
15
+
16
+ __all__ = [
17
+ "Provider",
18
+ "ProviderError",
19
+ "AnthropicProvider",
20
+ "OpenAIProvider",
21
+ "LocalProvider",
22
+ "make_provider",
23
+ ]
24
+
25
+
26
+ def make_provider(config: Config) -> Provider:
27
+ """Construct the adapter named by ``config.provider``. Keys come from env."""
28
+ if config.provider == "anthropic":
29
+ return AnthropicProvider(config.model)
30
+ if config.provider == "openai":
31
+ return OpenAIProvider(config.model)
32
+ if config.provider == "local":
33
+ kwargs = {} if config.base_url is None else {"base_url": config.base_url}
34
+ return LocalProvider(config.model, **kwargs)
35
+ raise ProviderError(f"unknown provider: {config.provider!r}")
@@ -0,0 +1,157 @@
1
+ """Shared HTTP transport for provider adapters.
2
+
3
+ A provider that is unreachable after retries raises ``ProviderError`` — one of
4
+ the few kernel faults that is allowed to propagate out of the loop (design §8.3).
5
+ Tests never touch this module: they exercise the pure translation functions
6
+ directly, so the suite stays offline (design §15).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import email.utils
12
+ import json
13
+ import time
14
+ from collections.abc import Iterator
15
+ from typing import Any
16
+
17
+ import httpx
18
+
19
+ _RETRYABLE_STATUS = {429, 500, 502, 503, 504}
20
+
21
+ # Cap how long a server-supplied Retry-After can stall us, so a hostile or
22
+ # misconfigured header can't park the loop for minutes.
23
+ _MAX_RETRY_AFTER = 30.0
24
+
25
+
26
+ class ProviderError(RuntimeError):
27
+ """A non-recoverable provider/transport fault (config or network)."""
28
+
29
+
30
+ class RateLimitError(ProviderError):
31
+ """The request was rate-limited (HTTP 429) after retries — a pool may rotate."""
32
+
33
+
34
+ def _parse_retry_after(value: str | None) -> float | None:
35
+ """Parse a ``Retry-After`` header (delta-seconds or HTTP-date) to seconds.
36
+
37
+ Returns ``None`` when the header is absent or unparseable, and never a
38
+ negative delay (a past date clamps to 0).
39
+ """
40
+ if not value:
41
+ return None
42
+ value = value.strip()
43
+ if value.isdigit():
44
+ return float(value)
45
+ try:
46
+ parsed = email.utils.parsedate_to_datetime(value)
47
+ except (ValueError, TypeError):
48
+ return None
49
+ if parsed is None:
50
+ return None
51
+ delay = parsed.timestamp() - time.time()
52
+ return max(0.0, delay)
53
+
54
+
55
+ def post_json(
56
+ url: str,
57
+ *,
58
+ headers: dict[str, str],
59
+ payload: dict[str, Any],
60
+ timeout: float = 120.0,
61
+ retries: int = 2,
62
+ ) -> dict[str, Any]:
63
+ """POST ``payload`` as JSON and return the parsed JSON response.
64
+
65
+ Retries transient transport errors and retryable status codes; raises
66
+ ``ProviderError`` once retries are exhausted or on a non-retryable 4xx.
67
+ """
68
+ last_detail = ""
69
+ last_status: int | None = None
70
+ for attempt in range(retries + 1):
71
+ retry_after: float | None = None
72
+ try:
73
+ resp = httpx.post(url, headers=headers, json=payload, timeout=timeout)
74
+ except httpx.TransportError as exc:
75
+ last_detail = f"transport error: {exc}"
76
+ last_status = None
77
+ else:
78
+ if resp.status_code < 400:
79
+ return resp.json()
80
+ # Body may contain provider error detail but never our secrets.
81
+ last_status = resp.status_code
82
+ last_detail = f"HTTP {resp.status_code}: {resp.text[:500]}"
83
+ if resp.status_code not in _RETRYABLE_STATUS:
84
+ raise ProviderError(f"{url} -> {last_detail}")
85
+ # Honor Retry-After (429/503) when the server tells us how long to
86
+ # wait, bounded so a bad header can't stall the loop indefinitely.
87
+ retry_after = _parse_retry_after(resp.headers.get("Retry-After"))
88
+ if attempt < retries:
89
+ backoff = 0.5 * (attempt + 1)
90
+ delay = min(retry_after, _MAX_RETRY_AFTER) if retry_after is not None else backoff
91
+ time.sleep(delay)
92
+ # A 429 that survived retries is recoverable by rotating to another key.
93
+ err = RateLimitError if last_status == 429 else ProviderError
94
+ raise err(f"{url} unreachable after {retries + 1} attempts; {last_detail}")
95
+
96
+
97
+ def stream_sse(
98
+ url: str,
99
+ *,
100
+ headers: dict[str, str],
101
+ payload: dict[str, Any],
102
+ timeout: float = 600.0,
103
+ ) -> Iterator[dict[str, Any]]:
104
+ """Yield parsed ``data:`` JSON objects from a streaming (SSE) POST.
105
+
106
+ Raises ``ProviderError`` on a non-2xx status (the error body is read first).
107
+ Both Anthropic and OpenAI carry a ``type``/``choices`` field in each ``data:``
108
+ payload, so callers parse the event from the JSON itself; ``event:`` lines and
109
+ keep-alives are ignored. ``[DONE]`` terminates the stream.
110
+ """
111
+ with httpx.stream("POST", url, headers=headers, json=payload, timeout=timeout) as resp:
112
+ if resp.status_code >= 400:
113
+ body = resp.read().decode("utf-8", "replace")[:500]
114
+ raise ProviderError(f"{url} -> HTTP {resp.status_code}: {body}")
115
+ for raw in resp.iter_lines():
116
+ line = raw if isinstance(raw, str) else raw.decode("utf-8", "replace")
117
+ line = line.strip()
118
+ if not line or not line.startswith("data:"):
119
+ continue
120
+ data = line[len("data:"):].strip()
121
+ if data == "[DONE]":
122
+ return
123
+ try:
124
+ yield json.loads(data)
125
+ except json.JSONDecodeError:
126
+ continue
127
+
128
+
129
+ def post_json_pooled(
130
+ url: str,
131
+ *,
132
+ header_for_key,
133
+ payload: dict[str, Any],
134
+ pool,
135
+ timeout: float = 120.0,
136
+ retries: int = 2,
137
+ _post=None,
138
+ ) -> dict[str, Any]:
139
+ """POST with credential rotation: on a rate limit, mark the key exhausted and
140
+ retry with the next one in ``pool`` until a key works or all are spent."""
141
+ post = _post if _post is not None else post_json
142
+ last_exc: ProviderError | None = None
143
+ for _ in range(max(1, len(pool))):
144
+ key = pool.current()
145
+ try:
146
+ return post(
147
+ url, headers=header_for_key(key), payload=payload,
148
+ timeout=timeout, retries=retries,
149
+ )
150
+ except RateLimitError as exc:
151
+ last_exc = exc
152
+ pool.mark_exhausted()
153
+ if not pool.rotate():
154
+ break
155
+ if last_exc is not None:
156
+ raise last_exc
157
+ raise ProviderError(f"{url}: no credentials available")