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/config.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Configuration (design §11).
|
|
2
|
+
|
|
3
|
+
Precedence: explicit constructor args > environment (``AGENTKERNEL_*``) >
|
|
4
|
+
config file (``agentkernel.toml``) > defaults. API keys come **only** from the
|
|
5
|
+
environment (``ANTHROPIC_API_KEY``, ``OPENAI_API_KEY``, …) and are never read
|
|
6
|
+
from, or written to, the config file or traces.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import tomllib
|
|
13
|
+
from dataclasses import dataclass, field, fields
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
ENV_PREFIX = "AGENTKERNEL_"
|
|
18
|
+
DEFAULT_CONFIG_FILE = "agentkernel.toml"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Config:
|
|
23
|
+
provider: str = "anthropic" # "anthropic" | "openai" | "local"
|
|
24
|
+
model: str = "claude-sonnet-4-6"
|
|
25
|
+
base_url: str | None = None # for local/OpenAI-compatible endpoints
|
|
26
|
+
max_output_tokens: int = 4096
|
|
27
|
+
output_reserve: int = 8192 # budget headroom for the reply
|
|
28
|
+
max_iterations: int = 25
|
|
29
|
+
keep_recent_turns: int = 6
|
|
30
|
+
max_tool_result_tokens: int = 4096
|
|
31
|
+
tool_concurrency: int = 4 # max parallel tool-call executions per turn (1 = sequential)
|
|
32
|
+
stream: bool = True # stream model text to the terminal as it arrives
|
|
33
|
+
approval_policy: str = "always_ask" # always_ask | auto_allow | deny_mutations | smart
|
|
34
|
+
approval_judge_model: str | None = None # model for `smart` risk judging (§18.1)
|
|
35
|
+
redact_tool_output: bool = True # scrub secret-looking strings from tool results (§18.1)
|
|
36
|
+
checkpoints: bool = False # back up files before edits; expose a `rollback` tool (§18.1)
|
|
37
|
+
enable_todo: bool = False # register the in-session `todo` planning tool (§18.4)
|
|
38
|
+
enable_clarify: bool = False # register the `clarify` ask-the-user tool (§18.4)
|
|
39
|
+
enable_plugins: bool = False # auto-load tools from plugins_dir (§18.7)
|
|
40
|
+
plugins_dir: str = "plugins" # directory of plugin tool modules
|
|
41
|
+
cron_path: str = ".agentkernel/cron.json" # scheduled-job store (§18.2)
|
|
42
|
+
enable_kanban: bool = False # register the `kanban` work-board tool (§18.3)
|
|
43
|
+
kanban_path: str = ".agentkernel/kanban.json" # shared work-board store
|
|
44
|
+
approval_allowlist: list[str] = field(default_factory=list) # patterns that skip the gate
|
|
45
|
+
plan_mode: bool = False # batch-approve the whole tool plan at once
|
|
46
|
+
sandbox: str = "local" # "local" | "docker" (design §10.3)
|
|
47
|
+
sandbox_image: str = "python:3.12-slim" # image for the docker sandbox
|
|
48
|
+
sandbox_network: str = "none" # docker container network ("none" | "bridge" | …)
|
|
49
|
+
working_dir: str = "."
|
|
50
|
+
summarizer_model: str | None = None # cheap model for compaction; None -> structural
|
|
51
|
+
log_dir: str = ".agentkernel/traces"
|
|
52
|
+
mcp_log_dir: str = ".agentkernel/mcp-logs" # per-server stderr logs
|
|
53
|
+
max_cost_usd: float | None = None # stop if cumulative cost exceeds this
|
|
54
|
+
max_input_tokens_per_run: int | None = None # stop if input tokens exceed this
|
|
55
|
+
profile: str | None = None # active profile name (Phase 5)
|
|
56
|
+
profile_dir: str = "profiles"
|
|
57
|
+
memory_store: str | None = None # "file" | "sqlite" | "memory" | None (Phase 3)
|
|
58
|
+
memory_dir: str | None = None
|
|
59
|
+
enable_memory_tools: bool = False # register remember/recall/forget tools (Phase 3)
|
|
60
|
+
memory_notes_path: str = ".agentkernel/memory/notes.jsonl" # the notebook file
|
|
61
|
+
memory_auto_context: bool = False # auto-inject recalled notes into user message
|
|
62
|
+
memory_auto_context_limit: int = 3 # max notes per auto-recall
|
|
63
|
+
memory_store_budget: int | None = None # max tokens to persist per session
|
|
64
|
+
memory_curator_model: str | None = None # cheap model for memory extract/consolidate
|
|
65
|
+
semantic_search: bool = False # rank note recall with dense embeddings
|
|
66
|
+
embedding_model: str = "text-embedding-3-small"
|
|
67
|
+
embedding_dimensions: int | None = None # optional truncation (OpenAI only)
|
|
68
|
+
embedding_base_url: str | None = None # OpenAI-compatible endpoint
|
|
69
|
+
embedding_api_key_env: str = "OPENAI_API_KEY" # env var holding API key
|
|
70
|
+
skills_dir: str = "skills" # Phase 4
|
|
71
|
+
skills: list[str] = field(default_factory=list) # active skill names (Phase 4)
|
|
72
|
+
enable_graph: bool = False # register graph_add/graph_query tools (Phase 6)
|
|
73
|
+
graph_path: str = ".agentkernel/graph.jsonl" # Phase 6
|
|
74
|
+
improvements_dir: str = ".agentkernel/improvements" # Phase 7
|
|
75
|
+
enable_spawn: bool = False # register the sub-agent `spawn` tool (design §13)
|
|
76
|
+
spawn_max_depth: int = 2 # recursion limit for nested spawn
|
|
77
|
+
judge_model: str | None = None # model used to score evals; None -> `model`
|
|
78
|
+
eval_threshold: float = 0.6 # pass/fail score cutoff for evals
|
|
79
|
+
eval_rubric: str | None = None # default rubric for eval runs
|
|
80
|
+
semantic_search_lsh_bits: int | None = None # LSH index bits for large notebooks
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def load(
|
|
84
|
+
cls,
|
|
85
|
+
config_file: str | os.PathLike[str] = DEFAULT_CONFIG_FILE,
|
|
86
|
+
*,
|
|
87
|
+
env: dict[str, str] | None = None,
|
|
88
|
+
**overrides: Any,
|
|
89
|
+
) -> Config:
|
|
90
|
+
"""Build a Config from defaults < file < environment < explicit overrides."""
|
|
91
|
+
env = os.environ if env is None else env
|
|
92
|
+
values: dict[str, Any] = {}
|
|
93
|
+
|
|
94
|
+
path = Path(config_file)
|
|
95
|
+
if path.is_file():
|
|
96
|
+
with path.open("rb") as fh:
|
|
97
|
+
file_data = tomllib.load(fh)
|
|
98
|
+
values.update({k: v for k, v in file_data.items() if k in _FIELD_TYPES})
|
|
99
|
+
|
|
100
|
+
for name, typ in _FIELD_TYPES.items():
|
|
101
|
+
raw = env.get(ENV_PREFIX + name.upper())
|
|
102
|
+
if raw is not None:
|
|
103
|
+
values[name] = _coerce(raw, typ)
|
|
104
|
+
|
|
105
|
+
values.update({k: v for k, v in overrides.items() if k in _FIELD_TYPES})
|
|
106
|
+
return cls(**values)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_FIELD_TYPES: dict[str, Any] = {f.name: f.type for f in fields(Config)}
|
|
110
|
+
|
|
111
|
+
# State that is the agent's user-global "brain & library" vs per-project.
|
|
112
|
+
# Defaults anchor to the appropriate root (see paths.py); a value customized in
|
|
113
|
+
# project config anchors to the project instead, so per-project overrides win.
|
|
114
|
+
_GLOBAL_PATH_FIELDS = (
|
|
115
|
+
"memory_notes_path", "graph_path", "skills_dir", "profile_dir",
|
|
116
|
+
"improvements_dir", "cron_path",
|
|
117
|
+
)
|
|
118
|
+
_PROJECT_PATH_FIELDS = (
|
|
119
|
+
"log_dir", "mcp_log_dir", "kanban_path", "plugins_dir", "memory_dir",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _read_config_fields(path: Path) -> dict[str, Any]:
|
|
124
|
+
"""Read recognized fields from a TOML config file."""
|
|
125
|
+
with path.open("rb") as fh:
|
|
126
|
+
data = tomllib.load(fh)
|
|
127
|
+
return {k: v for k, v in data.items() if k in _FIELD_TYPES}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def resolve_config(
|
|
131
|
+
config_arg: str | os.PathLike[str] | None = None,
|
|
132
|
+
*,
|
|
133
|
+
cwd: str | os.PathLike[str] = ".",
|
|
134
|
+
env: dict[str, str] | None = None,
|
|
135
|
+
**overrides: Any,
|
|
136
|
+
) -> tuple[Config, Path | None]:
|
|
137
|
+
"""Resolve config for running anywhere (global brain, project sessions).
|
|
138
|
+
|
|
139
|
+
Precedence: explicit ``config_arg`` (single file) **or** layered
|
|
140
|
+
``<home>/config.toml`` < ``<project>/agentkernel.toml``, then env, then
|
|
141
|
+
``overrides``. State paths are anchored to the agent home (global fields) or
|
|
142
|
+
the project root (project fields); ``working_dir`` defaults to the project
|
|
143
|
+
root. Returns ``(config, project_config_path)``.
|
|
144
|
+
"""
|
|
145
|
+
from agentkernel.paths import (
|
|
146
|
+
agent_home,
|
|
147
|
+
anchor_path,
|
|
148
|
+
find_project_config,
|
|
149
|
+
find_project_root,
|
|
150
|
+
global_config_path,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
env = os.environ if env is None else env
|
|
154
|
+
home = agent_home(env)
|
|
155
|
+
project_root = find_project_root(cwd)
|
|
156
|
+
values: dict[str, Any] = {}
|
|
157
|
+
project_config_path: Path | None = None
|
|
158
|
+
|
|
159
|
+
if config_arg is not None:
|
|
160
|
+
explicit = Path(config_arg)
|
|
161
|
+
if explicit.is_file():
|
|
162
|
+
values.update(_read_config_fields(explicit))
|
|
163
|
+
project_config_path = explicit
|
|
164
|
+
else:
|
|
165
|
+
gpath = global_config_path(home)
|
|
166
|
+
if gpath.is_file():
|
|
167
|
+
values.update(_read_config_fields(gpath))
|
|
168
|
+
ppath = find_project_config(project_root)
|
|
169
|
+
if ppath is not None:
|
|
170
|
+
values.update(_read_config_fields(ppath))
|
|
171
|
+
project_config_path = ppath
|
|
172
|
+
|
|
173
|
+
for name, typ in _FIELD_TYPES.items():
|
|
174
|
+
raw = env.get(ENV_PREFIX + name.upper())
|
|
175
|
+
if raw is not None:
|
|
176
|
+
values[name] = _coerce(raw, typ)
|
|
177
|
+
values.update({k: v for k, v in overrides.items() if k in _FIELD_TYPES})
|
|
178
|
+
|
|
179
|
+
config = Config(**values)
|
|
180
|
+
defaults = Config()
|
|
181
|
+
|
|
182
|
+
if config.working_dir in (".", "", None):
|
|
183
|
+
config.working_dir = str(project_root)
|
|
184
|
+
else:
|
|
185
|
+
config.working_dir = anchor_path(config.working_dir, base=project_root)
|
|
186
|
+
|
|
187
|
+
for name in _GLOBAL_PATH_FIELDS:
|
|
188
|
+
value = getattr(config, name)
|
|
189
|
+
if value is None:
|
|
190
|
+
continue
|
|
191
|
+
base = home if value == getattr(defaults, name) else project_root
|
|
192
|
+
setattr(config, name, anchor_path(value, base=base))
|
|
193
|
+
|
|
194
|
+
for name in _PROJECT_PATH_FIELDS:
|
|
195
|
+
value = getattr(config, name)
|
|
196
|
+
if value is None:
|
|
197
|
+
if name == "memory_dir":
|
|
198
|
+
config.memory_dir = anchor_path(".agentkernel/memory", base=project_root)
|
|
199
|
+
continue
|
|
200
|
+
setattr(config, name, anchor_path(value, base=project_root))
|
|
201
|
+
|
|
202
|
+
return config, project_config_path
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _coerce(raw: str, typ: Any) -> Any:
|
|
206
|
+
"""Coerce an environment string to the field's declared type."""
|
|
207
|
+
# Field types are stringified annotations under ``from __future__ import``.
|
|
208
|
+
if typ == "bool":
|
|
209
|
+
return raw.strip().lower() in ("1", "true", "yes", "on")
|
|
210
|
+
if typ == "int":
|
|
211
|
+
return int(raw)
|
|
212
|
+
if typ == "float":
|
|
213
|
+
return float(raw)
|
|
214
|
+
if typ == "list[str]":
|
|
215
|
+
return [s.strip() for s in raw.split(",") if s.strip()]
|
|
216
|
+
if typ in ("str | None", "int | None", "float | None"):
|
|
217
|
+
if raw == "":
|
|
218
|
+
return None
|
|
219
|
+
if "int" in typ:
|
|
220
|
+
return int(raw)
|
|
221
|
+
if "float" in typ:
|
|
222
|
+
return float(raw)
|
|
223
|
+
return raw
|
|
224
|
+
return raw
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Context management: accounting and compaction (design §9)."""
|
|
2
|
+
|
|
3
|
+
from agentkernel.context.manager import (
|
|
4
|
+
CompactionEvent,
|
|
5
|
+
ContextManager,
|
|
6
|
+
ModelSummarizer,
|
|
7
|
+
estimate_tokens,
|
|
8
|
+
structural_summary,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ContextManager",
|
|
13
|
+
"CompactionEvent",
|
|
14
|
+
"ModelSummarizer",
|
|
15
|
+
"estimate_tokens",
|
|
16
|
+
"structural_summary",
|
|
17
|
+
]
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Conversation context: accounting, budget, and compaction (design §9).
|
|
2
|
+
|
|
3
|
+
The system prompt and tool definitions are NOT in this message list — they live
|
|
4
|
+
in the cacheable prefix (§9.3) and are passed to the provider separately. So
|
|
5
|
+
compaction here operates only on user/assistant/tool messages and can never
|
|
6
|
+
drop the system prompt.
|
|
7
|
+
|
|
8
|
+
Compaction collapses the oldest completed turns into one synthetic assistant
|
|
9
|
+
summary, always keeping the most recent ``keep_recent_turns`` turns verbatim and
|
|
10
|
+
never splitting an assistant tool-call from its tool results (§9.2).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from collections import Counter
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from agentkernel.context.truncate import CHARS_PER_TOKEN, truncate_text
|
|
22
|
+
from agentkernel.types import Message
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from agentkernel.providers import Provider
|
|
26
|
+
|
|
27
|
+
# A summarizer turns a list of (old) messages into one summary string. The
|
|
28
|
+
# default is the deterministic structural fallback below; a model-based
|
|
29
|
+
# summarizer can be injected here (design §9.2).
|
|
30
|
+
Summarizer = Callable[[list[Message]], str]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class CompactionEvent:
|
|
35
|
+
"""Telemetry for one compaction pass (design §9.2)."""
|
|
36
|
+
|
|
37
|
+
turns_collapsed: int
|
|
38
|
+
tokens_before: int
|
|
39
|
+
tokens_after: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def estimate_tokens(message: Message) -> int:
|
|
43
|
+
"""Conservative chars/4 estimate for one message (design §9.1)."""
|
|
44
|
+
chars = len(message.content or "")
|
|
45
|
+
for tc in message.tool_calls:
|
|
46
|
+
chars += len(tc.name) + len(json.dumps(tc.arguments))
|
|
47
|
+
for r in message.tool_results:
|
|
48
|
+
chars += len(r.content or "")
|
|
49
|
+
return max(1, chars // CHARS_PER_TOKEN)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def structural_summary(messages: list[Message]) -> str:
|
|
53
|
+
"""Deterministic, offline summary: message counts, tools used, files touched."""
|
|
54
|
+
users = sum(1 for m in messages if m.role == "user")
|
|
55
|
+
assistants = sum(1 for m in messages if m.role == "assistant")
|
|
56
|
+
tool_names: list[str] = []
|
|
57
|
+
paths: set[str] = set()
|
|
58
|
+
for m in messages:
|
|
59
|
+
for tc in m.tool_calls:
|
|
60
|
+
tool_names.append(tc.name)
|
|
61
|
+
p = tc.arguments.get("path")
|
|
62
|
+
if isinstance(p, str):
|
|
63
|
+
paths.add(p)
|
|
64
|
+
parts = [f"{users} user and {assistants} assistant message(s) were exchanged"]
|
|
65
|
+
if tool_names:
|
|
66
|
+
counts = Counter(tool_names)
|
|
67
|
+
parts.append("tools used: " + ", ".join(f"{n}×{c}" for n, c in counts.items()))
|
|
68
|
+
if paths:
|
|
69
|
+
parts.append("files touched: " + ", ".join(sorted(paths)))
|
|
70
|
+
return "; ".join(parts) + "."
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _render_transcript(messages: list[Message]) -> str:
|
|
74
|
+
"""A compact, readable transcript of messages for the summarizer prompt."""
|
|
75
|
+
lines: list[str] = []
|
|
76
|
+
for m in messages:
|
|
77
|
+
if m.content and m.role in ("user", "assistant"):
|
|
78
|
+
lines.append(f"{m.role}: {m.content}")
|
|
79
|
+
for tc in m.tool_calls:
|
|
80
|
+
lines.append(f"assistant called {tc.name}({json.dumps(tc.arguments)})")
|
|
81
|
+
for r in m.tool_results:
|
|
82
|
+
lines.append(f"tool[{'error' if r.is_error else 'ok'}]: {r.content}")
|
|
83
|
+
return "\n".join(lines)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ModelSummarizer:
|
|
87
|
+
"""Summarize old turns with a (cheap) model call (design §9.2).
|
|
88
|
+
|
|
89
|
+
Wired when ``config.summarizer_model`` is set. Summarization is best-effort:
|
|
90
|
+
any provider failure falls back to the deterministic ``structural_summary``
|
|
91
|
+
so compaction — and therefore the loop — can never crash on it.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, provider: Provider, *, max_tokens: int = 512) -> None:
|
|
95
|
+
self._provider = provider
|
|
96
|
+
self._max_tokens = max_tokens
|
|
97
|
+
|
|
98
|
+
def __call__(self, messages: list[Message]) -> str:
|
|
99
|
+
# Bound the transcript so it fits the summarizer's own context.
|
|
100
|
+
transcript = truncate_text(_render_transcript(messages), 4000)
|
|
101
|
+
prompt = Message(
|
|
102
|
+
role="user",
|
|
103
|
+
content=(
|
|
104
|
+
"Summarize the earlier conversation below so it can be dropped from "
|
|
105
|
+
"context without losing continuity. Be concise; preserve key facts, "
|
|
106
|
+
"decisions, tool results, and file paths.\n\n" + transcript
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
try:
|
|
110
|
+
resp = self._provider.complete(
|
|
111
|
+
[prompt],
|
|
112
|
+
[],
|
|
113
|
+
max_tokens=self._max_tokens,
|
|
114
|
+
temperature=0.0,
|
|
115
|
+
system="You are a precise note-taker producing a context summary.",
|
|
116
|
+
)
|
|
117
|
+
except Exception: # noqa: BLE001 - best-effort; never break the loop
|
|
118
|
+
return structural_summary(messages)
|
|
119
|
+
return resp.message.content.strip() or structural_summary(messages)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ContextManager:
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
*,
|
|
126
|
+
budget: int | None = None,
|
|
127
|
+
keep_recent_turns: int = 6,
|
|
128
|
+
summarizer: Summarizer | None = None,
|
|
129
|
+
estimator: Callable[[Message], int] = estimate_tokens,
|
|
130
|
+
) -> None:
|
|
131
|
+
# budget=None means unlimited (no compaction) — used by tests that don't
|
|
132
|
+
# exercise context limits.
|
|
133
|
+
self._messages: list[Message] = []
|
|
134
|
+
self._budget = budget
|
|
135
|
+
self._keep_recent_turns = keep_recent_turns
|
|
136
|
+
# A model-based summarizer (ModelSummarizer, wired from
|
|
137
|
+
# config.summarizer_model) can be injected; otherwise the deterministic
|
|
138
|
+
# structural fallback is used.
|
|
139
|
+
self._summarize: Summarizer = summarizer or structural_summary
|
|
140
|
+
self._estimate = estimator
|
|
141
|
+
self._pending_compaction: CompactionEvent | None = None
|
|
142
|
+
|
|
143
|
+
def add(self, message: Message) -> None:
|
|
144
|
+
if message.token_estimate is None:
|
|
145
|
+
message.token_estimate = self._estimate(message)
|
|
146
|
+
self._messages.append(message)
|
|
147
|
+
|
|
148
|
+
def messages(self) -> list[Message]:
|
|
149
|
+
"""The full stored history."""
|
|
150
|
+
return list(self._messages)
|
|
151
|
+
|
|
152
|
+
def clear(self) -> None:
|
|
153
|
+
"""Drop all stored messages. System-prompt/tool prefix is unaffected."""
|
|
154
|
+
self._messages = []
|
|
155
|
+
|
|
156
|
+
def window(self) -> list[Message]:
|
|
157
|
+
"""Messages to send this turn, compacted in place if over budget (§9.2)."""
|
|
158
|
+
if self._budget is not None and self._total() > self._budget:
|
|
159
|
+
self._compact()
|
|
160
|
+
return list(self._messages)
|
|
161
|
+
|
|
162
|
+
def take_compaction(self) -> CompactionEvent | None:
|
|
163
|
+
"""Return and clear the most recent compaction event (for telemetry)."""
|
|
164
|
+
event, self._pending_compaction = self._pending_compaction, None
|
|
165
|
+
return event
|
|
166
|
+
|
|
167
|
+
# --- internals ---------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def _total(self) -> int:
|
|
170
|
+
return sum(m.token_estimate or 0 for m in self._messages)
|
|
171
|
+
|
|
172
|
+
def _group_turns(self) -> list[list[Message]]:
|
|
173
|
+
"""Group messages into atomic units. An assistant tool-call message is
|
|
174
|
+
bound to the tool-result message that answers it so compaction can never
|
|
175
|
+
split an open pair (§9.2)."""
|
|
176
|
+
groups: list[list[Message]] = []
|
|
177
|
+
i = 0
|
|
178
|
+
n = len(self._messages)
|
|
179
|
+
while i < n:
|
|
180
|
+
m = self._messages[i]
|
|
181
|
+
if (
|
|
182
|
+
m.role == "assistant"
|
|
183
|
+
and m.tool_calls
|
|
184
|
+
and i + 1 < n
|
|
185
|
+
and self._messages[i + 1].role == "tool"
|
|
186
|
+
):
|
|
187
|
+
groups.append([m, self._messages[i + 1]])
|
|
188
|
+
i += 2
|
|
189
|
+
else:
|
|
190
|
+
groups.append([m])
|
|
191
|
+
i += 1
|
|
192
|
+
return groups
|
|
193
|
+
|
|
194
|
+
def _compact(self) -> None:
|
|
195
|
+
groups = self._group_turns()
|
|
196
|
+
if len(groups) <= self._keep_recent_turns:
|
|
197
|
+
return # nothing old enough to compact; keep recent turns verbatim
|
|
198
|
+
|
|
199
|
+
old = groups[: -self._keep_recent_turns]
|
|
200
|
+
recent = groups[-self._keep_recent_turns :]
|
|
201
|
+
old_messages = [m for g in old for m in g]
|
|
202
|
+
|
|
203
|
+
tokens_before = self._total()
|
|
204
|
+
summary = Message(
|
|
205
|
+
role="assistant",
|
|
206
|
+
content="Earlier in this session: " + self._summarize(old_messages),
|
|
207
|
+
)
|
|
208
|
+
summary.token_estimate = self._estimate(summary)
|
|
209
|
+
recent_messages = [m for g in recent for m in g]
|
|
210
|
+
|
|
211
|
+
self._messages = [summary, *recent_messages]
|
|
212
|
+
self._pending_compaction = CompactionEvent(
|
|
213
|
+
turns_collapsed=len(old),
|
|
214
|
+
tokens_before=tokens_before,
|
|
215
|
+
tokens_after=self._total(),
|
|
216
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Shared truncation (design §8.4 / §9).
|
|
2
|
+
|
|
3
|
+
Both large tool results and context management use this single mechanism so the
|
|
4
|
+
marker and policy stay consistent. M0 needs it for ``read_file``; M2's context
|
|
5
|
+
manager reuses it. Truncation keeps the head and tail and replaces the middle
|
|
6
|
+
with a marker, which is more useful to the model than a hard cut.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# Rough chars-per-token heuristic for budgeting; only needs to be conservative.
|
|
12
|
+
CHARS_PER_TOKEN = 4
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def truncate_text(text: str, max_tokens: int, *, hint: str = "use a narrower query") -> str:
|
|
16
|
+
"""Truncate ``text`` to roughly ``max_tokens``, marking the removed middle.
|
|
17
|
+
|
|
18
|
+
Returns the text unchanged if it already fits. The marker states how many
|
|
19
|
+
bytes were dropped so the model knows the result is partial.
|
|
20
|
+
"""
|
|
21
|
+
max_chars = max(max_tokens * CHARS_PER_TOKEN, 0)
|
|
22
|
+
if len(text) <= max_chars or max_chars == 0:
|
|
23
|
+
return text
|
|
24
|
+
|
|
25
|
+
marker_template = "\n… [truncated {n} bytes; {hint}] …\n"
|
|
26
|
+
# Reserve room for the marker, then split the budget between head and tail.
|
|
27
|
+
marker = marker_template.format(n=0, hint=hint)
|
|
28
|
+
body_chars = max(max_chars - len(marker), 0)
|
|
29
|
+
head_chars = body_chars // 2
|
|
30
|
+
tail_chars = body_chars - head_chars
|
|
31
|
+
|
|
32
|
+
head = text[:head_chars]
|
|
33
|
+
tail = text[len(text) - tail_chars :] if tail_chars else ""
|
|
34
|
+
dropped = len(text) - len(head) - len(tail)
|
|
35
|
+
return head + marker_template.format(n=dropped, hint=hint) + tail
|
agentkernel/cron.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Scheduled jobs (design §18.2).
|
|
2
|
+
|
|
3
|
+
A durable, dependency-light scheduler: jobs live in a JSON file and run on a
|
|
4
|
+
fixed interval. There is no long-running daemon — ``agentkernel cron tick`` runs
|
|
5
|
+
whatever is due once and exits, so an OS scheduler (cron, systemd timer, Windows
|
|
6
|
+
Task Scheduler) can drive it. ``cron run <id>`` runs one job immediately.
|
|
7
|
+
|
|
8
|
+
Schedules are interval strings (``30s``, ``15m``, ``2h``, ``1d``, ``1w``). Full
|
|
9
|
+
5-field cron expressions are intentionally left for later — the interval covers
|
|
10
|
+
the common "every N" case with a fraction of the surface area.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import uuid
|
|
18
|
+
from dataclasses import asdict, dataclass
|
|
19
|
+
from datetime import UTC, datetime, timedelta
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
|
|
23
|
+
_INTERVAL_RE = re.compile(r"^\s*(\d+)\s*([smhdw])\s*$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_interval(schedule: str) -> timedelta:
|
|
27
|
+
"""Parse an interval like ``30m`` / ``2h`` into a timedelta. Raises ValueError."""
|
|
28
|
+
match = _INTERVAL_RE.match(schedule or "")
|
|
29
|
+
if not match:
|
|
30
|
+
raise ValueError(
|
|
31
|
+
f"invalid schedule {schedule!r}; use forms like '30s', '15m', '2h', '1d', '1w'"
|
|
32
|
+
)
|
|
33
|
+
return timedelta(seconds=int(match.group(1)) * _UNIT_SECONDS[match.group(2)])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class CronJob:
|
|
38
|
+
id: str
|
|
39
|
+
schedule: str
|
|
40
|
+
prompt: str
|
|
41
|
+
enabled: bool = True
|
|
42
|
+
last_run: str | None = None # ISO-8601, UTC
|
|
43
|
+
created: str = ""
|
|
44
|
+
|
|
45
|
+
def next_run(self) -> datetime | None:
|
|
46
|
+
"""When this job is next due, or None if it has never run (due now)."""
|
|
47
|
+
if self.last_run is None:
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
return datetime.fromisoformat(self.last_run) + parse_interval(self.schedule)
|
|
51
|
+
except ValueError:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def is_due(self, now: datetime) -> bool:
|
|
55
|
+
if not self.enabled:
|
|
56
|
+
return False
|
|
57
|
+
nxt = self.next_run()
|
|
58
|
+
return nxt is None or now >= nxt
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class JobStore:
|
|
62
|
+
"""JSON-backed store of cron jobs."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, path: str | Path) -> None:
|
|
65
|
+
self._path = Path(path)
|
|
66
|
+
|
|
67
|
+
def _read(self) -> list[CronJob]:
|
|
68
|
+
if not self._path.is_file():
|
|
69
|
+
return []
|
|
70
|
+
try:
|
|
71
|
+
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
|
72
|
+
except (json.JSONDecodeError, OSError):
|
|
73
|
+
return []
|
|
74
|
+
return [CronJob(**j) for j in raw if isinstance(j, dict)]
|
|
75
|
+
|
|
76
|
+
def _write(self, jobs: list[CronJob]) -> None:
|
|
77
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
self._path.write_text(
|
|
79
|
+
json.dumps([asdict(j) for j in jobs], indent=2), encoding="utf-8"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def list(self) -> list[CronJob]:
|
|
83
|
+
return self._read()
|
|
84
|
+
|
|
85
|
+
def get(self, job_id: str) -> CronJob | None:
|
|
86
|
+
return next((j for j in self._read() if j.id == job_id), None)
|
|
87
|
+
|
|
88
|
+
def add(self, schedule: str, prompt: str, *, enabled: bool = True) -> CronJob:
|
|
89
|
+
parse_interval(schedule) # validate up front
|
|
90
|
+
job = CronJob(
|
|
91
|
+
id=uuid.uuid4().hex[:8],
|
|
92
|
+
schedule=schedule.strip(),
|
|
93
|
+
prompt=prompt,
|
|
94
|
+
enabled=enabled,
|
|
95
|
+
created=datetime.now(UTC).isoformat(timespec="seconds"),
|
|
96
|
+
)
|
|
97
|
+
jobs = self._read()
|
|
98
|
+
jobs.append(job)
|
|
99
|
+
self._write(jobs)
|
|
100
|
+
return job
|
|
101
|
+
|
|
102
|
+
def remove(self, job_id: str) -> bool:
|
|
103
|
+
jobs = self._read()
|
|
104
|
+
kept = [j for j in jobs if j.id != job_id]
|
|
105
|
+
if len(kept) == len(jobs):
|
|
106
|
+
return False
|
|
107
|
+
self._write(kept)
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
def set_enabled(self, job_id: str, enabled: bool) -> bool:
|
|
111
|
+
return self._update(job_id, lambda j: setattr(j, "enabled", enabled))
|
|
112
|
+
|
|
113
|
+
def mark_run(self, job_id: str, when: datetime | None = None) -> bool:
|
|
114
|
+
stamp = (when or datetime.now(UTC)).isoformat(timespec="seconds")
|
|
115
|
+
return self._update(job_id, lambda j: setattr(j, "last_run", stamp))
|
|
116
|
+
|
|
117
|
+
def _update(self, job_id: str, mutate) -> bool:
|
|
118
|
+
jobs = self._read()
|
|
119
|
+
found = False
|
|
120
|
+
for j in jobs:
|
|
121
|
+
if j.id == job_id:
|
|
122
|
+
mutate(j)
|
|
123
|
+
found = True
|
|
124
|
+
if found:
|
|
125
|
+
self._write(jobs)
|
|
126
|
+
return found
|
|
127
|
+
|
|
128
|
+
def due_jobs(self, now: datetime | None = None) -> list[CronJob]:
|
|
129
|
+
now = now or datetime.now(UTC)
|
|
130
|
+
return [j for j in self._read() if j.is_due(now)]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def run_due_jobs(store: JobStore, run_fn, *, now: datetime | None = None) -> list[tuple[str, str]]:
|
|
134
|
+
"""Run every due job via ``run_fn(prompt) -> str``; mark each run. Returns
|
|
135
|
+
``(job_id, result)`` pairs. A job whose run raises is recorded as run anyway
|
|
136
|
+
(with the error as its result) so one bad job doesn't re-fire every tick."""
|
|
137
|
+
now = now or datetime.now(UTC)
|
|
138
|
+
results: list[tuple[str, str]] = []
|
|
139
|
+
for job in store.due_jobs(now):
|
|
140
|
+
try:
|
|
141
|
+
result = run_fn(job.prompt)
|
|
142
|
+
except Exception as exc: # noqa: BLE001 - record and continue to next job
|
|
143
|
+
result = f"[error] {exc}"
|
|
144
|
+
store.mark_run(job.id, now)
|
|
145
|
+
results.append((job.id, result))
|
|
146
|
+
return results
|