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/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""agentkernel — a minimal, dependency-light kernel for a general-purpose AI agent.
|
|
2
|
+
|
|
3
|
+
The kernel runs the agent loop (model -> tool calls -> results -> repeat) and
|
|
4
|
+
nothing more. See `agent-kernel-design.md` for the full specification.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.0.1"
|
agentkernel/__main__.py
ADDED
agentkernel/agent.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""The agent loop (design §7).
|
|
2
|
+
|
|
3
|
+
This reads like the pseudocode in the design doc on purpose: no clever
|
|
4
|
+
metaprogramming, no provider-specific branching. The loop sends the context
|
|
5
|
+
window plus tools to the provider, parses any tool calls out of the response,
|
|
6
|
+
executes them through the registry (gating mutations through the approver), and
|
|
7
|
+
appends every result back, paired to its call id (design §8), until the model
|
|
8
|
+
produces a final answer.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import uuid
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from agentkernel.budget import BudgetGuard
|
|
18
|
+
from agentkernel.context import ContextManager
|
|
19
|
+
from agentkernel.context.truncate import truncate_text
|
|
20
|
+
from agentkernel.redaction import redact_secrets
|
|
21
|
+
from agentkernel.telemetry import ToolOutcome
|
|
22
|
+
from agentkernel.types import Message, ToolResult
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from agentkernel.approval import Approver
|
|
26
|
+
from agentkernel.config import Config
|
|
27
|
+
from agentkernel.memory import MemoryStore, NoteStore
|
|
28
|
+
from agentkernel.providers import Provider
|
|
29
|
+
from agentkernel.skills import ContextSource
|
|
30
|
+
from agentkernel.telemetry import Telemetry
|
|
31
|
+
from agentkernel.tools import ToolRegistry, ToolSpec
|
|
32
|
+
from agentkernel.types import ToolCall
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Agent:
|
|
36
|
+
"""Orchestrates one conversation. All collaborators are injected (no global
|
|
37
|
+
state), so ``run`` is re-entrant: a tool handler may construct another Agent
|
|
38
|
+
and call ``run`` to spawn a sub-agent (design §7, §13)."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
provider: Provider,
|
|
43
|
+
registry: ToolRegistry,
|
|
44
|
+
context: ContextManager,
|
|
45
|
+
approver: Approver,
|
|
46
|
+
telemetry: Telemetry,
|
|
47
|
+
config: Config,
|
|
48
|
+
budget: BudgetGuard | None = None,
|
|
49
|
+
memory: MemoryStore | None = None,
|
|
50
|
+
notes: NoteStore | None = None,
|
|
51
|
+
context_source: ContextSource | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.provider = provider
|
|
54
|
+
self.registry = registry
|
|
55
|
+
self.context = context
|
|
56
|
+
self.approver = approver
|
|
57
|
+
self.telemetry = telemetry
|
|
58
|
+
self.config = config
|
|
59
|
+
self.budget = budget
|
|
60
|
+
self.memory = memory
|
|
61
|
+
self.notes = notes
|
|
62
|
+
self.context_source = context_source
|
|
63
|
+
|
|
64
|
+
def run(
|
|
65
|
+
self,
|
|
66
|
+
user_input: str,
|
|
67
|
+
*,
|
|
68
|
+
profile: Any | None = None,
|
|
69
|
+
on_text: Any | None = None,
|
|
70
|
+
) -> str:
|
|
71
|
+
"""Drive the loop until a final answer or the max-iteration guard.
|
|
72
|
+
|
|
73
|
+
``profile`` (design §13, Phase 5) is accepted but, in the kernel, only
|
|
74
|
+
``tool_filter`` / ``system_prompt`` are honored if trivially present.
|
|
75
|
+
``on_text`` (when set) receives streamed text deltas; the loop contract is
|
|
76
|
+
otherwise unchanged.
|
|
77
|
+
"""
|
|
78
|
+
session_id = getattr(self.telemetry, "session_id", str(uuid.uuid4()))
|
|
79
|
+
|
|
80
|
+
# Pre-run memory load (Phase 3). Only load when context is empty so a
|
|
81
|
+
# persistent REPL session does not replay the same stored turns twice.
|
|
82
|
+
if self.memory is not None and not self.context.messages():
|
|
83
|
+
for message in self.memory.load(session_id):
|
|
84
|
+
self.context.add(message)
|
|
85
|
+
|
|
86
|
+
self.context.add(
|
|
87
|
+
Message(role="user", content=self._prepare_user_message(user_input))
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Assemble the cacheable prefix ONCE per run and reuse the same objects
|
|
91
|
+
# every turn. Re-building or re-sorting these per turn would silently
|
|
92
|
+
# destroy prompt-cache hit-rate (design §9.3, AGENT.md rule 3).
|
|
93
|
+
tools = self._tools_for(profile)
|
|
94
|
+
system = self._system_for(profile)
|
|
95
|
+
reasoning = getattr(profile, "reasoning", None)
|
|
96
|
+
provider = self._provider_for(profile) # honor profile.model_override
|
|
97
|
+
|
|
98
|
+
if self.budget is not None:
|
|
99
|
+
self.budget.reset()
|
|
100
|
+
|
|
101
|
+
for iteration in range(self.config.max_iterations):
|
|
102
|
+
messages = self.context.window() # compacted to budget in M2
|
|
103
|
+
|
|
104
|
+
resp = provider.complete(
|
|
105
|
+
messages,
|
|
106
|
+
tools,
|
|
107
|
+
max_tokens=self.config.max_output_tokens,
|
|
108
|
+
system=system,
|
|
109
|
+
reasoning=reasoning,
|
|
110
|
+
on_text=on_text,
|
|
111
|
+
)
|
|
112
|
+
self.context.add(resp.message)
|
|
113
|
+
compaction = self.context.take_compaction()
|
|
114
|
+
|
|
115
|
+
if self.budget is not None:
|
|
116
|
+
self.budget.add(resp.usage)
|
|
117
|
+
exceeded, reason = self.budget.exceeded()
|
|
118
|
+
if exceeded:
|
|
119
|
+
# The token spend already happened; record it, then either
|
|
120
|
+
# return the final answer (if we have one) or stop early.
|
|
121
|
+
self.telemetry.record_turn(iteration, resp, compaction=compaction)
|
|
122
|
+
if not resp.message.tool_calls:
|
|
123
|
+
self._persist_memory(session_id)
|
|
124
|
+
return resp.message.content
|
|
125
|
+
self._persist_memory(session_id)
|
|
126
|
+
return f"Stopped: budget exceeded ({reason})."
|
|
127
|
+
|
|
128
|
+
if not resp.message.tool_calls:
|
|
129
|
+
self.telemetry.record_turn(iteration, resp, compaction=compaction)
|
|
130
|
+
self._persist_memory(session_id)
|
|
131
|
+
return resp.message.content # final answer
|
|
132
|
+
|
|
133
|
+
tool_calls = resp.message.tool_calls
|
|
134
|
+
specs = [self.registry.spec(call.name) for call in tool_calls]
|
|
135
|
+
validation_errors = [self.registry.validate(call) for call in tool_calls]
|
|
136
|
+
|
|
137
|
+
if self.config.plan_mode and not self._approve_plan(tool_calls, specs):
|
|
138
|
+
self.telemetry.record_turn(
|
|
139
|
+
iteration, resp, tool_outcomes=[], compaction=compaction
|
|
140
|
+
)
|
|
141
|
+
self._persist_memory(session_id)
|
|
142
|
+
return "Plan denied by user."
|
|
143
|
+
|
|
144
|
+
# Validate + approve sequentially so interactive approval prompts
|
|
145
|
+
# stay ordered and un-interleaved; then execute the approved calls,
|
|
146
|
+
# concurrently when ``config.tool_concurrency > 1`` (design §7 said
|
|
147
|
+
# the structure must allow concurrency — this is it). Results are
|
|
148
|
+
# placed back by index so §8 pairing and ordering are preserved.
|
|
149
|
+
results: list[ToolResult] = [None] * len(tool_calls) # type: ignore[list-item]
|
|
150
|
+
outcomes: list[ToolOutcome] = [None] * len(tool_calls) # type: ignore[list-item]
|
|
151
|
+
pending: list[int] = []
|
|
152
|
+
for idx, (call, spec, err) in enumerate(
|
|
153
|
+
zip(tool_calls, specs, validation_errors, strict=True)
|
|
154
|
+
):
|
|
155
|
+
if err:
|
|
156
|
+
results[idx] = ToolResult(call.id, err, is_error=True)
|
|
157
|
+
outcomes[idx] = ToolOutcome(call.name, call.arguments, None, True)
|
|
158
|
+
elif (
|
|
159
|
+
not self.config.plan_mode
|
|
160
|
+
and self._needs_approval(spec)
|
|
161
|
+
and not self.approver.approve(call, spec)
|
|
162
|
+
):
|
|
163
|
+
results[idx] = ToolResult(call.id, "Denied by user.", is_error=True)
|
|
164
|
+
outcomes[idx] = ToolOutcome(call.name, call.arguments, False, True)
|
|
165
|
+
else:
|
|
166
|
+
pending.append(idx)
|
|
167
|
+
|
|
168
|
+
def _execute(idx: int, _calls=tool_calls) -> tuple[int, ToolResult]:
|
|
169
|
+
return idx, self.registry.execute(_calls[idx])
|
|
170
|
+
|
|
171
|
+
concurrency = max(1, getattr(self.config, "tool_concurrency", 1))
|
|
172
|
+
if concurrency > 1 and len(pending) > 1:
|
|
173
|
+
with ThreadPoolExecutor(max_workers=min(concurrency, len(pending))) as pool:
|
|
174
|
+
executed = list(pool.map(_execute, pending))
|
|
175
|
+
else:
|
|
176
|
+
executed = [_execute(idx) for idx in pending]
|
|
177
|
+
for idx, result in executed:
|
|
178
|
+
call = tool_calls[idx]
|
|
179
|
+
results[idx] = result
|
|
180
|
+
outcomes[idx] = ToolOutcome(
|
|
181
|
+
call.name, call.arguments, True, result.is_error
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Scrub secrets, then cap every result before it enters context
|
|
185
|
+
# (design §8.4, §18.1). This is the single processing point for all
|
|
186
|
+
# tools — builtin and future. Redaction runs on the full content
|
|
187
|
+
# (before truncation, so a secret can't be split past the cap), and
|
|
188
|
+
# structured `data` is left intact.
|
|
189
|
+
redact = getattr(self.config, "redact_tool_output", True)
|
|
190
|
+
for r in results:
|
|
191
|
+
if redact:
|
|
192
|
+
r.content, _ = redact_secrets(r.content)
|
|
193
|
+
r.content = truncate_text(r.content, self.config.max_tool_result_tokens)
|
|
194
|
+
|
|
195
|
+
self.telemetry.record_turn(
|
|
196
|
+
iteration, resp, tool_outcomes=outcomes, compaction=compaction
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# One tool-role message carries every result, paired to its call id.
|
|
200
|
+
# The adapter fans this out to the provider's shape (design §8.1).
|
|
201
|
+
self.context.add(Message(role="tool", tool_results=results))
|
|
202
|
+
|
|
203
|
+
self._persist_memory(session_id)
|
|
204
|
+
return "Stopped: reached max iterations without a final answer."
|
|
205
|
+
|
|
206
|
+
# --- memory helper ------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def _persist_memory(self, session_id: str) -> None:
|
|
209
|
+
if self.memory is not None:
|
|
210
|
+
self.memory.save(session_id, self._messages_for_storage())
|
|
211
|
+
|
|
212
|
+
def _messages_for_storage(self) -> list[Message]:
|
|
213
|
+
"""Return the conversation, compacted if a persistence budget is set.
|
|
214
|
+
|
|
215
|
+
This applies the same deterministic compaction the main context uses,
|
|
216
|
+
but with a separate ``memory_store_budget`` tuned for on-disk recall.
|
|
217
|
+
Keeping only a summary plus recent turns keeps the store lightweight.
|
|
218
|
+
"""
|
|
219
|
+
messages = self.context.messages()
|
|
220
|
+
budget = getattr(self.config, "memory_store_budget", None)
|
|
221
|
+
if budget is None or budget <= 0:
|
|
222
|
+
return messages
|
|
223
|
+
cm = ContextManager(budget=budget)
|
|
224
|
+
for m in messages:
|
|
225
|
+
cm.add(m)
|
|
226
|
+
return cm.window()
|
|
227
|
+
|
|
228
|
+
def _prepare_user_message(self, user_input: str) -> str:
|
|
229
|
+
"""Augment the user input with relevant long-term memory when configured.
|
|
230
|
+
|
|
231
|
+
This keeps memory at the model's fingertips for the current turn without
|
|
232
|
+
changing the stable system-prompt prefix.
|
|
233
|
+
"""
|
|
234
|
+
if not user_input or not self.notes or not getattr(
|
|
235
|
+
self.config, "memory_auto_context", False
|
|
236
|
+
):
|
|
237
|
+
return user_input
|
|
238
|
+
limit = getattr(self.config, "memory_auto_context_limit", 3)
|
|
239
|
+
try:
|
|
240
|
+
notes = self.notes.search(user_input, limit=limit)
|
|
241
|
+
except Exception: # noqa: BLE001 - best-effort recall must not crash the run
|
|
242
|
+
# Auto-context is a convenience layered before the loop. If recall
|
|
243
|
+
# fails (embedding endpoint down, API key missing, store error), fall
|
|
244
|
+
# back to the plain user input rather than taking down the session.
|
|
245
|
+
return user_input
|
|
246
|
+
if not notes:
|
|
247
|
+
return user_input
|
|
248
|
+
lines = ["Relevant long-term memory:"]
|
|
249
|
+
for n in notes:
|
|
250
|
+
lines.append(f"- {n.text}")
|
|
251
|
+
lines.append("---")
|
|
252
|
+
lines.append(user_input)
|
|
253
|
+
return "\n".join(lines)
|
|
254
|
+
|
|
255
|
+
# --- profile seams (design §13, Phase 5) -------------------------------
|
|
256
|
+
|
|
257
|
+
def _tools_for(self, profile: Any | None) -> list[ToolSpec]:
|
|
258
|
+
"""The tool set for this run. Stable across turns to keep the prefix
|
|
259
|
+
cacheable (design §9.3): assembled from the registry's registration
|
|
260
|
+
order and not re-sorted."""
|
|
261
|
+
specs = self.registry.specs()
|
|
262
|
+
tool_filter = getattr(profile, "tool_filter", None)
|
|
263
|
+
if tool_filter is not None:
|
|
264
|
+
allowed = set(tool_filter)
|
|
265
|
+
specs = [s for s in specs if s.name in allowed]
|
|
266
|
+
return specs
|
|
267
|
+
|
|
268
|
+
def _provider_for(self, profile: Any | None):
|
|
269
|
+
"""Honor ``profile.model_override`` for this run (design §13, Phase 5).
|
|
270
|
+
|
|
271
|
+
Returns a copy of the provider bound to the override model when set and
|
|
272
|
+
the provider supports ``with_model``; otherwise the injected provider."""
|
|
273
|
+
override = getattr(profile, "model_override", None)
|
|
274
|
+
if (
|
|
275
|
+
override
|
|
276
|
+
and override != getattr(self.provider, "model", None)
|
|
277
|
+
and hasattr(self.provider, "with_model")
|
|
278
|
+
):
|
|
279
|
+
return self.provider.with_model(override)
|
|
280
|
+
return self.provider
|
|
281
|
+
|
|
282
|
+
def _system_for(self, profile: Any | None) -> str | None:
|
|
283
|
+
"""Combine profile system prompt and active skill additions.
|
|
284
|
+
|
|
285
|
+
The cacheable prefix stays stable because tools and system Prompt are
|
|
286
|
+
assembled once per run.
|
|
287
|
+
"""
|
|
288
|
+
parts: list[str] = []
|
|
289
|
+
profile_prompt = getattr(profile, "system_prompt", None)
|
|
290
|
+
if profile_prompt:
|
|
291
|
+
parts.append(profile_prompt)
|
|
292
|
+
if self.context_source is not None:
|
|
293
|
+
parts.extend(self.context_source.system_additions())
|
|
294
|
+
if not parts:
|
|
295
|
+
return None
|
|
296
|
+
return "\n\n".join(parts)
|
|
297
|
+
|
|
298
|
+
@staticmethod
|
|
299
|
+
def _needs_approval(spec: ToolSpec | None) -> bool:
|
|
300
|
+
return bool(spec and spec.gated)
|
|
301
|
+
|
|
302
|
+
def _approve_plan(
|
|
303
|
+
self, calls: list[ToolCall], specs: list[ToolSpec | None]
|
|
304
|
+
) -> bool:
|
|
305
|
+
"""Ask the approver for the whole batch; fall back to per-call approval."""
|
|
306
|
+
if hasattr(self.approver, "approve_plan"):
|
|
307
|
+
return self.approver.approve_plan(calls, specs)
|
|
308
|
+
for call, spec in zip(calls, specs, strict=True):
|
|
309
|
+
if self._needs_approval(spec) and not self.approver.approve(call, spec):
|
|
310
|
+
return False
|
|
311
|
+
return True
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Approval gate and execution boundary (design §10)."""
|
|
2
|
+
|
|
3
|
+
from agentkernel.approval.base import Approver, Sandbox
|
|
4
|
+
from agentkernel.approval.cli import AutoApprover, CliApprover
|
|
5
|
+
from agentkernel.approval.policy import decide
|
|
6
|
+
from agentkernel.approval.sandbox import (
|
|
7
|
+
DockerSandbox,
|
|
8
|
+
LocalSandbox,
|
|
9
|
+
SandboxError,
|
|
10
|
+
make_sandbox,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Approver",
|
|
15
|
+
"Sandbox",
|
|
16
|
+
"AutoApprover",
|
|
17
|
+
"CliApprover",
|
|
18
|
+
"LocalSandbox",
|
|
19
|
+
"DockerSandbox",
|
|
20
|
+
"SandboxError",
|
|
21
|
+
"make_sandbox",
|
|
22
|
+
"decide",
|
|
23
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Approver protocol (design §10.1).
|
|
2
|
+
|
|
3
|
+
The loop consults the approver before executing any gated tool (one whose
|
|
4
|
+
``requires_approval``, ``mutates``, or ``runs_code`` flag is set). A denial
|
|
5
|
+
produces a ``ToolResult(is_error=True)``; it never raises. The Sandbox protocol
|
|
6
|
+
and approval policies land in M3.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, Protocol
|
|
12
|
+
|
|
13
|
+
from agentkernel.types import ToolCall
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from agentkernel.tools import ToolSpec
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Approver(Protocol):
|
|
20
|
+
def approve(self, call: ToolCall, spec: ToolSpec) -> bool: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Sandbox(Protocol):
|
|
24
|
+
"""Execution boundary for ``runs_code`` tools (design §10.3).
|
|
25
|
+
|
|
26
|
+
``run`` executes a command confined to ``cwd`` and returns
|
|
27
|
+
``(exit_code, stdout, stderr)``. ``LocalSandbox`` confines to a subprocess;
|
|
28
|
+
``DockerSandbox`` runs in a per-project container. ``close`` releases any
|
|
29
|
+
persistent resources (e.g. the container) and is a no-op for ``LocalSandbox``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def run(self, command: str, *, cwd: str, timeout: int) -> tuple[int, str, str]: ...
|
|
33
|
+
|
|
34
|
+
def close(self) -> None: ...
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Approver implementations (design §10.2).
|
|
2
|
+
|
|
3
|
+
Both apply the shared policy in ``policy.py``. ``CliApprover`` prompts the
|
|
4
|
+
terminal when the policy says ``ask``; ``AutoApprover`` never prompts (for tests
|
|
5
|
+
and non-interactive runs) and resolves ``ask`` to a fixed default.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from agentkernel.approval.policy import decide
|
|
15
|
+
from agentkernel.types import ToolCall
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from agentkernel.approval.risk import RiskJudge
|
|
19
|
+
from agentkernel.tools import ToolSpec
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _summarize(call: ToolCall) -> str:
|
|
23
|
+
"""One-line, side-effect-free description of a pending call for the prompt."""
|
|
24
|
+
try:
|
|
25
|
+
args = json.dumps(call.arguments, ensure_ascii=False)
|
|
26
|
+
except (TypeError, ValueError):
|
|
27
|
+
args = str(call.arguments)
|
|
28
|
+
return f"{call.name}({args})"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AutoApprover:
|
|
32
|
+
"""Non-interactive approver. Applies policy; resolves ``ask`` to ``ask_default``.
|
|
33
|
+
|
|
34
|
+
Defaults (no args) allow everything, which is what the offline test agents
|
|
35
|
+
rely on. Pass ``ask_default=False`` to exercise the denial path.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
policy: str = "always_ask",
|
|
41
|
+
*,
|
|
42
|
+
allowlist: list[str] | None = None,
|
|
43
|
+
ask_default: bool = True,
|
|
44
|
+
risk_judge: RiskJudge | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._policy = policy
|
|
47
|
+
self._allowlist = allowlist or []
|
|
48
|
+
self._ask_default = ask_default
|
|
49
|
+
self._risk_judge = risk_judge
|
|
50
|
+
|
|
51
|
+
def approve(self, call: ToolCall, spec: ToolSpec) -> bool:
|
|
52
|
+
decision = decide(self._policy, spec, call, self._allowlist)
|
|
53
|
+
if decision == "allow":
|
|
54
|
+
return True
|
|
55
|
+
if decision == "deny":
|
|
56
|
+
return False
|
|
57
|
+
if (
|
|
58
|
+
self._policy == "smart"
|
|
59
|
+
and self._risk_judge is not None
|
|
60
|
+
and self._risk_judge.is_low_risk(call, spec) is True
|
|
61
|
+
):
|
|
62
|
+
return True
|
|
63
|
+
return self._ask_default
|
|
64
|
+
|
|
65
|
+
def approve_plan(self, calls: list[ToolCall], specs: list[ToolSpec | None]) -> bool:
|
|
66
|
+
decisions = [
|
|
67
|
+
decide(self._policy, spec, call, self._allowlist)
|
|
68
|
+
for call, spec in zip(calls, specs, strict=True)
|
|
69
|
+
]
|
|
70
|
+
if any(d == "deny" for d in decisions):
|
|
71
|
+
return False
|
|
72
|
+
if all(d == "allow" for d in decisions):
|
|
73
|
+
return True
|
|
74
|
+
return self._ask_default
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class CliApprover:
|
|
78
|
+
"""Interactive approver: prints the pending call and reads y/n when the
|
|
79
|
+
policy requires asking. ``input_fn``/``output_fn`` are injectable for tests."""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
policy: str = "always_ask",
|
|
84
|
+
*,
|
|
85
|
+
allowlist: list[str] | None = None,
|
|
86
|
+
input_fn: Callable[[str], str] = input,
|
|
87
|
+
output_fn: Callable[[str], None] = print,
|
|
88
|
+
risk_judge: RiskJudge | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
self._policy = policy
|
|
91
|
+
self._allowlist = allowlist or []
|
|
92
|
+
self._input = input_fn
|
|
93
|
+
self._output = output_fn
|
|
94
|
+
self._risk_judge = risk_judge
|
|
95
|
+
|
|
96
|
+
def approve(self, call: ToolCall, spec: ToolSpec) -> bool:
|
|
97
|
+
decision = decide(self._policy, spec, call, self._allowlist)
|
|
98
|
+
if decision == "allow":
|
|
99
|
+
return True
|
|
100
|
+
if decision == "deny":
|
|
101
|
+
self._output(f"Denied by policy: {_summarize(call)}")
|
|
102
|
+
return False
|
|
103
|
+
# smart mode: let the risk judge auto-approve clearly low-risk calls;
|
|
104
|
+
# high-risk or undecided falls through to the human prompt.
|
|
105
|
+
if (
|
|
106
|
+
self._policy == "smart"
|
|
107
|
+
and self._risk_judge is not None
|
|
108
|
+
and self._risk_judge.is_low_risk(call, spec) is True
|
|
109
|
+
):
|
|
110
|
+
self._output(f"Auto-approved (low risk): {_summarize(call)}")
|
|
111
|
+
return True
|
|
112
|
+
answer = self._input(f"Approve {_summarize(call)}? [y/N] ").strip().lower()
|
|
113
|
+
return answer in ("y", "yes")
|
|
114
|
+
|
|
115
|
+
def approve_plan(self, calls: list[ToolCall], specs: list[ToolSpec | None]) -> bool:
|
|
116
|
+
decisions = [
|
|
117
|
+
decide(self._policy, spec, call, self._allowlist)
|
|
118
|
+
for call, spec in zip(calls, specs, strict=True)
|
|
119
|
+
]
|
|
120
|
+
if any(d == "deny" for d in decisions):
|
|
121
|
+
self._output("Denied by policy for at least one planned tool.")
|
|
122
|
+
return False
|
|
123
|
+
if all(d == "allow" for d in decisions):
|
|
124
|
+
return True
|
|
125
|
+
self._output("Proposed plan:")
|
|
126
|
+
for i, call in enumerate(calls, 1):
|
|
127
|
+
self._output(f" {i}. {_summarize(call)}")
|
|
128
|
+
answer = self._input("Approve entire plan? [y/N] ").strip().lower()
|
|
129
|
+
return answer in ("y", "yes")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Approval-policy decision, shared by every Approver (design §10.2).
|
|
2
|
+
|
|
3
|
+
Policies: ``always_ask`` (default), ``auto_allow``, ``deny_mutations``, and
|
|
4
|
+
``smart``. An optional allowlist of patterns (matched against the tool name and,
|
|
5
|
+
for shell tools, the command) skips the gate. The loop only consults an approver
|
|
6
|
+
for gated tools, but ``decide`` stays safe for non-gated ones too.
|
|
7
|
+
|
|
8
|
+
``smart`` resolves here to ``ask`` (the safe default); the approver, which has a
|
|
9
|
+
provider, may consult a risk judge before that prompt and auto-approve low-risk
|
|
10
|
+
calls. ``decide`` is pure and has no model, so the judging lives in the approver.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import fnmatch
|
|
16
|
+
from typing import TYPE_CHECKING, Literal
|
|
17
|
+
|
|
18
|
+
from agentkernel.types import ToolCall
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from agentkernel.tools import ToolSpec
|
|
22
|
+
|
|
23
|
+
Decision = Literal["allow", "deny", "ask"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _allowlisted(call: ToolCall, allowlist: list[str]) -> bool:
|
|
27
|
+
if not allowlist:
|
|
28
|
+
return False
|
|
29
|
+
targets = [call.name]
|
|
30
|
+
command = call.arguments.get("command")
|
|
31
|
+
if isinstance(command, str):
|
|
32
|
+
targets.append(command)
|
|
33
|
+
for pattern in allowlist:
|
|
34
|
+
for target in targets:
|
|
35
|
+
if target == pattern or target.startswith(pattern) or fnmatch.fnmatch(
|
|
36
|
+
target, pattern
|
|
37
|
+
):
|
|
38
|
+
return True
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def decide(
|
|
43
|
+
policy: str,
|
|
44
|
+
spec: ToolSpec,
|
|
45
|
+
call: ToolCall,
|
|
46
|
+
allowlist: list[str] | None = None,
|
|
47
|
+
) -> Decision:
|
|
48
|
+
"""Resolve a policy to allow / deny / ask for this call."""
|
|
49
|
+
if not spec.gated:
|
|
50
|
+
return "allow"
|
|
51
|
+
if _allowlisted(call, allowlist or []):
|
|
52
|
+
return "allow"
|
|
53
|
+
if policy == "auto_allow":
|
|
54
|
+
return "allow"
|
|
55
|
+
if policy == "deny_mutations":
|
|
56
|
+
return "deny" if (spec.mutates or spec.runs_code) else "allow"
|
|
57
|
+
# always_ask (default and unknown-policy fallback)
|
|
58
|
+
return "ask"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Risk judge for the ``smart`` approval mode (design §18.1).
|
|
2
|
+
|
|
3
|
+
A small auxiliary model classifies a pending gated tool call as low- or high-risk
|
|
4
|
+
so the approver can auto-approve the boring ones (reading a file, listing a dir)
|
|
5
|
+
and prompt only on the dangerous ones (``rm -rf``, overwriting config, anything
|
|
6
|
+
irreversible). It is intentionally conservative: any parse failure or provider
|
|
7
|
+
error returns ``None`` so the approver falls back to asking — a judge that can't
|
|
8
|
+
decide must never silently approve.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from agentkernel.types import Message
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from agentkernel.providers import Provider
|
|
21
|
+
from agentkernel.tools import ToolSpec
|
|
22
|
+
from agentkernel.types import ToolCall
|
|
23
|
+
|
|
24
|
+
_SYSTEM = (
|
|
25
|
+
"You are a security gate for an autonomous coding agent. Decide whether a "
|
|
26
|
+
"pending tool call is safe to auto-approve without a human. Treat as HIGH "
|
|
27
|
+
"risk anything destructive or irreversible: deleting or overwriting data, "
|
|
28
|
+
"force-resetting version control, modifying system or global config, "
|
|
29
|
+
"installing software, sending data over the network, or running shell "
|
|
30
|
+
"commands with broad/ambiguous scope. Treat as LOW risk read-only or easily "
|
|
31
|
+
"reversible, narrowly-scoped actions. When unsure, choose high. Respond with "
|
|
32
|
+
'ONLY a JSON object: {"risk": "low" | "high", "reason": "<short>"}.'
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _build_prompt(call: ToolCall, spec: ToolSpec | None) -> str:
|
|
37
|
+
flags = []
|
|
38
|
+
if spec is not None:
|
|
39
|
+
if spec.mutates:
|
|
40
|
+
flags.append("mutates")
|
|
41
|
+
if spec.runs_code:
|
|
42
|
+
flags.append("runs_code")
|
|
43
|
+
try:
|
|
44
|
+
args = json.dumps(call.arguments, ensure_ascii=False)
|
|
45
|
+
except (TypeError, ValueError):
|
|
46
|
+
args = str(call.arguments)
|
|
47
|
+
return (
|
|
48
|
+
f"Tool: {call.name}\n"
|
|
49
|
+
f"Flags: {', '.join(flags) or 'none'}\n"
|
|
50
|
+
f"Arguments: {args}\n\n"
|
|
51
|
+
"Classify the risk of running this call."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_risk(text: str) -> bool | None:
|
|
56
|
+
"""Return True (low risk), False (high risk), or None (undecided)."""
|
|
57
|
+
match = re.search(r"\{.*\}", text, re.DOTALL)
|
|
58
|
+
if not match:
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
data = json.loads(match.group(0))
|
|
62
|
+
except json.JSONDecodeError:
|
|
63
|
+
return None
|
|
64
|
+
risk = str(data.get("risk", "")).strip().lower()
|
|
65
|
+
if risk == "low":
|
|
66
|
+
return True
|
|
67
|
+
if risk == "high":
|
|
68
|
+
return False
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RiskJudge:
|
|
73
|
+
"""Classifies a pending tool call's risk with a cheap auxiliary model."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, provider: Provider, *, max_tokens: int = 256) -> None:
|
|
76
|
+
self._provider = provider
|
|
77
|
+
self._max_tokens = max_tokens
|
|
78
|
+
|
|
79
|
+
def is_low_risk(self, call: ToolCall, spec: ToolSpec | None) -> bool | None:
|
|
80
|
+
"""True if safe to auto-approve, False if it should be asked, None if the
|
|
81
|
+
judge could not decide (provider error or unparseable reply)."""
|
|
82
|
+
try:
|
|
83
|
+
resp = self._provider.complete(
|
|
84
|
+
[Message(role="user", content=_build_prompt(call, spec))],
|
|
85
|
+
[],
|
|
86
|
+
max_tokens=self._max_tokens,
|
|
87
|
+
system=_SYSTEM,
|
|
88
|
+
)
|
|
89
|
+
except Exception: # noqa: BLE001 - a judge failure must fall back to asking
|
|
90
|
+
return None
|
|
91
|
+
return _parse_risk(resp.message.content)
|