bareagent-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.
- bareagent/__init__.py +10 -0
- bareagent/concurrency/__init__.py +6 -0
- bareagent/concurrency/background.py +97 -0
- bareagent/concurrency/notification.py +61 -0
- bareagent/concurrency/scheduler.py +136 -0
- bareagent/config.toml +299 -0
- bareagent/core/__init__.py +1 -0
- bareagent/core/config_paths.py +49 -0
- bareagent/core/context.py +127 -0
- bareagent/core/fileutil.py +103 -0
- bareagent/core/goal.py +214 -0
- bareagent/core/handlers/__init__.py +1 -0
- bareagent/core/handlers/bash.py +79 -0
- bareagent/core/handlers/file_edit.py +47 -0
- bareagent/core/handlers/file_read.py +270 -0
- bareagent/core/handlers/file_write.py +34 -0
- bareagent/core/handlers/glob_search.py +30 -0
- bareagent/core/handlers/goal.py +60 -0
- bareagent/core/handlers/grep_search.py +52 -0
- bareagent/core/handlers/memory.py +71 -0
- bareagent/core/handlers/plan.py +106 -0
- bareagent/core/handlers/search_utils.py +77 -0
- bareagent/core/handlers/skill.py +87 -0
- bareagent/core/handlers/subagent_send.py +70 -0
- bareagent/core/handlers/web_fetch.py +126 -0
- bareagent/core/handlers/web_search.py +165 -0
- bareagent/core/handlers/workflow.py +190 -0
- bareagent/core/loop.py +535 -0
- bareagent/core/retry.py +131 -0
- bareagent/core/sandbox.py +27 -0
- bareagent/core/schema.py +21 -0
- bareagent/core/tools.py +779 -0
- bareagent/core/workflow.py +517 -0
- bareagent/core/workflow_registry.py +219 -0
- bareagent/debug/__init__.py +0 -0
- bareagent/debug/interaction_log.py +263 -0
- bareagent/debug/viewer.html +1750 -0
- bareagent/debug/web_viewer.py +157 -0
- bareagent/hooks/__init__.py +32 -0
- bareagent/hooks/config.py +118 -0
- bareagent/hooks/engine.py +197 -0
- bareagent/hooks/errors.py +14 -0
- bareagent/hooks/events.py +22 -0
- bareagent/lsp/__init__.py +63 -0
- bareagent/lsp/config.py +134 -0
- bareagent/lsp/coord.py +118 -0
- bareagent/lsp/diagnostics.py +240 -0
- bareagent/lsp/errors.py +24 -0
- bareagent/lsp/manager.py +866 -0
- bareagent/lsp/tools.py +629 -0
- bareagent/lsp/workspace_edit.py +305 -0
- bareagent/main.py +4205 -0
- bareagent/mcp/__init__.py +69 -0
- bareagent/mcp/_sse.py +69 -0
- bareagent/mcp/client.py +341 -0
- bareagent/mcp/config.py +169 -0
- bareagent/mcp/errors.py +32 -0
- bareagent/mcp/manager.py +318 -0
- bareagent/mcp/protocol.py +187 -0
- bareagent/mcp/registry.py +557 -0
- bareagent/mcp/transport/__init__.py +15 -0
- bareagent/mcp/transport/base.py +149 -0
- bareagent/mcp/transport/http_legacy.py +192 -0
- bareagent/mcp/transport/http_streamable.py +217 -0
- bareagent/mcp/transport/stdio.py +202 -0
- bareagent/memory/__init__.py +1 -0
- bareagent/memory/compact.py +203 -0
- bareagent/memory/conversation_io.py +226 -0
- bareagent/memory/embedding.py +194 -0
- bareagent/memory/persistent.py +515 -0
- bareagent/memory/token_counter.py +67 -0
- bareagent/memory/token_tracker.py +262 -0
- bareagent/memory/transcript.py +100 -0
- bareagent/permission/__init__.py +1 -0
- bareagent/permission/guard.py +329 -0
- bareagent/permission/rules.py +19 -0
- bareagent/planning/__init__.py +19 -0
- bareagent/planning/agent_types.py +169 -0
- bareagent/planning/skill_gen.py +141 -0
- bareagent/planning/skill_store.py +173 -0
- bareagent/planning/skills.py +146 -0
- bareagent/planning/subagent.py +355 -0
- bareagent/planning/subagent_registry.py +77 -0
- bareagent/planning/tasks.py +348 -0
- bareagent/planning/todo.py +153 -0
- bareagent/planning/worktree.py +122 -0
- bareagent/provider/__init__.py +1 -0
- bareagent/provider/anthropic.py +348 -0
- bareagent/provider/base.py +136 -0
- bareagent/provider/factory.py +130 -0
- bareagent/provider/openai.py +881 -0
- bareagent/provider/presets.py +72 -0
- bareagent/provider/setup.py +356 -0
- bareagent/skills/.gitkeep +1 -0
- bareagent/skills/code-review/SKILL.md +68 -0
- bareagent/skills/git/SKILL.md +68 -0
- bareagent/skills/test/SKILL.md +70 -0
- bareagent/team/__init__.py +17 -0
- bareagent/team/autonomous.py +193 -0
- bareagent/team/mailbox.py +239 -0
- bareagent/team/manager.py +155 -0
- bareagent/team/protocols.py +129 -0
- bareagent/tracing/__init__.py +12 -0
- bareagent/tracing/_api.py +92 -0
- bareagent/tracing/_proxy.py +60 -0
- bareagent/tracing/composite.py +115 -0
- bareagent/tracing/json_file.py +115 -0
- bareagent/tracing/langfuse.py +139 -0
- bareagent/tracing/otel.py +107 -0
- bareagent/tracing/setup.py +85 -0
- bareagent/ui/__init__.py +24 -0
- bareagent/ui/console.py +167 -0
- bareagent/ui/prompt.py +78 -0
- bareagent/ui/protocol.py +24 -0
- bareagent/ui/stream.py +66 -0
- bareagent/ui/theme.py +240 -0
- bareagent_cli-0.1.0.dist-info/METADATA +331 -0
- bareagent_cli-0.1.0.dist-info/RECORD +121 -0
- bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
- bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- bareagent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def parse_permission_rules(config: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
7
|
+
"""Parse allow and deny permission rules from config data."""
|
|
8
|
+
permission_config = config.get("permission", {})
|
|
9
|
+
allow = _coerce_rule_list(permission_config.get("allow", []))
|
|
10
|
+
deny = _coerce_rule_list(permission_config.get("deny", []))
|
|
11
|
+
return allow, deny
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _coerce_rule_list(value: Any) -> list[str]:
|
|
15
|
+
if value is None:
|
|
16
|
+
return []
|
|
17
|
+
if isinstance(value, str):
|
|
18
|
+
return [value]
|
|
19
|
+
return [str(rule) for rule in value]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Planning modules for BareAgent."""
|
|
2
|
+
|
|
3
|
+
from bareagent.planning.agent_types import BUILTIN_AGENT_TYPES, DEFAULT_AGENT_TYPE, AgentType
|
|
4
|
+
from bareagent.planning.skills import SkillLoader, SkillMeta
|
|
5
|
+
from bareagent.planning.subagent import run_subagent
|
|
6
|
+
from bareagent.planning.tasks import Task, TaskManager
|
|
7
|
+
from bareagent.planning.todo import TodoManager
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AgentType",
|
|
11
|
+
"BUILTIN_AGENT_TYPES",
|
|
12
|
+
"DEFAULT_AGENT_TYPE",
|
|
13
|
+
"SkillLoader",
|
|
14
|
+
"SkillMeta",
|
|
15
|
+
"Task",
|
|
16
|
+
"TaskManager",
|
|
17
|
+
"TodoManager",
|
|
18
|
+
"run_subagent",
|
|
19
|
+
]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from bareagent.permission.guard import PermissionMode
|
|
8
|
+
|
|
9
|
+
_log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class AgentType:
|
|
14
|
+
"""Definition for a built-in child-agent profile."""
|
|
15
|
+
|
|
16
|
+
name: str
|
|
17
|
+
description: str
|
|
18
|
+
system_prompt: str = ""
|
|
19
|
+
tools: list[str] | None = None
|
|
20
|
+
disallowed_tools: list[str] | None = None
|
|
21
|
+
max_turns: int = 200
|
|
22
|
+
allow_nesting: bool = True
|
|
23
|
+
permission_mode: PermissionMode | None = None
|
|
24
|
+
# When False, ``filter_tools`` strips every ``mcp__*`` tool. Used by
|
|
25
|
+
# read-only agent types (explore / plan / code-review) to keep MCP side
|
|
26
|
+
# effects out of subagent contexts. Defaults to True for backwards
|
|
27
|
+
# compatibility with custom user-defined agent types.
|
|
28
|
+
mcp_tools_enabled: bool = True
|
|
29
|
+
# When False, ``filter_tools`` strips every ``lsp_*`` tool. The four
|
|
30
|
+
# Tier-1 LSP tools are read-only (outline / definition / references /
|
|
31
|
+
# diagnostics), so read-only agent types keep them enabled by default —
|
|
32
|
+
# this flag exists for tighter sandboxes that want a strict allow-list.
|
|
33
|
+
lsp_tools_enabled: bool = True
|
|
34
|
+
# The ``memory`` tool is a single tool with a ``command`` enum, so it
|
|
35
|
+
# cannot be downgraded to read-only by name-filtering like mcp/lsp. When
|
|
36
|
+
# False, the subagent layer wraps the handler to reject the write commands
|
|
37
|
+
# (create/str_replace/insert/delete/rename) while still allowing ``view``.
|
|
38
|
+
# Read-only agent types default to False so they can recall but not mutate
|
|
39
|
+
# shared memory.
|
|
40
|
+
memory_writable: bool = True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Tools that only make sense in the main REPL loop and must never reach a
|
|
44
|
+
# sub-agent, regardless of agent type. ``exit_plan_mode`` flips the *parent's*
|
|
45
|
+
# permission mode through an interactive approval prompt -- a sub-agent has no
|
|
46
|
+
# human to ask and no business mutating the parent's mode. ``workflow`` fans out
|
|
47
|
+
# its own subagents; letting a sub-agent call it would allow unbounded nesting
|
|
48
|
+
# (out of scope for the MVP). ``subagent_send`` resumes a foreground subagent
|
|
49
|
+
# from the REPL-scoped registry; only the main loop spawns resumable contexts,
|
|
50
|
+
# so a sub-agent has nothing to continue. ``skill_create`` stays out of
|
|
51
|
+
# sub-agents by never joining the global tool set; ``exit_plan_mode`` /
|
|
52
|
+
# ``workflow`` / ``subagent_send`` do live in the main loop's set, so they are
|
|
53
|
+
# stripped here as a centralized, agent-type-independent guarantee
|
|
54
|
+
# (filter_handlers then drops the orphaned handler).
|
|
55
|
+
MAIN_LOOP_ONLY_TOOLS = frozenset({"exit_plan_mode", "workflow", "subagent_send"})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_READ_ONLY_DEFAULTS: dict[str, Any] = {
|
|
59
|
+
# ``semantic_rename`` is a write tool that does not carry the ``lsp_`` name
|
|
60
|
+
# prefix (so ``lsp_tools_enabled=True`` does not filter it). It must be
|
|
61
|
+
# denied explicitly here, otherwise read-only agents would keep a tool that
|
|
62
|
+
# mutates files across the workspace.
|
|
63
|
+
"disallowed_tools": [
|
|
64
|
+
"write_file",
|
|
65
|
+
"edit_file",
|
|
66
|
+
"bash",
|
|
67
|
+
"subagent",
|
|
68
|
+
"semantic_rename",
|
|
69
|
+
],
|
|
70
|
+
"max_turns": 50,
|
|
71
|
+
"allow_nesting": False,
|
|
72
|
+
"permission_mode": PermissionMode.PLAN,
|
|
73
|
+
"mcp_tools_enabled": False,
|
|
74
|
+
"lsp_tools_enabled": True,
|
|
75
|
+
"memory_writable": False,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
BUILTIN_AGENT_TYPES: dict[str, AgentType] = {
|
|
79
|
+
"general-purpose": AgentType(
|
|
80
|
+
name="general-purpose",
|
|
81
|
+
description="General child agent with the full inherited toolset.",
|
|
82
|
+
),
|
|
83
|
+
"explore": AgentType(
|
|
84
|
+
name="explore",
|
|
85
|
+
description="Read-only agent for code search and repository understanding.",
|
|
86
|
+
system_prompt=(
|
|
87
|
+
"You are a read-only exploration agent. Search and inspect the repository, "
|
|
88
|
+
"but do not modify files or perform side effects."
|
|
89
|
+
),
|
|
90
|
+
**_READ_ONLY_DEFAULTS,
|
|
91
|
+
),
|
|
92
|
+
"plan": AgentType(
|
|
93
|
+
name="plan",
|
|
94
|
+
description="Planning agent for implementation design without repository mutation.",
|
|
95
|
+
system_prompt=(
|
|
96
|
+
"You are a planning agent. Analyze the codebase and produce an implementation "
|
|
97
|
+
"plan, but do not modify files or perform side effects."
|
|
98
|
+
),
|
|
99
|
+
**_READ_ONLY_DEFAULTS,
|
|
100
|
+
),
|
|
101
|
+
"code-review": AgentType(
|
|
102
|
+
name="code-review",
|
|
103
|
+
description="Read-only review agent for bugs, regressions, and code quality issues.",
|
|
104
|
+
system_prompt=(
|
|
105
|
+
"You are a code review agent. Inspect code for defects, regressions, security "
|
|
106
|
+
"issues, and maintainability risks. Do not modify files."
|
|
107
|
+
),
|
|
108
|
+
**_READ_ONLY_DEFAULTS,
|
|
109
|
+
),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
DEFAULT_AGENT_TYPE = "general-purpose"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def resolve_agent_type(
|
|
116
|
+
name: str | None,
|
|
117
|
+
*,
|
|
118
|
+
default_name: str = DEFAULT_AGENT_TYPE,
|
|
119
|
+
) -> AgentType:
|
|
120
|
+
"""Resolve a child-agent type, falling back to the configured default."""
|
|
121
|
+
|
|
122
|
+
resolved_default = BUILTIN_AGENT_TYPES.get(
|
|
123
|
+
default_name, BUILTIN_AGENT_TYPES[DEFAULT_AGENT_TYPE]
|
|
124
|
+
)
|
|
125
|
+
if name is None:
|
|
126
|
+
return resolved_default
|
|
127
|
+
if name not in BUILTIN_AGENT_TYPES:
|
|
128
|
+
_log.warning("Unknown agent type %r, falling back to %r", name, resolved_default.name)
|
|
129
|
+
return resolved_default
|
|
130
|
+
return BUILTIN_AGENT_TYPES[name]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def filter_tools(
|
|
134
|
+
all_tools: list[dict[str, Any]],
|
|
135
|
+
agent_type: AgentType,
|
|
136
|
+
) -> list[dict[str, Any]]:
|
|
137
|
+
"""Apply whitelist, blacklist, and nesting controls to a tool schema list."""
|
|
138
|
+
|
|
139
|
+
allowed = set(agent_type.tools) if agent_type.tools is not None else None
|
|
140
|
+
denied = set(agent_type.disallowed_tools) if agent_type.disallowed_tools is not None else None
|
|
141
|
+
strip_nesting = not agent_type.allow_nesting
|
|
142
|
+
|
|
143
|
+
def _keep(tool: dict[str, Any]) -> bool:
|
|
144
|
+
name = str(tool.get("name"))
|
|
145
|
+
if name in MAIN_LOOP_ONLY_TOOLS:
|
|
146
|
+
return False
|
|
147
|
+
if allowed is not None and name not in allowed:
|
|
148
|
+
return False
|
|
149
|
+
if denied is not None and name in denied:
|
|
150
|
+
return False
|
|
151
|
+
if strip_nesting and name == "subagent":
|
|
152
|
+
return False
|
|
153
|
+
if not agent_type.mcp_tools_enabled and name.startswith("mcp__"):
|
|
154
|
+
return False
|
|
155
|
+
if not agent_type.lsp_tools_enabled and name.startswith("lsp_"):
|
|
156
|
+
return False
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
return [tool for tool in all_tools if _keep(tool)]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def filter_handlers(
|
|
163
|
+
all_handlers: dict[str, Any],
|
|
164
|
+
filtered_tools: list[dict[str, Any]],
|
|
165
|
+
) -> dict[str, Any]:
|
|
166
|
+
"""Keep only handlers that still have a matching tool schema."""
|
|
167
|
+
|
|
168
|
+
allowed_names = {str(tool.get("name")) for tool in filtered_tools}
|
|
169
|
+
return {name: handler for name, handler in all_handlers.items() if name in allowed_names}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Experiential skill generation: decide when to draft a reusable skill.
|
|
2
|
+
|
|
3
|
+
Pure logic with no LLM / loop / SDK dependencies so it is unit-testable in
|
|
4
|
+
isolation (mirrors the ``src/core/retry.py`` pure-module pattern). The agent
|
|
5
|
+
loop feeds per-turn tool-call counts into :class:`SkillGenerator`, which
|
|
6
|
+
accumulates them across user turns and reports when both thresholds are crossed.
|
|
7
|
+
The actual reflection LLM call + draft persistence live in the REPL / store
|
|
8
|
+
layers; this module only owns the *trigger decision* and the draft instruction
|
|
9
|
+
text.
|
|
10
|
+
|
|
11
|
+
Design (see task 06-01-experiential-skill-gen):
|
|
12
|
+
- A "task worth saving" spans multiple user turns AND involves real tool work.
|
|
13
|
+
So the trigger is a double AND: cumulative ``tool_calls >= min_tool_calls``
|
|
14
|
+
and cumulative ``user_replies >= min_user_replies``.
|
|
15
|
+
- Counters accumulate from session start / last draft and reset on each trigger,
|
|
16
|
+
so one multi-turn workflow is packed into a single skill rather than firing
|
|
17
|
+
every turn.
|
|
18
|
+
- ``enabled=False`` short-circuits everything: no counting, never drafts.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class SkillGenConfig:
|
|
28
|
+
"""Thresholds + master switch for experiential skill generation.
|
|
29
|
+
|
|
30
|
+
Mirrors the user-facing ``[skills]`` config (``main.py`` builds this from
|
|
31
|
+
``SkillsConfig`` the same way ``_build_retry_policy`` adapts ``RetryConfig``
|
|
32
|
+
to ``RetryPolicy``).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
enabled: bool = True
|
|
36
|
+
min_tool_calls: int = 5
|
|
37
|
+
min_user_replies: int = 3
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def should_draft_skill(
|
|
41
|
+
tool_calls: int,
|
|
42
|
+
user_replies: int,
|
|
43
|
+
config: SkillGenConfig,
|
|
44
|
+
) -> bool:
|
|
45
|
+
"""Return True when the accumulated activity warrants drafting a skill.
|
|
46
|
+
|
|
47
|
+
Double AND on the two thresholds; always False when disabled. Pure function
|
|
48
|
+
so the decision is directly unit-testable without constructing a generator.
|
|
49
|
+
"""
|
|
50
|
+
if not config.enabled:
|
|
51
|
+
return False
|
|
52
|
+
return tool_calls >= config.min_tool_calls and user_replies >= config.min_user_replies
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Instruction injected as a user turn for the isolated reflection call. It lets
|
|
56
|
+
# the model DECLINE (respond "no skill") so a low-value, one-off task does not
|
|
57
|
+
# get forced into a skill — a second quality gate on top of the user-promote
|
|
58
|
+
# step. The model is told to call ``skill_create`` exactly once when it does
|
|
59
|
+
# decide the workflow is worth preserving.
|
|
60
|
+
DRAFT_INSTRUCTION = (
|
|
61
|
+
"You just finished a multi-step task spanning several turns. If — and only "
|
|
62
|
+
"if — there is a genuinely reusable, non-trivial workflow worth preserving "
|
|
63
|
+
"for next time, capture it as a skill by calling the `skill_create` tool "
|
|
64
|
+
"exactly once.\n\n"
|
|
65
|
+
"Distill the procedure so a future agent with no memory of this session "
|
|
66
|
+
"could follow it:\n"
|
|
67
|
+
'- name: short kebab-case identifier (e.g. "add-config-section").\n'
|
|
68
|
+
'- description: one line starting with "Use this when".\n'
|
|
69
|
+
"- body: markdown with sections like Steps / Pitfalls / Verification, "
|
|
70
|
+
"including dead-ends you hit and how you got past them.\n\n"
|
|
71
|
+
"If the work was too trivial or one-off to generalize, do NOT call the "
|
|
72
|
+
'tool — reply with the single line "no skill" instead.'
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def render_reflection_prompt(candidates: list[tuple[str, str]]) -> str:
|
|
77
|
+
"""Build the reflection user message, optionally offering skills to refine.
|
|
78
|
+
|
|
79
|
+
Self-evolution (task 06-01-skill-self-evolution): when generated skills
|
|
80
|
+
already exist they are listed as refinement targets — the model is told to
|
|
81
|
+
reuse a skill's EXACT name (after reading it with ``load_skill``) so the new
|
|
82
|
+
draft supersedes it on promotion, rather than piling up a near-duplicate.
|
|
83
|
+
|
|
84
|
+
With no candidates this returns the bare :data:`DRAFT_INSTRUCTION`
|
|
85
|
+
(byte-identical to the pre-evolution create-only behavior).
|
|
86
|
+
"""
|
|
87
|
+
if not candidates:
|
|
88
|
+
return DRAFT_INSTRUCTION
|
|
89
|
+
listing = "\n".join(f"- {name}: {desc}" for name, desc in candidates)
|
|
90
|
+
prefix = (
|
|
91
|
+
"Existing generated skills you may REFINE instead of duplicating. If "
|
|
92
|
+
"this session improves one of them, read it first with `load_skill`, "
|
|
93
|
+
"then call `skill_create` with that skill's EXACT name so your improved "
|
|
94
|
+
"version supersedes it. Otherwise use a fresh name. Never reuse a "
|
|
95
|
+
"built-in (repo) skill name.\n"
|
|
96
|
+
f"{listing}\n\n"
|
|
97
|
+
)
|
|
98
|
+
return prefix + DRAFT_INSTRUCTION
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class SkillGenerator:
|
|
102
|
+
"""Accumulates per-turn activity and decides when to draft a skill.
|
|
103
|
+
|
|
104
|
+
Lives for the whole REPL session and is passed into the *main* ``agent_loop``
|
|
105
|
+
only (sub-agents never receive it, so background / nested agents never
|
|
106
|
+
trigger generation — same isolation stance as ``hook_engine``). The loop
|
|
107
|
+
calls :meth:`note_turn` once per completed user turn; the REPL then checks
|
|
108
|
+
:meth:`should_draft` and runs the reflection.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
__slots__ = ("config", "_tool_calls", "_user_replies")
|
|
112
|
+
|
|
113
|
+
def __init__(self, config: SkillGenConfig) -> None:
|
|
114
|
+
self.config = config
|
|
115
|
+
self._tool_calls = 0
|
|
116
|
+
self._user_replies = 0
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def enabled(self) -> bool:
|
|
120
|
+
return self.config.enabled
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def counters(self) -> tuple[int, int]:
|
|
124
|
+
"""Current ``(tool_calls, user_replies)`` accumulators (for tests / logs)."""
|
|
125
|
+
return (self._tool_calls, self._user_replies)
|
|
126
|
+
|
|
127
|
+
def note_turn(self, turn_tool_calls: int) -> None:
|
|
128
|
+
"""Record one completed user turn that ran ``turn_tool_calls`` tools."""
|
|
129
|
+
if not self.config.enabled:
|
|
130
|
+
return
|
|
131
|
+
self._tool_calls += max(0, int(turn_tool_calls))
|
|
132
|
+
self._user_replies += 1
|
|
133
|
+
|
|
134
|
+
def should_draft(self) -> bool:
|
|
135
|
+
"""Whether the accumulated activity has crossed both thresholds."""
|
|
136
|
+
return should_draft_skill(self._tool_calls, self._user_replies, self.config)
|
|
137
|
+
|
|
138
|
+
def reset(self) -> None:
|
|
139
|
+
"""Zero the accumulators (on trigger, or on session boundary)."""
|
|
140
|
+
self._tool_calls = 0
|
|
141
|
+
self._user_replies = 0
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Storage for experientially generated skills (drafts + promotion).
|
|
2
|
+
|
|
3
|
+
Generated skills live OUTSIDE the repo's checked-in ``skills/`` (which is the
|
|
4
|
+
hand-written canon) to avoid polluting version control. They go under the
|
|
5
|
+
user-global BareAgent home, project-isolated by workspace slug — mirroring the
|
|
6
|
+
persistent-memory directory convention (``derive_memory_slug``). Drafts land in
|
|
7
|
+
a ``.pending/`` subdirectory and only become loadable once the user promotes
|
|
8
|
+
them with ``/skill keep``.
|
|
9
|
+
|
|
10
|
+
Layout (``root`` = generated skills root for this project)::
|
|
11
|
+
|
|
12
|
+
<root>/<skill>/SKILL.md # live (loadable) generated skills
|
|
13
|
+
<root>/.pending/<skill>/SKILL.md # drafts awaiting /skill keep
|
|
14
|
+
|
|
15
|
+
Pure filesystem logic with no LLM/loop dependency — unit-testable in isolation.
|
|
16
|
+
The agent-facing ``skill_create`` tool and the ``/skill`` REPL command are thin
|
|
17
|
+
layers over this store.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from bareagent.core.fileutil import atomic_write_text
|
|
28
|
+
from bareagent.memory.persistent import derive_memory_slug
|
|
29
|
+
|
|
30
|
+
PENDING_DIRNAME = ".pending"
|
|
31
|
+
_SKILL_FILE = "SKILL.md"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SkillStoreError(ValueError):
|
|
35
|
+
"""Raised for caller-facing storage failures (bad name, missing draft)."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def derive_skill_slug(name: str) -> str:
|
|
39
|
+
"""Slugify a skill name into a safe single path segment.
|
|
40
|
+
|
|
41
|
+
Lowercases, collapses any non ``[a-z0-9]`` run to a single hyphen, and
|
|
42
|
+
strips leading/trailing hyphens. This also neutralizes path traversal
|
|
43
|
+
(``../`` etc.) since separators become hyphens. Empty result is rejected by
|
|
44
|
+
the callers as an invalid name.
|
|
45
|
+
"""
|
|
46
|
+
slug = re.sub(r"[^a-z0-9]+", "-", name.strip().lower()).strip("-")
|
|
47
|
+
return slug
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def default_generated_skills_root(workspace: Path) -> Path:
|
|
51
|
+
"""Per-project generated-skills directory under the user-global home."""
|
|
52
|
+
return Path.home() / ".bareagent" / "projects" / derive_memory_slug(workspace) / "skills"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def resolve_generated_skills_root(workspace: Path, configured_dir: str) -> Path:
|
|
56
|
+
"""Resolve the generated-skills root from config.
|
|
57
|
+
|
|
58
|
+
Empty ``configured_dir`` falls back to :func:`default_generated_skills_root`.
|
|
59
|
+
A relative override is taken relative to the workspace; an absolute one is
|
|
60
|
+
used as-is. Mirrors ``resolve_memory_root``.
|
|
61
|
+
"""
|
|
62
|
+
configured = (configured_dir or "").strip()
|
|
63
|
+
if not configured:
|
|
64
|
+
return default_generated_skills_root(workspace)
|
|
65
|
+
candidate = Path(configured).expanduser()
|
|
66
|
+
if not candidate.is_absolute():
|
|
67
|
+
candidate = workspace / candidate
|
|
68
|
+
return candidate
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _render_skill_md(name: str, description: str, body: str) -> str:
|
|
72
|
+
"""Render SKILL.md in the format ``SkillLoader`` expects.
|
|
73
|
+
|
|
74
|
+
The description must be the first non-empty, non-``#`` line so
|
|
75
|
+
``SkillLoader._extract_description`` picks it up.
|
|
76
|
+
"""
|
|
77
|
+
desc = description.strip() or "No description provided."
|
|
78
|
+
sections = [f"# {name}", "", desc]
|
|
79
|
+
body = body.strip()
|
|
80
|
+
if body:
|
|
81
|
+
sections += ["", body]
|
|
82
|
+
return "\n".join(sections).rstrip() + "\n"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(slots=True)
|
|
86
|
+
class SkillStore:
|
|
87
|
+
"""Create / promote / discard / list generated skills under ``root``."""
|
|
88
|
+
|
|
89
|
+
root: Path
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def pending_root(self) -> Path:
|
|
93
|
+
return self.root / PENDING_DIRNAME
|
|
94
|
+
|
|
95
|
+
def create_draft(self, name: str, description: str, body: str) -> str:
|
|
96
|
+
"""Write a draft SKILL.md under ``.pending/<slug>/`` and return a note."""
|
|
97
|
+
slug = derive_skill_slug(name)
|
|
98
|
+
if not slug:
|
|
99
|
+
raise SkillStoreError(f"invalid skill name: {name!r}")
|
|
100
|
+
target = self.pending_root / slug / _SKILL_FILE
|
|
101
|
+
atomic_write_text(target, _render_skill_md(slug, description, body))
|
|
102
|
+
return f"Drafted skill '{slug}' to pending (use /skill keep {slug} to keep it)."
|
|
103
|
+
|
|
104
|
+
def promote(self, name: str) -> str:
|
|
105
|
+
"""Move a draft from ``.pending/`` to the live root (replacing any live)."""
|
|
106
|
+
slug = derive_skill_slug(name)
|
|
107
|
+
src = self.pending_root / slug
|
|
108
|
+
if not (src / _SKILL_FILE).exists():
|
|
109
|
+
raise SkillStoreError(f"no pending draft named {slug!r}")
|
|
110
|
+
dest = self.root / slug
|
|
111
|
+
if dest.exists():
|
|
112
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
113
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
shutil.move(str(src), str(dest))
|
|
115
|
+
return f"Promoted skill '{slug}' — it is now loadable."
|
|
116
|
+
|
|
117
|
+
def discard(self, name: str) -> str:
|
|
118
|
+
"""Delete a pending draft."""
|
|
119
|
+
slug = derive_skill_slug(name)
|
|
120
|
+
src = self.pending_root / slug
|
|
121
|
+
if not (src / _SKILL_FILE).exists():
|
|
122
|
+
raise SkillStoreError(f"no pending draft named {slug!r}")
|
|
123
|
+
shutil.rmtree(src, ignore_errors=True)
|
|
124
|
+
return f"Discarded pending skill '{slug}'."
|
|
125
|
+
|
|
126
|
+
def list_live(self) -> list[str]:
|
|
127
|
+
"""Names of promoted (loadable) generated skills, sorted."""
|
|
128
|
+
return self._list_skill_dirs(self.root)
|
|
129
|
+
|
|
130
|
+
def list_pending(self) -> list[str]:
|
|
131
|
+
"""Names of pending drafts, sorted."""
|
|
132
|
+
return self._list_skill_dirs(self.pending_root)
|
|
133
|
+
|
|
134
|
+
def prune_pending(self, max_pending: int) -> list[str]:
|
|
135
|
+
"""Drop oldest pending drafts beyond ``max_pending``; return removed names.
|
|
136
|
+
|
|
137
|
+
Count-based soft cap (no time TTL): keep the ``max_pending`` newest
|
|
138
|
+
drafts by SKILL.md mtime, remove the rest. ``max_pending <= 0`` disables
|
|
139
|
+
pruning.
|
|
140
|
+
"""
|
|
141
|
+
if max_pending <= 0:
|
|
142
|
+
return []
|
|
143
|
+
entries = self._pending_entries_by_mtime()
|
|
144
|
+
if len(entries) <= max_pending:
|
|
145
|
+
return []
|
|
146
|
+
removed: list[str] = []
|
|
147
|
+
for _mtime, slug, path in entries[: len(entries) - max_pending]:
|
|
148
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
149
|
+
removed.append(slug)
|
|
150
|
+
return removed
|
|
151
|
+
|
|
152
|
+
def _pending_entries_by_mtime(self) -> list[tuple[float, str, Path]]:
|
|
153
|
+
"""Pending ``(mtime, slug, dir)`` tuples, oldest first (ties by name)."""
|
|
154
|
+
entries: list[tuple[float, str, Path]] = []
|
|
155
|
+
try:
|
|
156
|
+
paths = sorted(self.pending_root.glob(f"*/{_SKILL_FILE}"))
|
|
157
|
+
except OSError:
|
|
158
|
+
return []
|
|
159
|
+
for skill_file in paths:
|
|
160
|
+
try:
|
|
161
|
+
mtime = skill_file.stat().st_mtime
|
|
162
|
+
except OSError:
|
|
163
|
+
mtime = 0.0
|
|
164
|
+
entries.append((mtime, skill_file.parent.name, skill_file.parent))
|
|
165
|
+
entries.sort(key=lambda item: (item[0], item[1]))
|
|
166
|
+
return entries
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _list_skill_dirs(base: Path) -> list[str]:
|
|
170
|
+
try:
|
|
171
|
+
return sorted(p.parent.name for p in base.glob(f"*/{_SKILL_FILE}"))
|
|
172
|
+
except OSError:
|
|
173
|
+
return []
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from bareagent.core.schema import tool_schema as _schema
|
|
10
|
+
|
|
11
|
+
LOAD_SKILL_TOOL_SCHEMAS: list[dict[str, Any]] = [
|
|
12
|
+
_schema(
|
|
13
|
+
"load_skill",
|
|
14
|
+
"Load the full content of a named SKILL.md file on demand.",
|
|
15
|
+
{
|
|
16
|
+
"skill_name": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"description": "The skill directory name to load.",
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
["skill_name"],
|
|
22
|
+
)
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class SkillMeta:
|
|
28
|
+
skill_name: str
|
|
29
|
+
description: str
|
|
30
|
+
path: Path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_skills_dir() -> Path:
|
|
34
|
+
"""Locate the bundled canon skills directory.
|
|
35
|
+
|
|
36
|
+
Honors the ``BAREAGENT_SKILLS_DIR`` override first (if it exists); otherwise
|
|
37
|
+
resolves the ``skills/`` tree bundled inside the package
|
|
38
|
+
(``src/bareagent/skills``) via importlib.resources, which works for both
|
|
39
|
+
editable and wheel installs. BareAgent is never a zipapp, so the resource is
|
|
40
|
+
a real on-disk directory that supports ``.glob()``.
|
|
41
|
+
"""
|
|
42
|
+
env_path = os.getenv("BAREAGENT_SKILLS_DIR")
|
|
43
|
+
if env_path:
|
|
44
|
+
candidate = Path(env_path).expanduser()
|
|
45
|
+
if candidate.exists():
|
|
46
|
+
return candidate.resolve()
|
|
47
|
+
|
|
48
|
+
return Path(str(files("bareagent").joinpath("skills"))).resolve()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SkillLoader:
|
|
52
|
+
def __init__(self, skills_dir: Path, generated_root: Path | None = None) -> None:
|
|
53
|
+
# ``skills_dir`` is the repo's checked-in canon. ``generated_root`` is the
|
|
54
|
+
# optional experiential-skill layer (live promoted skills only — its
|
|
55
|
+
# ``.pending/`` drafts are NOT scanned because the glob is one level
|
|
56
|
+
# deep). Canon wins on name conflicts.
|
|
57
|
+
self.skills_dir = skills_dir
|
|
58
|
+
self.generated_root = generated_root
|
|
59
|
+
self._cache: dict[str, SkillMeta] = {}
|
|
60
|
+
|
|
61
|
+
def scan(self) -> list[SkillMeta]:
|
|
62
|
+
skills: list[SkillMeta] = []
|
|
63
|
+
cache: dict[str, SkillMeta] = {}
|
|
64
|
+
|
|
65
|
+
# Order matters: scan canon first so a generated skill never shadows a
|
|
66
|
+
# repo skill of the same name.
|
|
67
|
+
roots = [self.skills_dir]
|
|
68
|
+
if self.generated_root is not None:
|
|
69
|
+
roots.append(self.generated_root)
|
|
70
|
+
|
|
71
|
+
for root in roots:
|
|
72
|
+
try:
|
|
73
|
+
entries = sorted(root.glob("*/SKILL.md"))
|
|
74
|
+
except OSError:
|
|
75
|
+
continue
|
|
76
|
+
for skill_file in entries:
|
|
77
|
+
skill_name = skill_file.parent.name
|
|
78
|
+
if skill_name in cache:
|
|
79
|
+
continue
|
|
80
|
+
description = self._extract_description(skill_file)
|
|
81
|
+
meta = SkillMeta(
|
|
82
|
+
skill_name=skill_name,
|
|
83
|
+
description=description,
|
|
84
|
+
path=skill_file,
|
|
85
|
+
)
|
|
86
|
+
skills.append(meta)
|
|
87
|
+
cache[skill_name] = meta
|
|
88
|
+
|
|
89
|
+
self._cache = cache
|
|
90
|
+
return skills
|
|
91
|
+
|
|
92
|
+
def load(self, skill_name: str) -> str:
|
|
93
|
+
meta = self._lookup(skill_name)
|
|
94
|
+
return meta.path.read_text(encoding="utf-8").strip()
|
|
95
|
+
|
|
96
|
+
def canon_skill_names(self) -> set[str]:
|
|
97
|
+
"""Names of the repo's checked-in canon skills (``skills_dir`` only).
|
|
98
|
+
|
|
99
|
+
Used by the self-evolution reflection to forbid generated skills from
|
|
100
|
+
colliding with a canon name — such a generated skill would be shadowed
|
|
101
|
+
(canon wins in :meth:`scan`) and never load, i.e. a dead skill.
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
return {skill_file.parent.name for skill_file in self.skills_dir.glob("*/SKILL.md")}
|
|
105
|
+
except OSError:
|
|
106
|
+
return set()
|
|
107
|
+
|
|
108
|
+
def get_skill_list_prompt(self) -> str:
|
|
109
|
+
skills = self.scan()
|
|
110
|
+
if not skills:
|
|
111
|
+
return "No skills are available."
|
|
112
|
+
|
|
113
|
+
lines = [
|
|
114
|
+
"Available skills (load the full SKILL.md only when you need the details):"
|
|
115
|
+
]
|
|
116
|
+
for skill in skills:
|
|
117
|
+
lines.append(f"- {skill.skill_name}: {skill.description}")
|
|
118
|
+
return "\n".join(lines)
|
|
119
|
+
|
|
120
|
+
def _lookup(self, skill_name: str) -> SkillMeta:
|
|
121
|
+
normalized = skill_name.strip()
|
|
122
|
+
if not normalized:
|
|
123
|
+
raise ValueError("skill_name must not be empty")
|
|
124
|
+
|
|
125
|
+
meta = self._cache.get(normalized)
|
|
126
|
+
if meta is None:
|
|
127
|
+
self.scan()
|
|
128
|
+
meta = self._cache.get(normalized)
|
|
129
|
+
if meta is None:
|
|
130
|
+
raise ValueError(f"Unknown skill: {skill_name}")
|
|
131
|
+
return meta
|
|
132
|
+
|
|
133
|
+
def _extract_description(self, skill_file: Path) -> str:
|
|
134
|
+
with skill_file.open(encoding="utf-8") as fh:
|
|
135
|
+
for raw_line in fh:
|
|
136
|
+
line = raw_line.strip()
|
|
137
|
+
if not line:
|
|
138
|
+
continue
|
|
139
|
+
if line.startswith("#"):
|
|
140
|
+
continue
|
|
141
|
+
return line
|
|
142
|
+
return "No description provided."
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def make_skill_handlers(skill_loader: SkillLoader) -> dict[str, Any]:
|
|
146
|
+
return {"load_skill": skill_loader.load}
|