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
@@ -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"
@@ -0,0 +1,5 @@
1
+ """Enable ``python -m agentkernel`` (used for detached background runs)."""
2
+
3
+ from agentkernel.cli import main
4
+
5
+ raise SystemExit(main())
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)