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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""The `kanban` tool (design §18.3).
|
|
2
|
+
|
|
3
|
+
Exposes a shared work-queue board to the model so a long mission — or several
|
|
4
|
+
cooperating sub-agents — can file, claim, and complete tasks. The board is the
|
|
5
|
+
durable JSON store in ``kanban.py``; the tool is a thin, single-entry dispatcher
|
|
6
|
+
over it, bound to one board (and an optional worker identity) by the factory.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from agentkernel.kanban import render_task
|
|
14
|
+
from agentkernel.tools.base import ToolSpec
|
|
15
|
+
from agentkernel.types import ToolResult
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from agentkernel.kanban import Board
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _board_view(board: Board) -> str:
|
|
22
|
+
tasks = board.list()
|
|
23
|
+
if not tasks:
|
|
24
|
+
return "(board is empty)"
|
|
25
|
+
open_n = sum(1 for t in tasks if t.status in ("todo", "in_progress"))
|
|
26
|
+
lines = [f"Board ({open_n} open / {len(tasks)} total):"]
|
|
27
|
+
lines += [f" {render_task(t)}" for t in tasks]
|
|
28
|
+
return "\n".join(lines)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def kanban_tool(board: Board, *, worker: str = "agent") -> ToolSpec:
|
|
32
|
+
"""Build the `kanban` tool over ``board``. ``worker`` labels who claims tasks."""
|
|
33
|
+
|
|
34
|
+
def kanban(args: dict) -> ToolResult:
|
|
35
|
+
action = args["action"]
|
|
36
|
+
|
|
37
|
+
if action == "list":
|
|
38
|
+
return ToolResult("", _board_view(board))
|
|
39
|
+
if action == "add":
|
|
40
|
+
title = (args.get("title") or "").strip()
|
|
41
|
+
if not title:
|
|
42
|
+
return ToolResult("", "add requires `title`.", is_error=True)
|
|
43
|
+
task = board.add(title)
|
|
44
|
+
return ToolResult("", f"Added {task.id}: {task.title}\n\n{_board_view(board)}")
|
|
45
|
+
if action == "next":
|
|
46
|
+
task = board.next_todo()
|
|
47
|
+
if task is None:
|
|
48
|
+
return ToolResult("", "No unclaimed tasks on the board.")
|
|
49
|
+
board.claim(task.id, worker)
|
|
50
|
+
return ToolResult("", f"Claimed {task.id}: {task.title}")
|
|
51
|
+
|
|
52
|
+
# The remaining actions target a specific task id.
|
|
53
|
+
task_id = args.get("id")
|
|
54
|
+
if not task_id:
|
|
55
|
+
return ToolResult("", f"{action} requires `id`.", is_error=True)
|
|
56
|
+
if action == "claim":
|
|
57
|
+
result = board.claim(task_id, args.get("assignee") or worker)
|
|
58
|
+
elif action == "complete":
|
|
59
|
+
result = board.complete(task_id)
|
|
60
|
+
elif action == "block":
|
|
61
|
+
result = board.block(task_id, args.get("reason") or "")
|
|
62
|
+
elif action == "comment":
|
|
63
|
+
text = (args.get("text") or "").strip()
|
|
64
|
+
if not text:
|
|
65
|
+
return ToolResult("", "comment requires `text`.", is_error=True)
|
|
66
|
+
result = board.comment(task_id, text)
|
|
67
|
+
else: # pragma: no cover - schema restricts the enum
|
|
68
|
+
return ToolResult("", f"unknown action {action!r}", is_error=True)
|
|
69
|
+
|
|
70
|
+
if result is None:
|
|
71
|
+
return ToolResult("", f"No task with id={task_id}.", is_error=True)
|
|
72
|
+
return ToolResult("", f"{render_task(result)}")
|
|
73
|
+
|
|
74
|
+
return ToolSpec(
|
|
75
|
+
name="kanban",
|
|
76
|
+
description=(
|
|
77
|
+
"Coordinate work on a shared task board. Use it to break a large job "
|
|
78
|
+
"into tasks and track them, or — as a worker — to pull and finish work. "
|
|
79
|
+
"Actions: list; add (title); next (claim the next todo); claim/complete/"
|
|
80
|
+
"block/comment (id, plus reason/text where relevant)."
|
|
81
|
+
),
|
|
82
|
+
parameters={
|
|
83
|
+
"type": "object",
|
|
84
|
+
"properties": {
|
|
85
|
+
"action": {
|
|
86
|
+
"type": "string",
|
|
87
|
+
"enum": ["list", "add", "next", "claim", "complete", "block", "comment"],
|
|
88
|
+
},
|
|
89
|
+
"title": {"type": "string", "description": "Task title (for add)."},
|
|
90
|
+
"id": {"type": "string", "description": "Task id (claim/complete/block/comment)."},
|
|
91
|
+
"assignee": {"type": "string", "description": "Who claims it (default: you)."},
|
|
92
|
+
"reason": {"type": "string", "description": "Why it's blocked (for block)."},
|
|
93
|
+
"text": {"type": "string", "description": "Comment text (for comment)."},
|
|
94
|
+
},
|
|
95
|
+
"required": ["action"],
|
|
96
|
+
"additionalProperties": False,
|
|
97
|
+
},
|
|
98
|
+
handler=kanban,
|
|
99
|
+
category="coordination",
|
|
100
|
+
)
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Read-only discovery tools: find_files, search_text, file_info (design §6.3).
|
|
2
|
+
|
|
3
|
+
These let the model locate and inspect code without shelling out to `bash`, so
|
|
4
|
+
they work the same on every platform and inside the no-network Docker sandbox.
|
|
5
|
+
Like the file tools, every path is confined to the working directory and every
|
|
6
|
+
failure is returned as an error result, never raised (AGENT.md, design §8.3).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from agentkernel.context.truncate import truncate_text
|
|
16
|
+
from agentkernel.tools.base import ToolSpec
|
|
17
|
+
from agentkernel.tools.builtin.files import resolve_within
|
|
18
|
+
from agentkernel.types import ToolResult
|
|
19
|
+
|
|
20
|
+
# Directories that are never worth walking for code search.
|
|
21
|
+
_SKIP_DIRS = {".git", ".venv", "__pycache__", "node_modules", ".agentkernel", ".pytest_cache"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_probably_binary(data: bytes) -> bool:
|
|
25
|
+
"""A cheap heuristic: a NUL byte in the first chunk means "not text"."""
|
|
26
|
+
return b"\x00" in data[:1024]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _iter_files(base: Path, pattern: str):
|
|
30
|
+
"""Yield files under ``base`` matching ``pattern``, skipping noise dirs."""
|
|
31
|
+
for p in sorted(base.glob(pattern)):
|
|
32
|
+
if not p.is_file():
|
|
33
|
+
continue
|
|
34
|
+
if any(part in _SKIP_DIRS for part in p.parts):
|
|
35
|
+
continue
|
|
36
|
+
yield p
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def search_tools(working_dir: str = ".", *, max_result_tokens: int = 4096) -> list[ToolSpec]:
|
|
40
|
+
"""Build the read-only discovery toolset bound to ``working_dir``."""
|
|
41
|
+
root = Path(working_dir).resolve()
|
|
42
|
+
|
|
43
|
+
def _rel(p: Path) -> str:
|
|
44
|
+
return p.relative_to(root).as_posix()
|
|
45
|
+
|
|
46
|
+
def find_files(args: dict) -> ToolResult:
|
|
47
|
+
pattern = args["pattern"]
|
|
48
|
+
sub = args.get("path", ".")
|
|
49
|
+
try:
|
|
50
|
+
base = resolve_within(root, sub)
|
|
51
|
+
except ValueError as exc:
|
|
52
|
+
return ToolResult("", str(exc), is_error=True)
|
|
53
|
+
if not base.is_dir():
|
|
54
|
+
return ToolResult("", f"Not a directory: {sub!r}", is_error=True)
|
|
55
|
+
matches = []
|
|
56
|
+
for p in sorted(base.glob(pattern)):
|
|
57
|
+
if any(part in _SKIP_DIRS for part in p.relative_to(root).parts):
|
|
58
|
+
continue
|
|
59
|
+
matches.append(_rel(p) + ("/" if p.is_dir() else ""))
|
|
60
|
+
if not matches:
|
|
61
|
+
return ToolResult("", f"(no files match {pattern!r})")
|
|
62
|
+
return ToolResult("", truncate_text("\n".join(matches), max_result_tokens))
|
|
63
|
+
|
|
64
|
+
def search_text(args: dict) -> ToolResult:
|
|
65
|
+
pattern = args["pattern"]
|
|
66
|
+
glob = args.get("glob", "**/*")
|
|
67
|
+
sub = args.get("path", ".")
|
|
68
|
+
max_results = int(args.get("max_results", 100))
|
|
69
|
+
flags = re.IGNORECASE if args.get("ignore_case") else 0
|
|
70
|
+
try:
|
|
71
|
+
regex = re.compile(pattern, flags)
|
|
72
|
+
except re.error as exc:
|
|
73
|
+
return ToolResult("", f"Invalid regex {pattern!r}: {exc}", is_error=True)
|
|
74
|
+
try:
|
|
75
|
+
base = resolve_within(root, sub)
|
|
76
|
+
except ValueError as exc:
|
|
77
|
+
return ToolResult("", str(exc), is_error=True)
|
|
78
|
+
if not base.is_dir():
|
|
79
|
+
return ToolResult("", f"Not a directory: {sub!r}", is_error=True)
|
|
80
|
+
|
|
81
|
+
hits: list[str] = []
|
|
82
|
+
truncated = False
|
|
83
|
+
for f in _iter_files(base, glob):
|
|
84
|
+
try:
|
|
85
|
+
raw = f.read_bytes()
|
|
86
|
+
except OSError:
|
|
87
|
+
continue
|
|
88
|
+
if _is_probably_binary(raw):
|
|
89
|
+
continue
|
|
90
|
+
rel = _rel(f)
|
|
91
|
+
for lineno, line in enumerate(raw.decode("utf-8", "replace").splitlines(), 1):
|
|
92
|
+
if regex.search(line):
|
|
93
|
+
hits.append(f"{rel}:{lineno}: {line.strip()}")
|
|
94
|
+
if len(hits) >= max_results:
|
|
95
|
+
truncated = True
|
|
96
|
+
break
|
|
97
|
+
if truncated:
|
|
98
|
+
break
|
|
99
|
+
if not hits:
|
|
100
|
+
return ToolResult("", f"(no matches for {pattern!r})")
|
|
101
|
+
body = "\n".join(hits)
|
|
102
|
+
if truncated:
|
|
103
|
+
body += f"\n... (stopped at {max_results} matches)"
|
|
104
|
+
return ToolResult("", truncate_text(body, max_result_tokens))
|
|
105
|
+
|
|
106
|
+
def file_info(args: dict) -> ToolResult:
|
|
107
|
+
path = args["path"]
|
|
108
|
+
try:
|
|
109
|
+
target = resolve_within(root, path)
|
|
110
|
+
except ValueError as exc:
|
|
111
|
+
return ToolResult("", str(exc), is_error=True)
|
|
112
|
+
if not target.exists():
|
|
113
|
+
return ToolResult("", f"No such path: {path!r}", is_error=True)
|
|
114
|
+
st = target.stat()
|
|
115
|
+
modified = datetime.fromtimestamp(st.st_mtime, tz=UTC).isoformat(timespec="seconds")
|
|
116
|
+
kind = "directory" if target.is_dir() else "file"
|
|
117
|
+
lines = [
|
|
118
|
+
f"path: {_rel(target)}",
|
|
119
|
+
f"type: {kind}",
|
|
120
|
+
f"size: {st.st_size} bytes",
|
|
121
|
+
f"modified: {modified}",
|
|
122
|
+
]
|
|
123
|
+
if target.is_dir():
|
|
124
|
+
lines.append(f"entries: {sum(1 for _ in target.iterdir())}")
|
|
125
|
+
elif not _is_probably_binary(target.read_bytes()[:1024]):
|
|
126
|
+
line_count = target.read_text(encoding="utf-8", errors="replace").count("\n") + 1
|
|
127
|
+
lines.append(f"lines: {line_count}")
|
|
128
|
+
else:
|
|
129
|
+
lines.append("content: binary")
|
|
130
|
+
return ToolResult("", "\n".join(lines))
|
|
131
|
+
|
|
132
|
+
return [
|
|
133
|
+
ToolSpec(
|
|
134
|
+
name="find_files",
|
|
135
|
+
description=(
|
|
136
|
+
"Find files and directories by glob pattern within the working "
|
|
137
|
+
"directory — your first move when you know roughly what a file is "
|
|
138
|
+
"named but not where it lives. Supports `**` for recursive match "
|
|
139
|
+
"(e.g. '**/*.py', 'src/**/test_*.py'). Returns matching paths; "
|
|
140
|
+
"noise dirs like .git and __pycache__ are skipped."
|
|
141
|
+
),
|
|
142
|
+
parameters={
|
|
143
|
+
"type": "object",
|
|
144
|
+
"properties": {
|
|
145
|
+
"pattern": {
|
|
146
|
+
"type": "string",
|
|
147
|
+
"description": "Glob pattern, e.g. '**/*.py' or 'docs/*.md'.",
|
|
148
|
+
},
|
|
149
|
+
"path": {
|
|
150
|
+
"type": "string",
|
|
151
|
+
"description": (
|
|
152
|
+
"Subdirectory to search under (default: the working dir root)."
|
|
153
|
+
),
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
"required": ["pattern"],
|
|
157
|
+
"additionalProperties": False,
|
|
158
|
+
},
|
|
159
|
+
handler=find_files,
|
|
160
|
+
category="search",
|
|
161
|
+
),
|
|
162
|
+
ToolSpec(
|
|
163
|
+
name="search_text",
|
|
164
|
+
description=(
|
|
165
|
+
"Search file contents by regular expression within the working "
|
|
166
|
+
"directory (a built-in grep) — use it to locate where a symbol, "
|
|
167
|
+
"string, or pattern is defined or used. Returns 'path:line: text' "
|
|
168
|
+
"for each match. Filter the files searched with `glob` and narrow "
|
|
169
|
+
"with `path`; binary files and noise dirs are skipped."
|
|
170
|
+
),
|
|
171
|
+
parameters={
|
|
172
|
+
"type": "object",
|
|
173
|
+
"properties": {
|
|
174
|
+
"pattern": {
|
|
175
|
+
"type": "string",
|
|
176
|
+
"description": "Python regular expression to match against each line.",
|
|
177
|
+
},
|
|
178
|
+
"glob": {
|
|
179
|
+
"type": "string",
|
|
180
|
+
"description": "Restrict to files matching this glob (default '**/*').",
|
|
181
|
+
},
|
|
182
|
+
"path": {
|
|
183
|
+
"type": "string",
|
|
184
|
+
"description": (
|
|
185
|
+
"Subdirectory to search under (default: the working dir root)."
|
|
186
|
+
),
|
|
187
|
+
},
|
|
188
|
+
"ignore_case": {
|
|
189
|
+
"type": "boolean",
|
|
190
|
+
"description": "Case-insensitive match.",
|
|
191
|
+
},
|
|
192
|
+
"max_results": {
|
|
193
|
+
"type": "integer",
|
|
194
|
+
"description": "Stop after this many matches (default 100).",
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
"required": ["pattern"],
|
|
198
|
+
"additionalProperties": False,
|
|
199
|
+
},
|
|
200
|
+
handler=search_text,
|
|
201
|
+
category="search",
|
|
202
|
+
),
|
|
203
|
+
ToolSpec(
|
|
204
|
+
name="file_info",
|
|
205
|
+
description=(
|
|
206
|
+
"Report metadata for a path within the working directory: whether "
|
|
207
|
+
"it is a file or directory, its size, last-modified time, and line "
|
|
208
|
+
"count (for text). Cheaper than reading a whole file when you only "
|
|
209
|
+
"need to know it exists or how big it is."
|
|
210
|
+
),
|
|
211
|
+
parameters={
|
|
212
|
+
"type": "object",
|
|
213
|
+
"properties": {
|
|
214
|
+
"path": {
|
|
215
|
+
"type": "string",
|
|
216
|
+
"description": "Path relative to the working directory.",
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
"required": ["path"],
|
|
220
|
+
"additionalProperties": False,
|
|
221
|
+
},
|
|
222
|
+
handler=file_info,
|
|
223
|
+
category="search",
|
|
224
|
+
),
|
|
225
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""The bash tool (design §6.3, §10.3).
|
|
2
|
+
|
|
3
|
+
Flagged ``runs_code`` + ``mutates`` + ``requires_approval``, so the loop gates
|
|
4
|
+
it through the approver and it executes only inside the injected ``Sandbox``,
|
|
5
|
+
confined to the working directory. Output is assembled here; the loop applies
|
|
6
|
+
the shared §8.4 truncation before it enters context.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from agentkernel.tools.base import ToolSpec
|
|
14
|
+
from agentkernel.types import ToolResult
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from agentkernel.approval import Sandbox
|
|
18
|
+
|
|
19
|
+
_BASH_SCHEMA = {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"properties": {
|
|
22
|
+
"command": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "The shell command to run in the working directory.",
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"required": ["command"],
|
|
28
|
+
"additionalProperties": False,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def bash_tool(sandbox: Sandbox, working_dir: str = ".", *, timeout: int = 60) -> ToolSpec:
|
|
33
|
+
"""Build the bash tool bound to ``sandbox`` and ``working_dir``."""
|
|
34
|
+
|
|
35
|
+
def bash(args: dict) -> ToolResult:
|
|
36
|
+
command = args["command"]
|
|
37
|
+
exit_code, stdout, stderr = sandbox.run(
|
|
38
|
+
command, cwd=working_dir, timeout=timeout
|
|
39
|
+
)
|
|
40
|
+
sections = []
|
|
41
|
+
if stdout:
|
|
42
|
+
sections.append(stdout.rstrip("\n"))
|
|
43
|
+
if stderr:
|
|
44
|
+
sections.append(f"[stderr]\n{stderr.rstrip(chr(10))}")
|
|
45
|
+
if exit_code != 0:
|
|
46
|
+
sections.append(f"[exit code {exit_code}]")
|
|
47
|
+
content = "\n".join(sections) if sections else "(no output)"
|
|
48
|
+
return ToolResult(
|
|
49
|
+
"",
|
|
50
|
+
content,
|
|
51
|
+
is_error=exit_code != 0,
|
|
52
|
+
data={"exit_code": exit_code},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return ToolSpec(
|
|
56
|
+
name="bash",
|
|
57
|
+
description=(
|
|
58
|
+
"Run a shell command in the working directory and return its output. "
|
|
59
|
+
"Use for builds, tests, git, and filesystem operations."
|
|
60
|
+
),
|
|
61
|
+
parameters=_BASH_SCHEMA,
|
|
62
|
+
handler=bash,
|
|
63
|
+
requires_approval=True,
|
|
64
|
+
mutates=True,
|
|
65
|
+
runs_code=True,
|
|
66
|
+
category="shell",
|
|
67
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""In-session todo tool (design §18.4).
|
|
2
|
+
|
|
3
|
+
A lightweight task list the model maintains while working through a multi-step
|
|
4
|
+
job, so its plan stays legible to both the model and the user. State is held in
|
|
5
|
+
memory for the life of one runtime (a session), bound into the handler by the
|
|
6
|
+
factory — no global state, no persistence.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from agentkernel.tools.base import ToolSpec
|
|
14
|
+
from agentkernel.types import ToolResult
|
|
15
|
+
|
|
16
|
+
_STATUS_MARK = {"pending": "[ ]", "in_progress": "[~]", "done": "[x]"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class TodoItem:
|
|
21
|
+
id: int
|
|
22
|
+
text: str
|
|
23
|
+
status: str = "pending" # pending | in_progress | done
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class TodoList:
|
|
28
|
+
"""The session's task list. Bound into the todo tool's handler."""
|
|
29
|
+
|
|
30
|
+
_items: list[TodoItem] = field(default_factory=list)
|
|
31
|
+
_next_id: int = 1
|
|
32
|
+
|
|
33
|
+
def add(self, text: str) -> TodoItem:
|
|
34
|
+
item = TodoItem(self._next_id, text.strip())
|
|
35
|
+
self._items.append(item)
|
|
36
|
+
self._next_id += 1
|
|
37
|
+
return item
|
|
38
|
+
|
|
39
|
+
def set_status(self, item_id: int, status: str) -> TodoItem | None:
|
|
40
|
+
for item in self._items:
|
|
41
|
+
if item.id == item_id:
|
|
42
|
+
item.status = status
|
|
43
|
+
return item
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def clear(self) -> None:
|
|
47
|
+
self._items.clear()
|
|
48
|
+
|
|
49
|
+
def render(self) -> str:
|
|
50
|
+
if not self._items:
|
|
51
|
+
return "(todo list is empty)"
|
|
52
|
+
done = sum(1 for i in self._items if i.status == "done")
|
|
53
|
+
lines = [f"Todos ({done}/{len(self._items)} done):"]
|
|
54
|
+
lines += [f" {_STATUS_MARK[i.status]} {i.id}. {i.text}" for i in self._items]
|
|
55
|
+
return "\n".join(lines)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def todo_tool(todo_list: TodoList) -> ToolSpec:
|
|
59
|
+
"""Build the `todo` tool over a session task list."""
|
|
60
|
+
|
|
61
|
+
def todo(args: dict) -> ToolResult:
|
|
62
|
+
action = args["action"]
|
|
63
|
+
if action == "add":
|
|
64
|
+
text = (args.get("text") or "").strip()
|
|
65
|
+
if not text:
|
|
66
|
+
return ToolResult("", "add requires non-empty `text`.", is_error=True)
|
|
67
|
+
todo_list.add(text)
|
|
68
|
+
return ToolResult("", todo_list.render())
|
|
69
|
+
if action in ("start", "complete"):
|
|
70
|
+
if "id" not in args:
|
|
71
|
+
return ToolResult("", f"{action} requires `id`.", is_error=True)
|
|
72
|
+
status = "in_progress" if action == "start" else "done"
|
|
73
|
+
if todo_list.set_status(int(args["id"]), status) is None:
|
|
74
|
+
return ToolResult("", f"No todo with id={args['id']}.", is_error=True)
|
|
75
|
+
return ToolResult("", todo_list.render())
|
|
76
|
+
if action == "clear":
|
|
77
|
+
todo_list.clear()
|
|
78
|
+
return ToolResult("", "Cleared the todo list.")
|
|
79
|
+
# action == "list"
|
|
80
|
+
return ToolResult("", todo_list.render())
|
|
81
|
+
|
|
82
|
+
return ToolSpec(
|
|
83
|
+
name="todo",
|
|
84
|
+
description=(
|
|
85
|
+
"Maintain a short task list for the current job so your plan stays "
|
|
86
|
+
"visible. Use it for multi-step work: add the steps, mark one `start` "
|
|
87
|
+
"as you begin it and `complete` when done, and `list` to review. "
|
|
88
|
+
"Actions: add (needs text), start/complete (need id), list, clear."
|
|
89
|
+
),
|
|
90
|
+
parameters={
|
|
91
|
+
"type": "object",
|
|
92
|
+
"properties": {
|
|
93
|
+
"action": {
|
|
94
|
+
"type": "string",
|
|
95
|
+
"enum": ["add", "start", "complete", "list", "clear"],
|
|
96
|
+
"description": "The operation to perform.",
|
|
97
|
+
},
|
|
98
|
+
"text": {"type": "string", "description": "Task text (for add)."},
|
|
99
|
+
"id": {"type": "integer", "description": "Task id (for start/complete)."},
|
|
100
|
+
},
|
|
101
|
+
"required": ["action"],
|
|
102
|
+
"additionalProperties": False,
|
|
103
|
+
},
|
|
104
|
+
handler=todo,
|
|
105
|
+
category="planning",
|
|
106
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Terminal UI for agentkernel — a curses-based interactive chat interface.
|
|
2
|
+
|
|
3
|
+
Provides a split-pane terminal UI with:
|
|
4
|
+
- Scrollable chat history (color-coded by role)
|
|
5
|
+
- Multi-line input area
|
|
6
|
+
- Status bar (model, tokens, cost)
|
|
7
|
+
- Background agent execution with live status indicator
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from agentkernel.tui import run_tui
|
|
11
|
+
run_tui(config) # or: uv run agentkernel tui
|
|
12
|
+
|
|
13
|
+
On Windows, ``pip install windows-curses`` first.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from agentkernel.config import Config
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_tui(config: Config) -> int:
|
|
26
|
+
"""Entry point: initialise curses and run the TUI event loop.
|
|
27
|
+
|
|
28
|
+
Returns 0 on clean exit, 1 on error.
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
import curses
|
|
32
|
+
except ImportError:
|
|
33
|
+
print(
|
|
34
|
+
"The TUI requires the `curses` module, which is not available.\n"
|
|
35
|
+
"On Windows, install it with: pip install windows-curses\n"
|
|
36
|
+
"On Unix, it should be included with your Python installation.",
|
|
37
|
+
file=sys.stderr,
|
|
38
|
+
)
|
|
39
|
+
return 1
|
|
40
|
+
|
|
41
|
+
from agentkernel.tui.app import TuiApp
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
app = TuiApp(config)
|
|
45
|
+
return curses.wrapper(app.run)
|
|
46
|
+
except KeyboardInterrupt:
|
|
47
|
+
return 0
|
|
48
|
+
except Exception as exc:
|
|
49
|
+
print(f"TUI error: {exc}", file=sys.stderr)
|
|
50
|
+
return 1
|