mini-code-cli 0.0.8.4__py3-none-any.whl → 0.1.0.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.
- mini_code_cli/agent_loop.py +68 -11
- mini_code_cli/plan.py +55 -17
- mini_code_cli/prompts.py +36 -2
- mini_code_cli/skills.py +365 -0
- mini_code_cli/subagents/builtin.py +95 -3
- mini_code_cli/tools.py +115 -44
- {mini_code_cli-0.0.8.4.dist-info → mini_code_cli-0.1.0.0.dist-info}/METADATA +5 -2
- {mini_code_cli-0.0.8.4.dist-info → mini_code_cli-0.1.0.0.dist-info}/RECORD +12 -11
- {mini_code_cli-0.0.8.4.dist-info → mini_code_cli-0.1.0.0.dist-info}/WHEEL +0 -0
- {mini_code_cli-0.0.8.4.dist-info → mini_code_cli-0.1.0.0.dist-info}/entry_points.txt +0 -0
- {mini_code_cli-0.0.8.4.dist-info → mini_code_cli-0.1.0.0.dist-info}/licenses/LICENSE +0 -0
- {mini_code_cli-0.0.8.4.dist-info → mini_code_cli-0.1.0.0.dist-info}/top_level.txt +0 -0
mini_code_cli/agent_loop.py
CHANGED
|
@@ -16,6 +16,7 @@ smaller, focused modules so each concern is easy to read and test:
|
|
|
16
16
|
* :mod:`mini_code_cli.runner` -- running a tool with a wall-clock timeout.
|
|
17
17
|
* :mod:`mini_code_cli.config` -- shared default settings.
|
|
18
18
|
* :mod:`mini_code_cli.subagents` -- delegating a task to a sub-agent.
|
|
19
|
+
* :mod:`mini_code_cli.skills` -- the ``.mini`` skills feature.
|
|
19
20
|
|
|
20
21
|
For backward compatibility (and the test-suite) the most commonly imported
|
|
21
22
|
helpers are re-exported from here, so ``from mini_code_cli.agent_loop import ...``
|
|
@@ -32,6 +33,7 @@ from . import tools
|
|
|
32
33
|
from . import utils
|
|
33
34
|
from . import ui
|
|
34
35
|
from . import subagents
|
|
36
|
+
from . import skills
|
|
35
37
|
from . import plan as plan_utils
|
|
36
38
|
from .config import (
|
|
37
39
|
DEFAULT_CACHE_LOCATION,
|
|
@@ -116,14 +118,29 @@ def parse_args():
|
|
|
116
118
|
parser.add_argument("--allowed-dir", type=str, default=".", help="Allowed directory for file operations, default='./'")
|
|
117
119
|
parser.add_argument("-s", "--enable-shell", action="store_true", help="Allow shell execution. default= False")
|
|
118
120
|
parser.add_argument("-g","--enable-subagents", action="store_true", help="Allow the agent to delegate work to sub-agents (exposed as extra tools). default= False")
|
|
121
|
+
parser.add_argument("--skills", action="store_true", help="Enable the skills feature: a local .mini directory (inside --allowed-dir) of skill.md files, skill sub-dirs, a high-level SKILLS.md summary, and executable Python scripts, exposed to the agent as list_skills/read_skill/create_skill/run_skill tools. default= False")
|
|
119
122
|
parser.add_argument("--llm-timeout", type=int, default=DEFAULT_HTTP_TIMEOUT, help=f"HTTP timeout in seconds for LLM requests (reasoning models can be slow). default={DEFAULT_HTTP_TIMEOUT}")
|
|
120
123
|
parser.add_argument("--tool-timeout", type=int, default=DEFAULT_TOOL_TIMEOUT, help=f"Timeout in seconds for a single tool call. default={DEFAULT_TOOL_TIMEOUT}")
|
|
121
124
|
parser.add_argument("-p","--plan", action="store_true", help="Enable plan mode: the agent keeps a CURRENT_PLAN.md (its step-by-step plan, marked finished when the goal is done) and CURRENT_GOAL.md (the user's latest instruction) in the session directory. Also changes how history compaction works.")
|
|
122
125
|
|
|
126
|
+
parser.add_argument("--all", action="store_true", help="Enable: --plan, --subagents, --shell, --automode, --sudo")
|
|
127
|
+
parser.add_argument("--all-last", action="store_true", help="Enable: --plan, --subagents, --shell, --automode, --sudo, --load-last-session")
|
|
128
|
+
|
|
123
129
|
# Output verbosity
|
|
124
130
|
parser.add_argument("--verbose", action="store_true", help="Print the full, detailed output. Without it (default), only user/assistant turns, token usage and tool calls are shown.")
|
|
125
131
|
|
|
126
132
|
args = parser.parse_args()
|
|
133
|
+
|
|
134
|
+
if args.all or args.all_last:
|
|
135
|
+
args.plan = True
|
|
136
|
+
args.enable_subagents = True
|
|
137
|
+
args.enable_shell = True
|
|
138
|
+
args.sudo = True
|
|
139
|
+
args.auto_mode = True
|
|
140
|
+
|
|
141
|
+
if args.all_last:
|
|
142
|
+
args.load_last_session = True
|
|
143
|
+
|
|
127
144
|
return args
|
|
128
145
|
|
|
129
146
|
|
|
@@ -154,6 +171,10 @@ def print_help(allowed_dir, server_url, args, unique_id, cache_dir, model_name):
|
|
|
154
171
|
ui.console.emit(ui.console.style("Sub-agents:", bold=True, fg="cyan"))
|
|
155
172
|
for sa in subagents.get_registry().values():
|
|
156
173
|
ui.console.emit(f" {sa.name:16} {sa.description}")
|
|
174
|
+
if args.enable_skills or args.verbose:
|
|
175
|
+
ui.console.emit(ui.console.style("Skills:", bold=True, fg="cyan"))
|
|
176
|
+
for name in skills.SKILL_TOOL_NAMES:
|
|
177
|
+
ui.console.emit(f" {name:16} (from the local .mini directory)")
|
|
157
178
|
ui.console.rule(char="═", fg="cyan")
|
|
158
179
|
|
|
159
180
|
|
|
@@ -285,6 +306,19 @@ class AgentSession:
|
|
|
285
306
|
# real function.
|
|
286
307
|
self.tools_dict[sa.name] = {"function": None, "description": sa.description, "subagent": True}
|
|
287
308
|
|
|
309
|
+
# Skills (opt-in via --skills). Each registered skill tool is exposed to
|
|
310
|
+
# the model as a normal tool, but they are all sandbox-safe: the .mini
|
|
311
|
+
# directory lives *inside* --allowed-dir and the tools never accept a
|
|
312
|
+
# path from the model -- skill / script names are validated as single
|
|
313
|
+
# path segments and resolved under the .mini root. So skills are exposed
|
|
314
|
+
# even in sessions without --enable-shell (run_skill only executes a
|
|
315
|
+
# named Python file, never a free-form shell command).
|
|
316
|
+
self.enable_skills = bool(getattr(args, "enable_skills", False))
|
|
317
|
+
if self.enable_skills:
|
|
318
|
+
self.skills_tools_dict = skills.get_skills_tools_dict(self.allowed_dir)
|
|
319
|
+
self.llm_tools.extend(skills.get_skills_tools(self.allowed_dir))
|
|
320
|
+
self.tools_dict.update(self.skills_tools_dict)
|
|
321
|
+
|
|
288
322
|
# Token accounting for the whole session.
|
|
289
323
|
self.token_stats = {
|
|
290
324
|
"prompt_tokens": 0,
|
|
@@ -363,7 +397,11 @@ class AgentSession:
|
|
|
363
397
|
if self.args.system_prompt:
|
|
364
398
|
system_prompt = self.args.system_prompt
|
|
365
399
|
else:
|
|
366
|
-
system_prompt = prompts.system_prompt(
|
|
400
|
+
system_prompt = prompts.system_prompt(
|
|
401
|
+
tools_dict=self.tools_dict, allowed_dir=self.allowed_dir,
|
|
402
|
+
plan_mode=self.plan_mode,
|
|
403
|
+
skills_dir=self.allowed_dir if self.enable_skills else None,
|
|
404
|
+
)
|
|
367
405
|
|
|
368
406
|
messages = [{"role": "system", "content": system_prompt}]
|
|
369
407
|
|
|
@@ -619,20 +657,39 @@ class AgentSession:
|
|
|
619
657
|
Appends ``initial_task`` as a user turn, then keeps stepping until the
|
|
620
658
|
model makes no further tool calls (auto-mode). ``max_steps`` caps the
|
|
621
659
|
number of model calls so a misbehaving sub-agent cannot loop forever.
|
|
660
|
+
|
|
661
|
+
The sub-agent records its work in its own cache dir: every step persists
|
|
662
|
+
history / recorder / usage into the child's own session folder, and it
|
|
663
|
+
seeds CURRENT_GOAL.md / CURRENT_PLAN.md (in plan mode) so each sub-session
|
|
664
|
+
is self-contained and easy to inspect or resume. A final record is left
|
|
665
|
+
even on a mid-run failure (the seeded goal is marked finished).
|
|
622
666
|
"""
|
|
623
667
|
self.messages.append({"role": "user", "content": initial_task})
|
|
668
|
+
# Seed the goal up front so the child keeps a self-contained record in
|
|
669
|
+
# its own cache dir (a sub-agent always records a goal, independent of
|
|
670
|
+
# the parent's --plan flag). We use the child's own plan_manager here so
|
|
671
|
+
# nothing is written to the parent's session folder.
|
|
672
|
+
from .plan import PlanManager
|
|
673
|
+
child_plan = PlanManager(os.path.join(self.cache_dir, self.unique_id), enabled=True)
|
|
674
|
+
child_plan.write_goal(initial_task)
|
|
624
675
|
steps = 0
|
|
625
|
-
|
|
626
|
-
|
|
676
|
+
try:
|
|
677
|
+
while True:
|
|
678
|
+
should_continue = self._step()
|
|
679
|
+
self._persist()
|
|
680
|
+
steps += 1
|
|
681
|
+
if max_steps and steps >= max_steps:
|
|
682
|
+
self.console.note(
|
|
683
|
+
f"Sub-agent reached max_steps ({max_steps}); stopping.", "warn")
|
|
684
|
+
break
|
|
685
|
+
if not should_continue:
|
|
686
|
+
break
|
|
687
|
+
return self._last_assistant_text()
|
|
688
|
+
finally:
|
|
689
|
+
# Leave a clean, finished record in the child's cache dir even on an
|
|
690
|
+
# error / early stop, so the sub-session is never left open.
|
|
691
|
+
child_plan.mark_finished()
|
|
627
692
|
self._persist()
|
|
628
|
-
steps += 1
|
|
629
|
-
if max_steps and steps >= max_steps:
|
|
630
|
-
self.console.note(
|
|
631
|
-
f"Sub-agent reached max_steps ({max_steps}); stopping.", "warn")
|
|
632
|
-
break
|
|
633
|
-
if not should_continue:
|
|
634
|
-
break
|
|
635
|
-
return self._last_assistant_text()
|
|
636
693
|
|
|
637
694
|
def _last_assistant_text(self):
|
|
638
695
|
"""Return the content of the most recent assistant (non tool-call) turn."""
|
mini_code_cli/plan.py
CHANGED
|
@@ -6,14 +6,16 @@ active goal and the plan for reaching it:
|
|
|
6
6
|
|
|
7
7
|
* ``CURRENT_GOAL.md`` -- the user's latest instruction / goal (verbatim).
|
|
8
8
|
* ``CURRENT_PLAN.md`` -- the agent's current plan for that goal, followed by
|
|
9
|
-
a short running log (e.g. an entry recording when a goal was
|
|
9
|
+
a short running log (e.g. an entry recording when a goal was set/changed,
|
|
10
|
+
and when it was finished).
|
|
10
11
|
|
|
11
12
|
The plan document itself is authored by the agent (the system prompt tells it
|
|
12
13
|
to, and it uses its ``write_file`` tool). This manager owns the parts the loop
|
|
13
14
|
is responsible for -- seeding/reading the goal, reading the plan back so history
|
|
14
|
-
compaction can re-inject it, and appending the "goal finished"
|
|
15
|
-
deliberately free of any network or terminal dependency, and
|
|
16
|
-
no-op when disabled, so it is trivial to unit-test in
|
|
15
|
+
compaction can re-inject it, and appending the "goal finished" / goal-change log
|
|
16
|
+
entries. It is deliberately free of any network or terminal dependency, and
|
|
17
|
+
every method is a no-op when disabled, so it is trivial to unit-test in
|
|
18
|
+
isolation.
|
|
17
19
|
"""
|
|
18
20
|
|
|
19
21
|
import os
|
|
@@ -31,6 +33,11 @@ class PlanManager:
|
|
|
31
33
|
``history_{n}.json``). When ``enabled`` is False every method is a no-op, so
|
|
32
34
|
callers can use the manager unconditionally and let the ``--plan`` flag
|
|
33
35
|
decide whether anything actually happens.
|
|
36
|
+
|
|
37
|
+
``session_dir`` is the session directory (``cache_dir/<unique_id>``) -- i.e.
|
|
38
|
+
the files always live in the *cache* dir, independent of ``--allowed-dir``.
|
|
39
|
+
The manager writes with plain ``open`` (not through the file tools), so the
|
|
40
|
+
records are created even when the allowed dir is a different location.
|
|
34
41
|
"""
|
|
35
42
|
|
|
36
43
|
def __init__(self, session_dir, enabled=True, goal=None):
|
|
@@ -39,6 +46,10 @@ class PlanManager:
|
|
|
39
46
|
# The last goal this manager was asked to record, so callers can tell a
|
|
40
47
|
# new goal apart from the previous one. Kept in sync by write_goal().
|
|
41
48
|
self.goal = goal
|
|
49
|
+
# The last goal (stripped) that was logged into CURRENT_PLAN.md, so
|
|
50
|
+
# write_goal can tell a genuine change apart from a re-stated goal and
|
|
51
|
+
# avoid spamming the plan with duplicate "goal updated" entries.
|
|
52
|
+
self._last_logged_goal = None
|
|
42
53
|
|
|
43
54
|
# -- paths -------------------------------------------------------------
|
|
44
55
|
@property
|
|
@@ -50,20 +61,55 @@ class PlanManager:
|
|
|
50
61
|
return os.path.join(self.session_dir, GOAL_FILENAME)
|
|
51
62
|
|
|
52
63
|
# -- writing -----------------------------------------------------------
|
|
64
|
+
def _append_plan_note(self, text):
|
|
65
|
+
"""Append one dated line to CURRENT_PLAN.md (creating it if needed).
|
|
66
|
+
|
|
67
|
+
Shared by the goal-change log and ``mark_finished``. Best-effort and
|
|
68
|
+
never raises: a plan-tracking hiccup must never break the agent loop.
|
|
69
|
+
"""
|
|
70
|
+
if not self.enabled:
|
|
71
|
+
return
|
|
72
|
+
try:
|
|
73
|
+
os.makedirs(self.session_dir, exist_ok=True)
|
|
74
|
+
if not os.path.exists(self.plan_path):
|
|
75
|
+
with open(self.plan_path, "w", encoding="utf-8") as f:
|
|
76
|
+
f.write("# Current Plan\n\n")
|
|
77
|
+
with open(self.plan_path, "a", encoding="utf-8") as f:
|
|
78
|
+
f.write(text + "\n")
|
|
79
|
+
except OSError:
|
|
80
|
+
pass
|
|
81
|
+
|
|
53
82
|
def write_goal(self, goal):
|
|
54
83
|
"""Persist the user's current goal / last instruction to CURRENT_GOAL.md.
|
|
55
84
|
|
|
56
85
|
Overwrites any previous goal -- the file always reflects the *latest*
|
|
57
|
-
instruction.
|
|
58
|
-
|
|
86
|
+
instruction. Also makes sure ``CURRENT_PLAN.md`` exists in the cache dir
|
|
87
|
+
(so both tracking files are present from the very first goal), and -- in
|
|
88
|
+
plan mode -- logs the goal into CURRENT_PLAN.md the *first* time it is
|
|
89
|
+
set and again whenever it changes, so the plan records goal pivots.
|
|
90
|
+
Best-effort and never raises.
|
|
59
91
|
"""
|
|
60
92
|
if not self.enabled:
|
|
61
93
|
return
|
|
94
|
+
stripped = (goal or "").strip()
|
|
62
95
|
try:
|
|
63
96
|
os.makedirs(self.session_dir, exist_ok=True)
|
|
97
|
+
# Ensure CURRENT_PLAN.md exists in the cache dir too, so the goal
|
|
98
|
+
# always has a companion plan file sitting next to it.
|
|
99
|
+
if not os.path.exists(self.plan_path):
|
|
100
|
+
with open(self.plan_path, "w", encoding="utf-8") as f:
|
|
101
|
+
f.write("# Current Plan\n\n")
|
|
64
102
|
with open(self.goal_path, "w", encoding="utf-8") as f:
|
|
65
103
|
f.write("# Current Goal\n\n")
|
|
66
|
-
f.write(
|
|
104
|
+
f.write(stripped + "\n")
|
|
105
|
+
# Log the goal change into the plan -- but only on a genuine change
|
|
106
|
+
# (compared against the last goal actually logged), so re-stating the
|
|
107
|
+
# same goal on later turns does not spam the plan log.
|
|
108
|
+
if stripped and stripped != self._last_logged_goal:
|
|
109
|
+
verb = "Goal set to" if self._last_logged_goal is None else "Goal updated to"
|
|
110
|
+
stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
111
|
+
self._append_plan_note(f"- [{stamp}] {verb}: {stripped}")
|
|
112
|
+
self._last_logged_goal = stripped
|
|
67
113
|
self.goal = goal
|
|
68
114
|
except OSError:
|
|
69
115
|
pass
|
|
@@ -82,16 +128,8 @@ class PlanManager:
|
|
|
82
128
|
# never captured one itself, e.g. a one-shot --prompt preset), and only
|
|
83
129
|
# then fall back to a generic description.
|
|
84
130
|
goal = goal or self.goal or self._read(self.goal_path) or "current goal"
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if not os.path.exists(self.plan_path):
|
|
88
|
-
with open(self.plan_path, "w", encoding="utf-8") as f:
|
|
89
|
-
f.write("# Current Plan\n\n")
|
|
90
|
-
stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
91
|
-
with open(self.plan_path, "a", encoding="utf-8") as f:
|
|
92
|
-
f.write(f"\n- [{stamp}] Task finished: {goal}\n")
|
|
93
|
-
except OSError:
|
|
94
|
-
pass
|
|
131
|
+
stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
132
|
+
self._append_plan_note(f"- [{stamp}] Task finished: {goal}")
|
|
95
133
|
|
|
96
134
|
# -- reading -----------------------------------------------------------
|
|
97
135
|
def read_plan(self):
|
mini_code_cli/prompts.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# System prompt defining available tools and compaction behavior
|
|
2
|
-
def system_prompt(tools_dict=None, allowed_dir=None, plan_mode=False):
|
|
2
|
+
def system_prompt(tools_dict=None, allowed_dir=None, plan_mode=False, skills_dir=None):
|
|
3
3
|
"""
|
|
4
4
|
Takes a list of tool names and returns a system prompt string.
|
|
5
5
|
|
|
@@ -26,6 +26,10 @@ def system_prompt(tools_dict=None, allowed_dir=None, plan_mode=False):
|
|
|
26
26
|
if allowed_dir:
|
|
27
27
|
allowed_dir_prompt = f"\nThe path you are allowed to operate in is called: {allowed_dir}"
|
|
28
28
|
|
|
29
|
+
skills_prompt = ""
|
|
30
|
+
if skills_dir is not None:
|
|
31
|
+
skills_prompt = "\n" + skills_instructions(skills_dir)
|
|
32
|
+
|
|
29
33
|
plan_mode_prompt = ""
|
|
30
34
|
if plan_mode:
|
|
31
35
|
plan_mode_prompt = "\n" + plan_instructions()
|
|
@@ -33,7 +37,7 @@ def system_prompt(tools_dict=None, allowed_dir=None, plan_mode=False):
|
|
|
33
37
|
|
|
34
38
|
prompt = f"""
|
|
35
39
|
{base_prompt}
|
|
36
|
-
{tool_descriptions}{end_prompt}{allowed_dir_prompt}{plan_mode_prompt}
|
|
40
|
+
{tool_descriptions}{end_prompt}{allowed_dir_prompt}{plan_mode_prompt}{skills_prompt}
|
|
37
41
|
Important: Follow the user instruction. Do not create additional .md files (unless asked to do so). Do not create unit tests (unless asked to do so), etc.
|
|
38
42
|
"""
|
|
39
43
|
return prompt.strip()
|
|
@@ -62,6 +66,36 @@ def plan_instructions():
|
|
|
62
66
|
"easy to track and resume."
|
|
63
67
|
)
|
|
64
68
|
|
|
69
|
+
def skills_instructions(skills_dir):
|
|
70
|
+
"""The extra system-prompt block when the skills feature is enabled.
|
|
71
|
+
|
|
72
|
+
Tells the agent it has a local ``.mini`` directory of reusable skills and
|
|
73
|
+
explains the four skill tools (see :mod:`mini_code_cli.skills`). ``skills_dir``
|
|
74
|
+
is the actual ``.mini`` path so the prompt can point at it concretely.
|
|
75
|
+
"""
|
|
76
|
+
from . import skills as skills_mod
|
|
77
|
+
return (
|
|
78
|
+
"SKILLS MODE: You can work with reusable skills kept in the local "
|
|
79
|
+
f"`.mini` directory at: {skills_mod.skills_dir(skills_dir)}.\n"
|
|
80
|
+
"Each skill is a sub-directory containing a `skill.md` (what the skill is "
|
|
81
|
+
"/ how to use it) and optional executable Python helper scripts. There is "
|
|
82
|
+
"also a high-level `SKILLS.md` summary of all skills.\n"
|
|
83
|
+
"Use these tools to work with skills:\n"
|
|
84
|
+
" * `list_skills` - see which skills exist (name + one-line summary).\n"
|
|
85
|
+
" * `read_skill` - read a skill's `skill.md` by name, to learn how to "
|
|
86
|
+
"use it or which scripts it provides.\n"
|
|
87
|
+
" * `create_skill` - create a new skill (its sub-directory and `skill.md`) "
|
|
88
|
+
"when you build a reusable capability. Give a clear, single-segment name "
|
|
89
|
+
"and a short summary. It is normal to then add Python helper scripts into "
|
|
90
|
+
"the skill's directory.\n"
|
|
91
|
+
" * `run_skill` - run one of a skill's Python scripts, passing any "
|
|
92
|
+
"command-line arguments via its `args`. Read the skill first to know "
|
|
93
|
+
"which script to run and what arguments it expects.\n"
|
|
94
|
+
"Prefer turning reusable helpers into skills (with a `skill.md` plus "
|
|
95
|
+
"scripts) so they can be reused later, rather than repeating one-off work.\n"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
65
99
|
def compaction_prompt(messages):
|
|
66
100
|
"""
|
|
67
101
|
Takes a messages list and returns a compact prompt string summarizing the conversation.
|
mini_code_cli/skills.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""mini_code_cli.skills -- the ``.mini`` *skills* feature.
|
|
2
|
+
|
|
3
|
+
A *skill* is a small, reusable, self-describing capability the agent can load on
|
|
4
|
+
demand, kept in a local ``.mini`` directory inside the allowed dir:
|
|
5
|
+
|
|
6
|
+
<allowed_dir>/.mini/
|
|
7
|
+
SKILLS.md # high-level summary of every skill
|
|
8
|
+
<skill-name>/
|
|
9
|
+
skill.md # what the skill is / how to use it
|
|
10
|
+
<script>.py # optional executable Python helper(s)
|
|
11
|
+
|
|
12
|
+
The agent "works with skills" through four tools (only present when ``--skills``
|
|
13
|
+
is enabled):
|
|
14
|
+
|
|
15
|
+
* ``list_skills`` -- what skills exist (names + one-line summaries).
|
|
16
|
+
* ``read_skill`` -- read a single skill's ``skill.md`` by name.
|
|
17
|
+
* ``create_skill`` -- create a new skill (its dir + ``skill.md``); this also
|
|
18
|
+
refreshes the high-level ``SKILLS.md`` summary.
|
|
19
|
+
* ``run_skill`` -- execute one of a skill's Python scripts, with optional
|
|
20
|
+
command-line args.
|
|
21
|
+
|
|
22
|
+
Names are sandboxed *by construction*: skill / script names are validated to be
|
|
23
|
+
a single safe path segment and then joined under the known ``.mini`` root inside
|
|
24
|
+
the allowed dir, so a skill tool can never escape ``--allowed-dir``. A final
|
|
25
|
+
``realpath`` containment check enforces that anyway.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
import shlex
|
|
31
|
+
import shutil
|
|
32
|
+
import subprocess
|
|
33
|
+
import functools
|
|
34
|
+
|
|
35
|
+
# Default timeout (seconds) for a script run via run_skill.
|
|
36
|
+
DEFAULT_TOOL_TIMEOUT = 120
|
|
37
|
+
|
|
38
|
+
# Layout constants. Keeping them here (and re-exported) means prompts.py and the
|
|
39
|
+
# agent loop can refer to the same names rather than hard-coding them twice.
|
|
40
|
+
SKILLS_DIRNAME = ".mini"
|
|
41
|
+
SUMMARY_FILENAME = "SKILLS.md"
|
|
42
|
+
SKILL_FILENAME = "skill.md"
|
|
43
|
+
SCRIPT_SUFFIX = ".py"
|
|
44
|
+
|
|
45
|
+
# The tool names this feature contributes to the agent (used by the loop to
|
|
46
|
+
# register them and by the sandbox/help wiring).
|
|
47
|
+
SKILL_TOOL_NAMES = ("list_skills", "read_skill", "create_skill", "run_skill")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# -- paths -----------------------------------------------------------------
|
|
51
|
+
def skills_dir(allowed_dir):
|
|
52
|
+
"""The ``.mini`` directory for ``allowed_dir``."""
|
|
53
|
+
return os.path.join(allowed_dir, SKILLS_DIRNAME)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def summary_path(allowed_dir):
|
|
57
|
+
"""Path to the high-level ``SKILLS.md`` summary."""
|
|
58
|
+
return os.path.join(skills_dir(allowed_dir), SUMMARY_FILENAME)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def skill_dir(allowed_dir, skill_name):
|
|
62
|
+
"""Path to a single skill's sub-directory."""
|
|
63
|
+
return os.path.join(skills_dir(allowed_dir), skill_name)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def skill_md_path(allowed_dir, skill_name):
|
|
67
|
+
"""Path to a skill's ``skill.md``."""
|
|
68
|
+
return os.path.join(skill_dir(allowed_dir, skill_name), SKILL_FILENAME)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# -- name safety -----------------------------------------------------------
|
|
72
|
+
def _safe_name(name, what="name"):
|
|
73
|
+
"""Return ``name`` if it is a safe single path segment, else ``None``.
|
|
74
|
+
|
|
75
|
+
Rejects empty names, absolute paths, any path separator and the ``.`` /
|
|
76
|
+
``..`` special segments, so skill / script names cannot traverse out of the
|
|
77
|
+
``.mini`` root or address sibling directories. Best-effort: never raises.
|
|
78
|
+
"""
|
|
79
|
+
if name is None:
|
|
80
|
+
return None
|
|
81
|
+
name = str(name).strip()
|
|
82
|
+
if not name:
|
|
83
|
+
return None
|
|
84
|
+
if name in (".", ".."):
|
|
85
|
+
return None
|
|
86
|
+
# Any separator at all means "this is trying to be a path" -> refuse.
|
|
87
|
+
# Reject any path separator on either platform (plus a bare backslash), so
|
|
88
|
+
# a name can never contain a directory component -- it cannot traverse.
|
|
89
|
+
# Any separator (on either platform) plus a bare backslash means
|
|
90
|
+
# "this is trying to be a path" -> refuse, so a name can never hold a
|
|
91
|
+
# directory component and cannot traverse.
|
|
92
|
+
if any(sep in name for sep in (os.sep, "\\", "/")):
|
|
93
|
+
return None
|
|
94
|
+
return name
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# -- discovery -------------------------------------------------------------
|
|
98
|
+
def _list_skill_names(allowed_dir):
|
|
99
|
+
"""Return the sorted names of skills (dirs holding a ``skill.md``)."""
|
|
100
|
+
sd = skills_dir(allowed_dir)
|
|
101
|
+
if not os.path.isdir(sd):
|
|
102
|
+
return []
|
|
103
|
+
out = []
|
|
104
|
+
for entry in sorted(os.listdir(sd)):
|
|
105
|
+
if os.path.isdir(os.path.join(sd, entry)) and \
|
|
106
|
+
os.path.isfile(os.path.join(sd, entry, SKILL_FILENAME)):
|
|
107
|
+
out.append(entry)
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def list_scripts(allowed_dir, skill_name):
|
|
112
|
+
"""Return the executable Python script names (without ``.py``) in a skill."""
|
|
113
|
+
safe = _safe_name(skill_name)
|
|
114
|
+
if not safe:
|
|
115
|
+
return []
|
|
116
|
+
base = skill_dir(allowed_dir, safe)
|
|
117
|
+
if not os.path.isdir(base):
|
|
118
|
+
return []
|
|
119
|
+
out = []
|
|
120
|
+
for entry in sorted(os.listdir(base)):
|
|
121
|
+
if entry.endswith(SCRIPT_SUFFIX):
|
|
122
|
+
out.append(entry[: -len(SCRIPT_SUFFIX)])
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def ensure_skills_dir(allowed_dir):
|
|
127
|
+
"""Create ``.mini`` and a skeleton ``SKILLS.md`` if they do not exist yet.
|
|
128
|
+
|
|
129
|
+
Best-effort and never raises: a skills hiccup must never break the loop.
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
sd = skills_dir(allowed_dir)
|
|
133
|
+
os.makedirs(sd, exist_ok=True)
|
|
134
|
+
if not os.path.exists(summary_path(allowed_dir)):
|
|
135
|
+
_write_summary(allowed_dir, [])
|
|
136
|
+
except OSError:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _skill_metadata(allowed_dir, skill_name):
|
|
141
|
+
"""Return ``(title, description)`` for a skill read from its ``skill.md``.
|
|
142
|
+
|
|
143
|
+
``description`` is the ``Summary:`` line if the skill has one, else the first
|
|
144
|
+
non-heading line of the file. ``title`` is the first ``#`` heading, else the
|
|
145
|
+
skill name. Best-effort: a missing/unreadable file yields (name, "").
|
|
146
|
+
"""
|
|
147
|
+
path = skill_md_path(allowed_dir, skill_name)
|
|
148
|
+
if not os.path.isfile(path):
|
|
149
|
+
return skill_name, ""
|
|
150
|
+
try:
|
|
151
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
152
|
+
content = f.read()
|
|
153
|
+
except OSError:
|
|
154
|
+
return skill_name, ""
|
|
155
|
+
|
|
156
|
+
description = ""
|
|
157
|
+
title = skill_name
|
|
158
|
+
for line in content.splitlines():
|
|
159
|
+
stripped = line.strip()
|
|
160
|
+
if not title and stripped.startswith("#"):
|
|
161
|
+
title = stripped.lstrip("#").strip() or skill_name
|
|
162
|
+
continue
|
|
163
|
+
if stripped.lower().startswith("summary:"):
|
|
164
|
+
description = stripped.split(":", 1)[1].strip()
|
|
165
|
+
break
|
|
166
|
+
if not stripped or stripped.startswith("#"):
|
|
167
|
+
continue
|
|
168
|
+
description = stripped
|
|
169
|
+
break
|
|
170
|
+
return (title or skill_name), description
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# -- tools -----------------------------------------------------------------
|
|
174
|
+
def list_skills(allowed_dir):
|
|
175
|
+
"""List available skills (name + one-line summary). Creates ``.mini`` first.
|
|
176
|
+
|
|
177
|
+
No parameters from the model (``allowed_dir`` is bound at wiring time).
|
|
178
|
+
"""
|
|
179
|
+
ensure_skills_dir(allowed_dir)
|
|
180
|
+
skills = _list_skill_names(allowed_dir)
|
|
181
|
+
# Keep the high-level SKILLS.md summary in sync with what is on disk (e.g.
|
|
182
|
+
# scripts dropped into a skill after it was created), so list_skills always
|
|
183
|
+
# reflects the current state.
|
|
184
|
+
if skills:
|
|
185
|
+
_write_summary(allowed_dir, skills)
|
|
186
|
+
if not skills:
|
|
187
|
+
return "No skills yet. Use create_skill to add one."
|
|
188
|
+
lines = ["Available skills:", ""]
|
|
189
|
+
for name in skills:
|
|
190
|
+
_title, desc = _skill_metadata(allowed_dir, name)
|
|
191
|
+
lines.append(f"- {name}: {desc}")
|
|
192
|
+
return "\n".join(lines)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def read_skill(allowed_dir, skill_name):
|
|
196
|
+
"""Return the contents of a skill's ``skill.md`` by name.
|
|
197
|
+
|
|
198
|
+
If the skill does not exist, returns an error listing the available skills
|
|
199
|
+
so the agent can recover.
|
|
200
|
+
"""
|
|
201
|
+
safe = _safe_name(skill_name)
|
|
202
|
+
if not safe:
|
|
203
|
+
return "Error: invalid skill name."
|
|
204
|
+
path = skill_md_path(allowed_dir, safe)
|
|
205
|
+
if not os.path.isfile(path):
|
|
206
|
+
available = _list_skill_names(allowed_dir)
|
|
207
|
+
listing = ", ".join(available) if available else "none"
|
|
208
|
+
return f"Error: no skill named {skill_name!r}. Available skills: {listing}."
|
|
209
|
+
try:
|
|
210
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
211
|
+
return f.read()
|
|
212
|
+
except OSError as e:
|
|
213
|
+
return f"Error: {e}"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def create_skill(allowed_dir, skill_name, description, title=None, overwrite=False):
|
|
217
|
+
"""Create a new skill: its sub-directory and ``skill.md``.
|
|
218
|
+
|
|
219
|
+
Refreshes the high-level ``SKILLS.md`` summary afterwards so the new skill
|
|
220
|
+
shows up in list_skills. Refuses to clobber an existing skill unless
|
|
221
|
+
``overwrite`` is set. Best-effort: never raises, returns an Error string.
|
|
222
|
+
"""
|
|
223
|
+
safe = _safe_name(skill_name)
|
|
224
|
+
if not safe:
|
|
225
|
+
return "Error: invalid skill name. Use a short, single-segment name."
|
|
226
|
+
if not description:
|
|
227
|
+
return "Error: a description is required."
|
|
228
|
+
|
|
229
|
+
sdir = skill_dir(allowed_dir, safe)
|
|
230
|
+
mpath = skill_md_path(allowed_dir, safe)
|
|
231
|
+
if os.path.exists(mpath) and not overwrite:
|
|
232
|
+
return (f"Error: skill {safe!r} already exists (use overwrite to replace "
|
|
233
|
+
f"it).")
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
os.makedirs(sdir, exist_ok=True)
|
|
237
|
+
with open(mpath, "w", encoding="utf-8") as f:
|
|
238
|
+
f.write(_skill_md_template(title or safe, description))
|
|
239
|
+
_write_summary(allowed_dir, _list_skill_names(allowed_dir))
|
|
240
|
+
return f"Created skill {safe!r} at {mpath}"
|
|
241
|
+
except OSError as e:
|
|
242
|
+
return f"Error: {e}"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def delete_skill(allowed_dir, skill_name):
|
|
246
|
+
"""Delete a skill's sub-directory (and drop it from SKILLS.md).
|
|
247
|
+
|
|
248
|
+
Kept minimal and only surfaced if ever wanted as a tool; best-effort.
|
|
249
|
+
"""
|
|
250
|
+
safe = _safe_name(skill_name)
|
|
251
|
+
if not safe:
|
|
252
|
+
return "Error: invalid skill name."
|
|
253
|
+
sdir = skill_dir(allowed_dir, safe)
|
|
254
|
+
if not os.path.isdir(sdir):
|
|
255
|
+
return f"Error: no skill named {skill_name!r}."
|
|
256
|
+
try:
|
|
257
|
+
shutil.rmtree(sdir)
|
|
258
|
+
_write_summary(allowed_dir, _list_skill_names(allowed_dir))
|
|
259
|
+
return f"Deleted skill {safe!r}."
|
|
260
|
+
except OSError as e:
|
|
261
|
+
return f"Error: {e}"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def run_skill(allowed_dir, skill_name, script_name, args=None):
|
|
265
|
+
"""Execute one of a skill's Python scripts and return its output.
|
|
266
|
+
|
|
267
|
+
``args`` is an optional string of command-line arguments, split with
|
|
268
|
+
:func:`shlex.split` and passed as ``argv`` (never through a shell, so there
|
|
269
|
+
is no shell-injection surface). The script is run with the same interpreter
|
|
270
|
+
that runs mini-code (``sys.executable``). The script path is resolved under
|
|
271
|
+
the skill's directory and checked to stay inside it.
|
|
272
|
+
"""
|
|
273
|
+
safe = _safe_name(skill_name)
|
|
274
|
+
script = _safe_name(script_name)
|
|
275
|
+
if not safe or not script:
|
|
276
|
+
return "Error: invalid skill or script name."
|
|
277
|
+
if not script.endswith(SCRIPT_SUFFIX):
|
|
278
|
+
script += SCRIPT_SUFFIX
|
|
279
|
+
|
|
280
|
+
base = os.path.realpath(skill_dir(allowed_dir, safe))
|
|
281
|
+
script_path = os.path.realpath(os.path.join(base, script))
|
|
282
|
+
# Containment: the script must live directly inside the skill dir.
|
|
283
|
+
if os.path.dirname(script_path) != base:
|
|
284
|
+
return "Error: script path escapes the skill directory."
|
|
285
|
+
if not os.path.isfile(script_path):
|
|
286
|
+
available = ", ".join(list_scripts(allowed_dir, safe)) or "none"
|
|
287
|
+
return (f"Error: no script {script!r} in skill {safe!r}. "
|
|
288
|
+
f"Available scripts: {available}.")
|
|
289
|
+
|
|
290
|
+
argv = shlex.split(args) if args else []
|
|
291
|
+
cmd = [sys.executable, script_path] + argv
|
|
292
|
+
try:
|
|
293
|
+
result = subprocess.run(cmd, capture_output=True, text=True,
|
|
294
|
+
timeout=DEFAULT_TOOL_TIMEOUT, check=False)
|
|
295
|
+
except Exception as e:
|
|
296
|
+
return f"Error: {e}"
|
|
297
|
+
out = result.stdout
|
|
298
|
+
if result.stderr:
|
|
299
|
+
out += "\nSTDERR:\n" + result.stderr
|
|
300
|
+
return out
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# -- summary ---------------------------------------------------------------
|
|
304
|
+
def _skill_md_template(title, description):
|
|
305
|
+
return (
|
|
306
|
+
f"# {title}\n\n"
|
|
307
|
+
f"Summary: {description}\n\n"
|
|
308
|
+
"## How to use\n"
|
|
309
|
+
f"{description}\n\n"
|
|
310
|
+
"## Scripts\n"
|
|
311
|
+
"- (none yet)\n"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _write_summary(allowed_dir, skill_names):
|
|
316
|
+
"""Write the high-level ``SKILLS.md`` summary from the given skill names."""
|
|
317
|
+
lines = [
|
|
318
|
+
"# Skills",
|
|
319
|
+
"",
|
|
320
|
+
"This is the high-level summary of the skills kept in the local "
|
|
321
|
+
"`.mini` directory. Each skill lives in its own sub-directory with a "
|
|
322
|
+
"`skill.md` (what it is / how to use it) and optional executable "
|
|
323
|
+
"Python scripts. Use `list_skills` / `read_skill` to consult them, "
|
|
324
|
+
"`create_skill` to add one, and `run_skill` to run a skill's scripts.",
|
|
325
|
+
"",
|
|
326
|
+
]
|
|
327
|
+
if not skill_names:
|
|
328
|
+
lines += ["No skills yet.", ""]
|
|
329
|
+
for name in skill_names:
|
|
330
|
+
_title, desc = _skill_metadata(allowed_dir, name)
|
|
331
|
+
lines.append(f"## {name}")
|
|
332
|
+
lines.append(desc or "(no summary)")
|
|
333
|
+
scripts = list_scripts(allowed_dir, name)
|
|
334
|
+
if scripts:
|
|
335
|
+
lines.append("")
|
|
336
|
+
lines.append(f"Scripts: {', '.join(scripts)}")
|
|
337
|
+
lines.append("")
|
|
338
|
+
try:
|
|
339
|
+
with open(summary_path(allowed_dir), "w", encoding="utf-8") as f:
|
|
340
|
+
f.write("\n".join(lines))
|
|
341
|
+
except OSError:
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# -- wiring (tool schemas + bound functions) -------------------------------
|
|
346
|
+
def _skill_functions(allowed_dir):
|
|
347
|
+
"""{name: functools.partial} for the four skill tools, allowed_dir bound."""
|
|
348
|
+
return {
|
|
349
|
+
"list_skills": functools.partial(list_skills, allowed_dir),
|
|
350
|
+
"read_skill": functools.partial(read_skill, allowed_dir),
|
|
351
|
+
"create_skill": functools.partial(create_skill, allowed_dir),
|
|
352
|
+
"run_skill": functools.partial(run_skill, allowed_dir),
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def get_skills_tools(allowed_dir):
|
|
357
|
+
"""Return the OpenAI tool schemas for the four skill tools."""
|
|
358
|
+
from . import tools
|
|
359
|
+
return tools.util_get_tools(functions=_skill_functions(allowed_dir))
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def get_skills_tools_dict(allowed_dir):
|
|
363
|
+
"""Return {name: bound-function} for the four skill tools."""
|
|
364
|
+
from . import tools
|
|
365
|
+
return tools.util_get_tools_dict(functions=_skill_functions(allowed_dir))
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
"""mini_code_cli.subagents.builtin --
|
|
1
|
+
"""mini_code_cli.subagents.builtin -- the built-in sub-agents.
|
|
2
2
|
|
|
3
3
|
These are registered on import (see :mod:`mini_code_cli.subagents`). They double
|
|
4
4
|
as documentation for how to build your own: a sub-agent is just a small class
|
|
5
|
-
configuring a name, a description, and a few knobs.
|
|
5
|
+
configuring a name, a description, a set of tools, and a few knobs. Each one
|
|
6
|
+
targets a specific kind of work -- its ``system_prompt`` overrides add a short,
|
|
7
|
+
task-specific preamble on top of the shared base prompt so the nested agent
|
|
8
|
+
knows exactly what "done" looks like for this kind of task.
|
|
6
9
|
"""
|
|
7
10
|
from .base import SubAgent
|
|
8
11
|
from . import register
|
|
@@ -74,6 +77,95 @@ class PlanSubAgent(SubAgent):
|
|
|
74
77
|
)
|
|
75
78
|
|
|
76
79
|
|
|
77
|
-
|
|
80
|
+
class SoftwareEngineeringSubAgent(SubAgent):
|
|
81
|
+
"""Implement, edit, refactor or test code in a project.
|
|
82
|
+
|
|
83
|
+
This is the "do the actual engineering" sub-agent. It can read and write
|
|
84
|
+
files and run shell commands, so it is meant for real coding work: writing a
|
|
85
|
+
new module, refactoring, wiring up a fix, running the tests / a build to
|
|
86
|
+
check it. It is given a task-specific system prompt that biases it toward
|
|
87
|
+
producing working, tested, minimal changes rather than exploratory writing.
|
|
88
|
+
|
|
89
|
+
Because it can run shell commands it should only be used in sessions where
|
|
90
|
+
the caller is comfortable with that; the parent's sandbox / shell-safety
|
|
91
|
+
rules still apply.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
name = "software_engineer"
|
|
95
|
+
description = (
|
|
96
|
+
"Software-engineering sub-agent: write, edit, refactor, fix or test code "
|
|
97
|
+
"in the project. It can read/write files and run shell commands (tests, "
|
|
98
|
+
"builds, etc.) to verify the work. Hands back a summary of the changes."
|
|
99
|
+
)
|
|
100
|
+
tools = ["read_file", "write_file", "grep", "glob", "run_shell_command"]
|
|
101
|
+
enable_shell = True
|
|
102
|
+
max_steps = 20
|
|
103
|
+
|
|
104
|
+
def system_prompt(self, task, parent):
|
|
105
|
+
base = super().system_prompt(task, parent)
|
|
106
|
+
return base + (
|
|
107
|
+
"\n\n---\nYou are the SOFTWARE ENGINEERING sub-agent: you write and "
|
|
108
|
+
"modify code to get a real task done. Follow these guidelines:\n"
|
|
109
|
+
"* Understand the relevant code before editing: read the files and "
|
|
110
|
+
"use grep/glob to find what you need. Prefer small, targeted edits over "
|
|
111
|
+
"rewriting large files.\n"
|
|
112
|
+
"* Keep changes minimal and focused on the task; do not touch unrelated "
|
|
113
|
+
"code.\n"
|
|
114
|
+
"* Make edits that are syntactically valid and consistent with the "
|
|
115
|
+
"existing style (imports, formatting, naming).\n"
|
|
116
|
+
"* When it makes sense, verify your work: run the project's tests, a "
|
|
117
|
+
"build, a linter or the code itself with run_shell_command, and use "
|
|
118
|
+
"the output to fix problems. Do not claim something works without "
|
|
119
|
+
"checking when you are able to check.\n"
|
|
120
|
+
"* If a task is ambiguous, make a sensible, minimal choice and state "
|
|
121
|
+
"it. Note any trade-offs or follow-ups.\n"
|
|
122
|
+
"When finished, write a concise summary of what you changed, why, and "
|
|
123
|
+
"how you verified it (including any test/build commands you ran and "
|
|
124
|
+
"their results) to report.md."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class GenericSubAgent(SubAgent):
|
|
129
|
+
"""A general-purpose helper for tasks that do not fit a narrower agent.
|
|
130
|
+
|
|
131
|
+
This is the catch-all sub-agent: it has the full set of read/write tools and
|
|
132
|
+
may run shell commands, so it can handle "everything else" -- formatting a
|
|
133
|
+
document, organising files, drafting text, running a quick script, or any
|
|
134
|
+
one-off task the main agent wants to farm out. Its system prompt is a light,
|
|
135
|
+
task-agnostic scaffold that works for a broad range of requests.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
name = "generic"
|
|
139
|
+
description = (
|
|
140
|
+
"General-purpose sub-agent for tasks that do not fit a more specialised "
|
|
141
|
+
"one. It can read/write files and run shell commands, so it suits most "
|
|
142
|
+
"one-off jobs. Hands back a summary of what it did."
|
|
143
|
+
)
|
|
144
|
+
tools = ["read_file", "write_file", "grep", "glob", "run_shell_command"]
|
|
145
|
+
enable_shell = True
|
|
146
|
+
max_steps = 12
|
|
147
|
+
|
|
148
|
+
def system_prompt(self, task, parent):
|
|
149
|
+
base = super().system_prompt(task, parent)
|
|
150
|
+
return base + (
|
|
151
|
+
"\n\n---\nYou are a GENERAL-PURPOSE sub-agent: a capable, careful "
|
|
152
|
+
"helper handed a single task. Follow these guidelines:\n"
|
|
153
|
+
"* Start by making sure you understand the task. If anything is "
|
|
154
|
+
"unclear, make a sensible, clearly-stated assumption and proceed -- "
|
|
155
|
+
"do not stall.\n"
|
|
156
|
+
"* Use the tools available to you: read before you write, and prefer "
|
|
157
|
+
"small, correct steps over large, risky ones.\n"
|
|
158
|
+
"* Prefer doing the task for real (editing files, running commands) "
|
|
159
|
+
"over merely describing how it could be done.\n"
|
|
160
|
+
"* If you run commands or make edits that could fail, check the "
|
|
161
|
+
"result and adapt.\n"
|
|
162
|
+
"When finished, write a concise summary of what you did and any "
|
|
163
|
+
"decisions or follow-ups to report.md."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# Register the built-in sub-agents.
|
|
78
168
|
register(ResearchSubAgent())
|
|
79
169
|
register(PlanSubAgent())
|
|
170
|
+
register(SoftwareEngineeringSubAgent())
|
|
171
|
+
register(GenericSubAgent())
|
mini_code_cli/tools.py
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
|
+
"""mini_code_cli.tools -- the tool functions the agent loop can expose to the LLM.
|
|
2
|
+
|
|
3
|
+
The plain functions in this module (``read_file``, ``grep``, ...) are the
|
|
4
|
+
default tools. They can be discovered automatically (see
|
|
5
|
+
:func:`util_get_tools` / :func:`util_get_tools_dict`). A caller may also pass an
|
|
6
|
+
explicit ``functions`` mapping so another feature (e.g. :mod:`mini_code_cli.skills`,
|
|
7
|
+
whose tools already have ``allowed_dir`` bound) can reuse the schema/formatting.
|
|
8
|
+
"""
|
|
1
9
|
import glob as glob_module
|
|
2
10
|
import subprocess
|
|
3
11
|
import urllib.parse
|
|
4
12
|
import urllib.request
|
|
5
13
|
import json
|
|
14
|
+
import inspect
|
|
15
|
+
import functools
|
|
6
16
|
|
|
7
17
|
# Default timeout (seconds) for a single tool invocation. Mirrors the CLI's
|
|
8
18
|
# --tool-timeout default so a tool that is slow/hangs gets aborted.
|
|
@@ -144,43 +154,96 @@ def run_shell_command(command, timeout=DEFAULT_TOOL_TIMEOUT, *args, **kwargs):
|
|
|
144
154
|
return f"Error: {e}"
|
|
145
155
|
|
|
146
156
|
|
|
147
|
-
def
|
|
148
|
-
"""
|
|
149
|
-
|
|
157
|
+
def _local_function_names():
|
|
158
|
+
"""Names of the real tool functions defined in this module (not util_/_)."""
|
|
159
|
+
return [name for name, obj in globals().items()
|
|
160
|
+
if callable(obj) and obj.__module__ == __name__
|
|
161
|
+
and not name.startswith('util_') and not name.startswith("_")]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _param_type(annotation):
|
|
165
|
+
"""Map a type annotation to an OpenAI-schema type string."""
|
|
166
|
+
param_type = str(annotation) if annotation != inspect.Parameter.empty else "string"
|
|
167
|
+
if 'int' in param_type.lower() or 'integer' in param_type.lower():
|
|
168
|
+
return "integer"
|
|
169
|
+
if 'float' in param_type.lower() or 'number' in param_type.lower():
|
|
170
|
+
return "number"
|
|
171
|
+
if 'bool' in param_type.lower():
|
|
172
|
+
return "boolean"
|
|
173
|
+
if 'list' in param_type.lower() or 'array' in param_type.lower():
|
|
174
|
+
return "array"
|
|
175
|
+
return "string"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _schema_params(func):
|
|
179
|
+
"""Return ``(properties, required)`` for a tool callable's signature.
|
|
180
|
+
|
|
181
|
+
Handles both plain module functions and :func:`functools.partial` objects
|
|
182
|
+
(used by skills, which bind ``allowed_dir`` up front): parameters that are
|
|
183
|
+
already bound on a partial are omitted from the schema, and remaining
|
|
184
|
+
non-default parameters are marked required. ``*args`` / ``**kwargs``
|
|
185
|
+
catch-alls are always skipped.
|
|
186
|
+
"""
|
|
187
|
+
if isinstance(func, functools.partial):
|
|
188
|
+
underlying = func.func
|
|
189
|
+
bound_kwargs = dict(func.keywords or {})
|
|
190
|
+
bound_positional = list(func.args)
|
|
191
|
+
else:
|
|
192
|
+
underlying = func
|
|
193
|
+
bound_kwargs = {}
|
|
194
|
+
bound_positional = []
|
|
195
|
+
|
|
196
|
+
sig = inspect.signature(underlying)
|
|
197
|
+
# Bind leading positional args (in signature order) to the partial's
|
|
198
|
+
# positional args, so a positional bound arg hides the right parameter.
|
|
199
|
+
positional_names = [
|
|
200
|
+
pn for pn, p in sig.parameters.items()
|
|
201
|
+
if p.kind not in (inspect.Parameter.VAR_POSITIONAL,
|
|
202
|
+
inspect.Parameter.VAR_KEYWORD)
|
|
203
|
+
]
|
|
204
|
+
positional_bound = {}
|
|
205
|
+
for i, pname in enumerate(positional_names):
|
|
206
|
+
if i < len(bound_positional):
|
|
207
|
+
positional_bound[pname] = bound_positional[i]
|
|
208
|
+
|
|
209
|
+
properties = {}
|
|
210
|
+
required = []
|
|
211
|
+
for param_name, param in sig.parameters.items():
|
|
212
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL,
|
|
213
|
+
inspect.Parameter.VAR_KEYWORD):
|
|
214
|
+
continue
|
|
215
|
+
if param_name in bound_kwargs or param_name in positional_bound:
|
|
216
|
+
# Already bound on the partial -- the LLM must not supply it.
|
|
217
|
+
continue
|
|
218
|
+
param_type = _param_type(param.annotation)
|
|
219
|
+
properties[param_name] = {"type": param_type}
|
|
220
|
+
if param.default == inspect.Parameter.empty:
|
|
221
|
+
required.append(param_name)
|
|
222
|
+
return properties, required
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def util_get_tools(allowed_tools=None, forbidden_tools=None, functions=None):
|
|
226
|
+
"""Return a JSON string describing tool functions for LLM usage.
|
|
227
|
+
|
|
228
|
+
By default scans this module's own tool functions. When ``functions`` is a
|
|
229
|
+
{name: callable} mapping, those are described instead (in ``functions``
|
|
230
|
+
order) -- letting other features (e.g. skills, with ``allowed_dir`` already
|
|
231
|
+
bound) contribute their own tools while reusing this formatting.
|
|
232
|
+
"""
|
|
150
233
|
tools = []
|
|
151
|
-
|
|
152
|
-
|
|
234
|
+
|
|
235
|
+
if functions:
|
|
236
|
+
items = list(functions.items())
|
|
237
|
+
else:
|
|
238
|
+
items = [(name, globals()[name]) for name in _local_function_names()]
|
|
239
|
+
|
|
153
240
|
if allowed_tools:
|
|
154
|
-
|
|
241
|
+
items = [(n, f) for n, f in items if n in allowed_tools]
|
|
155
242
|
if forbidden_tools:
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
for name in
|
|
159
|
-
|
|
160
|
-
sig = inspect.signature(func)
|
|
161
|
-
parameters = {}
|
|
162
|
-
required = []
|
|
163
|
-
for param_name, param in sig.parameters.items():
|
|
164
|
-
# Skip *args and **kwargs: they are catch-alls, not real inputs the
|
|
165
|
-
# LLM should (or can) supply, so they must not appear in the schema.
|
|
166
|
-
if param.kind in (inspect.Parameter.VAR_POSITIONAL,
|
|
167
|
-
inspect.Parameter.VAR_KEYWORD):
|
|
168
|
-
continue
|
|
169
|
-
param_type = str(param.annotation) if param.annotation != inspect.Parameter.empty else "string"
|
|
170
|
-
# Simplify common types
|
|
171
|
-
if 'int' in param_type.lower() or 'integer' in param_type.lower():
|
|
172
|
-
param_type = "integer"
|
|
173
|
-
elif 'float' in param_type.lower() or 'number' in param_type.lower():
|
|
174
|
-
param_type = "number"
|
|
175
|
-
elif 'bool' in param_type.lower():
|
|
176
|
-
param_type = "boolean"
|
|
177
|
-
elif 'list' in param_type.lower() or 'array' in param_type.lower():
|
|
178
|
-
param_type = "array"
|
|
179
|
-
else:
|
|
180
|
-
param_type = "string"
|
|
181
|
-
parameters[param_name] = {"type": param_type}
|
|
182
|
-
if param.default == inspect.Parameter.empty:
|
|
183
|
-
required.append(param_name)
|
|
243
|
+
items = [(n, f) for n, f in items if n not in forbidden_tools]
|
|
244
|
+
|
|
245
|
+
for name, func in items:
|
|
246
|
+
properties, required = _schema_params(func)
|
|
184
247
|
tool = {
|
|
185
248
|
"type": "function",
|
|
186
249
|
"function": {
|
|
@@ -188,7 +251,7 @@ def util_get_tools(allowed_tools=None, forbidden_tools=None):
|
|
|
188
251
|
"description": func.__doc__ if func.__doc__ else f"Function {name}",
|
|
189
252
|
"parameters": {
|
|
190
253
|
"type": "object",
|
|
191
|
-
"properties":
|
|
254
|
+
"properties": properties,
|
|
192
255
|
"required": required
|
|
193
256
|
}
|
|
194
257
|
}
|
|
@@ -197,25 +260,33 @@ def util_get_tools(allowed_tools=None, forbidden_tools=None):
|
|
|
197
260
|
return tools
|
|
198
261
|
|
|
199
262
|
|
|
200
|
-
def util_get_tools_dict(allowed_tools=None, forbidden_tools=None):
|
|
201
|
-
"""Return a dictionary mapping tool names to their function objects.
|
|
263
|
+
def util_get_tools_dict(allowed_tools=None, forbidden_tools=None, functions=None):
|
|
264
|
+
"""Return a dictionary mapping tool names to their function objects.
|
|
265
|
+
|
|
266
|
+
``functions`` is an optional {name: callable} mapping used in place of this
|
|
267
|
+
module's own functions (see :func:`util_get_tools`)."""
|
|
202
268
|
result = {}
|
|
203
|
-
|
|
269
|
+
|
|
270
|
+
if functions:
|
|
271
|
+
items = list(functions.items())
|
|
272
|
+
else:
|
|
273
|
+
items = [(name, globals()[name]) for name in _local_function_names()]
|
|
274
|
+
|
|
204
275
|
if allowed_tools:
|
|
205
|
-
|
|
276
|
+
items = [(n, f) for n, f in items if n in allowed_tools]
|
|
206
277
|
if forbidden_tools:
|
|
207
|
-
|
|
278
|
+
items = [(n, f) for n, f in items if n not in forbidden_tools]
|
|
208
279
|
|
|
209
|
-
for name in
|
|
210
|
-
func = globals()[name]
|
|
280
|
+
for name, func in items:
|
|
211
281
|
desc = func.__doc__ if func.__doc__ else f"Function {name}"
|
|
212
282
|
result[name] = {"function": func, "description": desc}
|
|
213
283
|
return result
|
|
214
284
|
|
|
285
|
+
|
|
215
286
|
shell_tools = [
|
|
216
287
|
"run_shell_command"
|
|
217
288
|
]
|
|
218
289
|
|
|
219
|
-
if __name__=="__main__":
|
|
290
|
+
if __name__ == "__main__":
|
|
220
291
|
print(util_get_tools())
|
|
221
|
-
# print(json.dumps(tools_dict, indent=4))
|
|
292
|
+
# print(json.dumps(tools_dict, indent=4))
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mini-code-cli
|
|
3
|
-
Version: 0.0.
|
|
4
|
-
Summary: Mini Coding
|
|
3
|
+
Version: 0.1.0.0
|
|
4
|
+
Summary: Mini Coding Agent. Allows you to easily experiment with a small coding agent.
|
|
5
5
|
Author-email: Nikolai Rozanov <nikolai.rozanov@gmail.com>
|
|
6
6
|
License: GNU AFFERO GENERAL PUBLIC LICENSE
|
|
7
7
|
Version 3, 19 November 2007
|
|
@@ -700,6 +700,9 @@ mini-code --url "https://api.deepseek.com" --model "deepseek-v4-flash"
|
|
|
700
700
|
```bash
|
|
701
701
|
mini-code --enable-shell --auto-mode
|
|
702
702
|
mini-code -s -a --sudo
|
|
703
|
+
mini-code -s -a -p -g -l --sudo #plan, previous session, subagents (#or short-hand the next commands)
|
|
704
|
+
mini-code --all
|
|
705
|
+
mini-code --all-last
|
|
703
706
|
```
|
|
704
707
|
|
|
705
708
|
4. Advanced usage:
|
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
mini_code_cli/__init__.py,sha256=iNgHmKSHOuH99obGjnUMoS2JP4Gw4AOrIfu68EYaZQM,77
|
|
2
|
-
mini_code_cli/agent_loop.py,sha256=
|
|
2
|
+
mini_code_cli/agent_loop.py,sha256=DnoHKv9hsB7a9ibdvbt3HxfsdASpw8N1Gw2dTLZFI9g,55999
|
|
3
3
|
mini_code_cli/config.py,sha256=AvzDZAKynQbuj5lcTtL-DcUwhyLa99QmNrLK6fYpdOA,814
|
|
4
4
|
mini_code_cli/permissions.py,sha256=uQpQlUtQyVICpjUqdh9QQ_mgl51hZrGLHJzQR9vG124,2017
|
|
5
5
|
mini_code_cli/persistence.py,sha256=L47NhXs6R54KETd8R17oGGxCXNEf7bjmvlwAJqtspLI,9944
|
|
6
|
-
mini_code_cli/plan.py,sha256=
|
|
7
|
-
mini_code_cli/prompts.py,sha256=
|
|
6
|
+
mini_code_cli/plan.py,sha256=M5WZQuiN8F_K7zyWIrU_5R9L8dcN9k8iYouXzfL_KOs,6985
|
|
7
|
+
mini_code_cli/prompts.py,sha256=X05Krwf9X46WinbHzHj8WYq4RTnO56KZpMiRYS2CGLw,5452
|
|
8
8
|
mini_code_cli/runner.py,sha256=oYUAzbQOrkakuR2uT1OnIrruh_XJYWEDzKpM09T4Zp0,1927
|
|
9
9
|
mini_code_cli/sandbox.py,sha256=t7xS_AIpE8fbo5kj9zbUFbfEaAnq6iUE7plQcn6-uAk,2128
|
|
10
10
|
mini_code_cli/shell_safety_utils.py,sha256=5E126vGnG7iIuTZe_R5JrxB_zxZpMKGe_pilz_Z2r4w,5551
|
|
11
|
-
mini_code_cli/
|
|
11
|
+
mini_code_cli/skills.py,sha256=baaz0RkPQ-OgWWwH5sHsKBmJdR430yT4HK3VLX0_88Y,13704
|
|
12
|
+
mini_code_cli/tools.py,sha256=bW1zYyIj6OFp5TIcg5DCwheN-4xI1LQmDOQaHIfRV7M,10998
|
|
12
13
|
mini_code_cli/ui.py,sha256=AUwn0OFt3yFx397-V8kYuidseCBxFHgO1KWDKrlLCOU,29371
|
|
13
14
|
mini_code_cli/utils.py,sha256=ffUAIisO8_h7VvlwAOLhdoNSAIdssGdtQHXm-i8bcLE,4620
|
|
14
15
|
mini_code_cli/subagents/__init__.py,sha256=3fgcwY0pNq5iW29OxLPtSA3H5IKXhl2LUhwbBjLUDuU,1884
|
|
15
16
|
mini_code_cli/subagents/base.py,sha256=bhZEENoTVjebfqaEKS7uPWeU0SwQWFN3BD40EtziPbg,12660
|
|
16
|
-
mini_code_cli/subagents/builtin.py,sha256=
|
|
17
|
-
mini_code_cli-0.0.
|
|
18
|
-
mini_code_cli-0.0.
|
|
19
|
-
mini_code_cli-0.0.
|
|
20
|
-
mini_code_cli-0.0.
|
|
21
|
-
mini_code_cli-0.0.
|
|
22
|
-
mini_code_cli-0.0.
|
|
17
|
+
mini_code_cli/subagents/builtin.py,sha256=ENzuooXIylzfZZ8uYkZmdxgCrCQ4pdbfrOONkBdGSPg,7598
|
|
18
|
+
mini_code_cli-0.1.0.0.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
|
|
19
|
+
mini_code_cli-0.1.0.0.dist-info/METADATA,sha256=iUqH7NzuQqmIetQQjA5Re6-sADvq7JD_iGQDKehWqpQ,52292
|
|
20
|
+
mini_code_cli-0.1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
21
|
+
mini_code_cli-0.1.0.0.dist-info/entry_points.txt,sha256=MwDp5UbXpI7wodSmOSYL8_nKutnBiyp96ZDDtaX7x14,60
|
|
22
|
+
mini_code_cli-0.1.0.0.dist-info/top_level.txt,sha256=3Hb6PpNkJYXvzDKXuuFJRvzCdsTRh0TuRWz-ScJ6qV0,14
|
|
23
|
+
mini_code_cli-0.1.0.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|