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/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