semora-coding 0.2.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.
- semora_coding/__init__.py +7 -0
- semora_coding/builtins/__init__.py +249 -0
- semora_coding/builtins/_exec.py +303 -0
- semora_coding/builtins/_files.py +257 -0
- semora_coding/builtins/_search.py +384 -0
- semora_coding/builtins/_types.py +118 -0
- semora_coding/builtins/_web.py +209 -0
- semora_coding/goal.py +87 -0
- semora_coding/plan_mode.py +134 -0
- semora_coding/prompts.py +98 -0
- semora_coding/py.typed +0 -0
- semora_coding/skills.py +424 -0
- semora_coding/tool_search.py +224 -0
- semora_coding-0.2.0.dist-info/METADATA +32 -0
- semora_coding-0.2.0.dist-info/RECORD +16 -0
- semora_coding-0.2.0.dist-info/WHEEL +4 -0
semora_coding/skills.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""Source-neutral skill discovery and progressive disclosure.
|
|
2
|
+
|
|
3
|
+
Sources list lightweight metadata and load full instructions only when the ``skill`` tool is
|
|
4
|
+
invoked. Filesystem discovery is one adapter, not a registry responsibility.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import builtins
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Mapping, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from html import escape
|
|
16
|
+
from inspect import isawaitable
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Protocol, runtime_checkable
|
|
19
|
+
|
|
20
|
+
from semora.contracts import BaseMessage, DynamicTools, Tools
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"DirectorySkillSource",
|
|
24
|
+
"Skill",
|
|
25
|
+
"SkillMetadata",
|
|
26
|
+
"SkillRegistry",
|
|
27
|
+
"SkillSource",
|
|
28
|
+
"SkillTools",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$")
|
|
32
|
+
_DEFAULT_CATALOG_BUDGET = 8_000
|
|
33
|
+
_DESCRIPTION_LIMIT = 250
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class SkillMetadata:
|
|
38
|
+
"""The model-visible part of a skill returned by source discovery."""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
description: str
|
|
42
|
+
revision: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Skill:
|
|
47
|
+
"""Full instructions loaded on demand from an arbitrary source."""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
description: str
|
|
51
|
+
body: str
|
|
52
|
+
origin: str | None = None
|
|
53
|
+
resource_base: str | None = None
|
|
54
|
+
allowed_tools: tuple[str, ...] = ()
|
|
55
|
+
paths: tuple[str, ...] = ()
|
|
56
|
+
|
|
57
|
+
def context(self, arguments: str = "") -> str:
|
|
58
|
+
"""Render the full on-demand context injected after the tool result."""
|
|
59
|
+
body = self.body
|
|
60
|
+
prefix = ""
|
|
61
|
+
if self.resource_base is not None:
|
|
62
|
+
body = body.replace("${SEMORA_SKILL_ROOT}", self.resource_base)
|
|
63
|
+
body = body.replace("${SEMORA_SKILL_DIR}", self.resource_base)
|
|
64
|
+
prefix = f"Resource base for this skill: {self.resource_base}\n\n"
|
|
65
|
+
body = body.replace("${ARGUMENTS}", arguments).replace("$ARGUMENTS", arguments)
|
|
66
|
+
argument_block = f"\n\nArguments: {arguments}" if arguments else ""
|
|
67
|
+
return f"{prefix}{body}{argument_block}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@runtime_checkable
|
|
71
|
+
class SkillSource(Protocol):
|
|
72
|
+
"""Metadata-first store for directory, database, API, or package-backed skills."""
|
|
73
|
+
|
|
74
|
+
async def list(self) -> Sequence[SkillMetadata]:
|
|
75
|
+
"""List discovery metadata without loading complete skill bodies."""
|
|
76
|
+
...
|
|
77
|
+
|
|
78
|
+
async def load(self, name: str) -> Skill | None:
|
|
79
|
+
"""Load one exact skill body, or return ``None`` if it disappeared."""
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class DirectorySkillSource:
|
|
84
|
+
"""Read directory-form ``SKILL.md`` files through the generic source contract."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, root: str | Path) -> None:
|
|
87
|
+
"""Resolve the root once; source callers remain responsible for user expansion."""
|
|
88
|
+
self.root = Path(root).resolve()
|
|
89
|
+
self._locations: dict[str, Path] | None = None
|
|
90
|
+
|
|
91
|
+
async def list(self) -> Sequence[SkillMetadata]:
|
|
92
|
+
"""Read frontmatter only; instruction bodies remain unread until ``load``."""
|
|
93
|
+
metadata, locations = await asyncio.to_thread(self._discover)
|
|
94
|
+
self._locations = locations
|
|
95
|
+
return metadata
|
|
96
|
+
|
|
97
|
+
async def load(self, name: str) -> Skill | None:
|
|
98
|
+
"""Read and parse one previously discovered skill file."""
|
|
99
|
+
if self._locations is None:
|
|
100
|
+
await self.list()
|
|
101
|
+
assert self._locations is not None
|
|
102
|
+
path = self._locations.get(name)
|
|
103
|
+
if path is None:
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
return await asyncio.to_thread(_parse_skill, path)
|
|
107
|
+
except (OSError, ValueError):
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def _discover(self) -> tuple[tuple[SkillMetadata, ...], dict[str, Path]]:
|
|
111
|
+
found: dict[str, SkillMetadata] = {}
|
|
112
|
+
locations: dict[str, Path] = {}
|
|
113
|
+
if not self.root.is_dir():
|
|
114
|
+
return (), locations
|
|
115
|
+
for root, directories, files in os.walk(self.root, followlinks=False):
|
|
116
|
+
directories[:] = sorted(
|
|
117
|
+
directory for directory in directories if not (Path(root) / directory).is_symlink()
|
|
118
|
+
)
|
|
119
|
+
if "SKILL.md" not in files:
|
|
120
|
+
continue
|
|
121
|
+
path = Path(root) / "SKILL.md"
|
|
122
|
+
if path.is_symlink():
|
|
123
|
+
continue
|
|
124
|
+
try:
|
|
125
|
+
metadata = _read_metadata(path)
|
|
126
|
+
except (OSError, ValueError):
|
|
127
|
+
continue
|
|
128
|
+
found[metadata.name] = metadata
|
|
129
|
+
locations[metadata.name] = path.resolve()
|
|
130
|
+
return tuple(found[name] for name in sorted(found)), locations
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class SkillRegistry:
|
|
134
|
+
"""Merge ordered metadata sources; a later source overrides an earlier one by name."""
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
sources: Sequence[SkillSource | str | Path],
|
|
139
|
+
*,
|
|
140
|
+
catalog_char_budget: int = _DEFAULT_CATALOG_BUDGET,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Configure source precedence and the maximum model-visible catalog size."""
|
|
143
|
+
if catalog_char_budget < 256:
|
|
144
|
+
raise ValueError("skill catalog budget must be at least 256 characters")
|
|
145
|
+
self._sources = tuple(_source(source) for source in sources)
|
|
146
|
+
self._catalog_char_budget = catalog_char_budget
|
|
147
|
+
self._metadata: dict[str, SkillMetadata] | None = None
|
|
148
|
+
self._owners: dict[str, SkillSource] = {}
|
|
149
|
+
|
|
150
|
+
async def refresh(self) -> tuple[SkillMetadata, ...]:
|
|
151
|
+
"""Refresh metadata from every source without loading any instruction body."""
|
|
152
|
+
found: dict[str, SkillMetadata] = {}
|
|
153
|
+
owners: dict[str, SkillSource] = {}
|
|
154
|
+
for source in self._sources:
|
|
155
|
+
for metadata in await source.list():
|
|
156
|
+
_validate_metadata(metadata)
|
|
157
|
+
found[metadata.name] = metadata
|
|
158
|
+
owners[metadata.name] = source
|
|
159
|
+
self._metadata = found
|
|
160
|
+
self._owners = owners
|
|
161
|
+
return self.snapshot()
|
|
162
|
+
|
|
163
|
+
def clear(self) -> None:
|
|
164
|
+
"""Forget discovery metadata so the next access refreshes every source."""
|
|
165
|
+
self._metadata = None
|
|
166
|
+
self._owners = {}
|
|
167
|
+
|
|
168
|
+
async def list(self) -> tuple[SkillMetadata, ...]:
|
|
169
|
+
"""Return deterministic discovery metadata, refreshing once when needed."""
|
|
170
|
+
if self._metadata is None:
|
|
171
|
+
await self.refresh()
|
|
172
|
+
return self.snapshot()
|
|
173
|
+
|
|
174
|
+
def snapshot(self) -> tuple[SkillMetadata, ...]:
|
|
175
|
+
"""Return already-fetched metadata without performing source I/O."""
|
|
176
|
+
if self._metadata is None:
|
|
177
|
+
return ()
|
|
178
|
+
return tuple(self._metadata[name] for name in sorted(self._metadata))
|
|
179
|
+
|
|
180
|
+
async def load(self, name: str) -> Skill | None:
|
|
181
|
+
"""Load a full skill from the source that won metadata precedence."""
|
|
182
|
+
await self.list()
|
|
183
|
+
owner = self._owners.get(name)
|
|
184
|
+
if owner is None:
|
|
185
|
+
return None
|
|
186
|
+
skill = await owner.load(name)
|
|
187
|
+
if skill is None:
|
|
188
|
+
return None
|
|
189
|
+
if skill.name != name:
|
|
190
|
+
raise ValueError(f"skill source returned {skill.name!r} for {name!r}")
|
|
191
|
+
return skill
|
|
192
|
+
|
|
193
|
+
async def catalog(self) -> str:
|
|
194
|
+
"""Fetch metadata and build a bounded discovery-only catalog."""
|
|
195
|
+
await self.list()
|
|
196
|
+
return self.catalog_snapshot()
|
|
197
|
+
|
|
198
|
+
def catalog_snapshot(self) -> str:
|
|
199
|
+
"""Build a catalog from fetched metadata without source I/O."""
|
|
200
|
+
skills = self.snapshot()
|
|
201
|
+
if not skills:
|
|
202
|
+
return ""
|
|
203
|
+
head = "<available_skills>\n"
|
|
204
|
+
tail = "\n</available_skills>"
|
|
205
|
+
entries = [
|
|
206
|
+
f" - {escape(skill.name)}: {escape(skill.description[:_DESCRIPTION_LIMIT])}"
|
|
207
|
+
for skill in skills
|
|
208
|
+
]
|
|
209
|
+
full = head + "\n".join(entries) + tail
|
|
210
|
+
if len(full) <= self._catalog_char_budget:
|
|
211
|
+
return full
|
|
212
|
+
|
|
213
|
+
names = [f" - {escape(skill.name)}" for skill in skills]
|
|
214
|
+
kept: list[str] = []
|
|
215
|
+
for entry in names:
|
|
216
|
+
remaining = len(skills) - len(kept) - 1
|
|
217
|
+
suffix = f"\n ... {remaining} more" if remaining > 0 else ""
|
|
218
|
+
candidate = head + "\n".join([*kept, entry]) + suffix + tail
|
|
219
|
+
if len(candidate) > self._catalog_char_budget:
|
|
220
|
+
break
|
|
221
|
+
kept.append(entry)
|
|
222
|
+
omitted = len(skills) - len(kept)
|
|
223
|
+
suffix = f"\n ... {omitted} more" if omitted else ""
|
|
224
|
+
return head + "\n".join(kept) + suffix + tail
|
|
225
|
+
|
|
226
|
+
class SkillTools:
|
|
227
|
+
"""Add an on-demand ``skill`` tool to another tool collection."""
|
|
228
|
+
|
|
229
|
+
def __init__(self, inner: Tools, registry: SkillRegistry) -> None:
|
|
230
|
+
"""Compose the registry over an existing tool collection."""
|
|
231
|
+
if any(definition.get("name") == "skill" for definition in inner.list()):
|
|
232
|
+
raise ValueError("the wrapped tool collection already defines 'skill'")
|
|
233
|
+
self._inner = inner
|
|
234
|
+
self._registry = registry
|
|
235
|
+
|
|
236
|
+
async def execute(self, name: str, call_id: str, arguments: Any) -> dict[str, Any]:
|
|
237
|
+
"""Load a skill or delegate an ordinary tool call."""
|
|
238
|
+
if name != "skill":
|
|
239
|
+
return await self._inner.execute(name, call_id, arguments)
|
|
240
|
+
if not isinstance(arguments, Mapping) or not isinstance(arguments.get("skill"), str):
|
|
241
|
+
return {"type": "error", "message": "skill requires a string 'skill' argument"}
|
|
242
|
+
skill_name = str(arguments["skill"]).removeprefix("/").strip()
|
|
243
|
+
skill = await self._registry.load(skill_name)
|
|
244
|
+
if skill is None:
|
|
245
|
+
return {"type": "error", "message": f"unknown skill: {skill_name}"}
|
|
246
|
+
raw_args = arguments.get("args", "")
|
|
247
|
+
if not isinstance(raw_args, str):
|
|
248
|
+
return {"type": "error", "message": "skill 'args' must be a string"}
|
|
249
|
+
metadata: dict[str, Any] = {
|
|
250
|
+
"kind": "skill",
|
|
251
|
+
"name": skill.name,
|
|
252
|
+
"allowed_tools": list(skill.allowed_tools),
|
|
253
|
+
}
|
|
254
|
+
if skill.origin is not None:
|
|
255
|
+
metadata["origin"] = skill.origin
|
|
256
|
+
return {
|
|
257
|
+
"type": "text",
|
|
258
|
+
"text": f"Loaded skill {skill.name}.",
|
|
259
|
+
"context_messages": [
|
|
260
|
+
{
|
|
261
|
+
"content": skill.context(raw_args),
|
|
262
|
+
"metadata": metadata,
|
|
263
|
+
}
|
|
264
|
+
],
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
def get(self, name: str) -> dict[str, Any] | None:
|
|
268
|
+
"""Return one currently available tool definition."""
|
|
269
|
+
if name == "skill":
|
|
270
|
+
return self._definition()
|
|
271
|
+
return self._inner.get(name)
|
|
272
|
+
|
|
273
|
+
def list(self) -> list[dict[str, Any]]:
|
|
274
|
+
"""Expose ordinary tools plus the fetched skill discovery schema."""
|
|
275
|
+
return [*self._inner.list(), self._definition()]
|
|
276
|
+
|
|
277
|
+
async def prepare(self, messages: builtins.list[BaseMessage]) -> None:
|
|
278
|
+
"""Fetch skill metadata and forward dynamic exposure reconstruction."""
|
|
279
|
+
await self._registry.list()
|
|
280
|
+
if isinstance(self._inner, DynamicTools):
|
|
281
|
+
prepared = self._inner.prepare(messages)
|
|
282
|
+
if isawaitable(prepared):
|
|
283
|
+
await prepared
|
|
284
|
+
|
|
285
|
+
def get_context(self) -> Any:
|
|
286
|
+
"""Forward the workspace context when the wrapped collection supports it."""
|
|
287
|
+
get_context = getattr(self._inner, "get_context", None)
|
|
288
|
+
if get_context is None:
|
|
289
|
+
raise TypeError("wrapped tools do not expose a workspace context")
|
|
290
|
+
return get_context()
|
|
291
|
+
|
|
292
|
+
def with_context(self, context: Any) -> SkillTools:
|
|
293
|
+
"""Rebind the wrapped collection without rebuilding the skill registry."""
|
|
294
|
+
with_context = getattr(self._inner, "with_context", None)
|
|
295
|
+
if with_context is None:
|
|
296
|
+
raise TypeError("wrapped tools cannot be rebound to a workspace context")
|
|
297
|
+
return SkillTools(with_context(context), self._registry)
|
|
298
|
+
|
|
299
|
+
def _definition(self) -> dict[str, Any]:
|
|
300
|
+
catalog = self._registry.catalog_snapshot()
|
|
301
|
+
return {
|
|
302
|
+
"name": "skill",
|
|
303
|
+
"description": (
|
|
304
|
+
"Load a matching skill before doing the task. The tool injects the full "
|
|
305
|
+
"instructions into the next model round; do not guess unlisted names.\n\n"
|
|
306
|
+
f"{catalog}"
|
|
307
|
+
),
|
|
308
|
+
"parameters": {
|
|
309
|
+
"type": "object",
|
|
310
|
+
"properties": {
|
|
311
|
+
"skill": {"type": "string", "description": "Exact skill name"},
|
|
312
|
+
"args": {"type": "string", "description": "Optional skill arguments"},
|
|
313
|
+
},
|
|
314
|
+
"required": ["skill"],
|
|
315
|
+
"additionalProperties": False,
|
|
316
|
+
},
|
|
317
|
+
"is_exclusive": True,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _source(source: SkillSource | str | Path) -> SkillSource:
|
|
322
|
+
if isinstance(source, str | Path):
|
|
323
|
+
return DirectorySkillSource(source)
|
|
324
|
+
return source
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _validate_metadata(metadata: SkillMetadata) -> None:
|
|
328
|
+
if not _NAME.fullmatch(metadata.name):
|
|
329
|
+
raise ValueError(f"invalid skill name: {metadata.name!r}")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _read_metadata(path: Path) -> SkillMetadata:
|
|
333
|
+
lines: list[str] = []
|
|
334
|
+
with path.open(encoding="utf-8") as stream:
|
|
335
|
+
first = stream.readline()
|
|
336
|
+
if first.strip() != "---":
|
|
337
|
+
raise ValueError("SKILL.md has no YAML frontmatter")
|
|
338
|
+
for line in stream:
|
|
339
|
+
if line.strip() == "---":
|
|
340
|
+
break
|
|
341
|
+
lines.append(line.rstrip("\n"))
|
|
342
|
+
else:
|
|
343
|
+
raise ValueError("SKILL.md frontmatter is not closed")
|
|
344
|
+
frontmatter = _frontmatter_values(lines)
|
|
345
|
+
name = str(frontmatter.get("name") or path.parent.name).strip()
|
|
346
|
+
if not _NAME.fullmatch(name):
|
|
347
|
+
raise ValueError(f"invalid skill name: {name!r}")
|
|
348
|
+
return SkillMetadata(
|
|
349
|
+
name=name,
|
|
350
|
+
description=str(frontmatter.get("description") or "").strip(),
|
|
351
|
+
revision=str(path.stat().st_mtime_ns),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _parse_skill(path: Path) -> Skill:
|
|
356
|
+
content = path.read_text(encoding="utf-8")
|
|
357
|
+
frontmatter, body = _frontmatter(content)
|
|
358
|
+
name = str(frontmatter.get("name") or path.parent.name).strip()
|
|
359
|
+
if not _NAME.fullmatch(name):
|
|
360
|
+
raise ValueError(f"invalid skill name: {name!r}")
|
|
361
|
+
description = str(frontmatter.get("description") or "").strip()
|
|
362
|
+
return Skill(
|
|
363
|
+
name=name,
|
|
364
|
+
description=description,
|
|
365
|
+
body=body.strip(),
|
|
366
|
+
origin=path.resolve().as_uri(),
|
|
367
|
+
resource_base=str(path.resolve().parent),
|
|
368
|
+
allowed_tools=_string_tuple(frontmatter.get("allowed-tools")),
|
|
369
|
+
paths=_string_tuple(frontmatter.get("paths")),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _frontmatter(content: str) -> tuple[dict[str, Any], str]:
|
|
374
|
+
lines = content.splitlines()
|
|
375
|
+
if not lines or lines[0].strip() != "---":
|
|
376
|
+
raise ValueError("SKILL.md has no YAML frontmatter")
|
|
377
|
+
try:
|
|
378
|
+
end = next(index for index, line in enumerate(lines[1:], 1) if line.strip() == "---")
|
|
379
|
+
except StopIteration as error:
|
|
380
|
+
raise ValueError("SKILL.md frontmatter is not closed") from error
|
|
381
|
+
return _frontmatter_values(lines[1:end]), "\n".join(lines[end + 1 :])
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _frontmatter_values(lines: Sequence[str]) -> dict[str, Any]:
|
|
385
|
+
values: dict[str, Any] = {}
|
|
386
|
+
current_list: str | None = None
|
|
387
|
+
for line in lines:
|
|
388
|
+
stripped = line.strip()
|
|
389
|
+
if current_list is not None and stripped.startswith("- "):
|
|
390
|
+
value = values[current_list]
|
|
391
|
+
assert isinstance(value, list)
|
|
392
|
+
value.append(_scalar(stripped[2:]))
|
|
393
|
+
continue
|
|
394
|
+
if ":" not in line:
|
|
395
|
+
current_list = None
|
|
396
|
+
continue
|
|
397
|
+
key, raw = line.split(":", 1)
|
|
398
|
+
key = key.strip()
|
|
399
|
+
raw = raw.strip()
|
|
400
|
+
if not raw:
|
|
401
|
+
values[key] = []
|
|
402
|
+
current_list = key
|
|
403
|
+
continue
|
|
404
|
+
current_list = None
|
|
405
|
+
values[key] = _scalar(raw)
|
|
406
|
+
return values
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _scalar(value: str) -> Any:
|
|
410
|
+
value = value.strip().strip("\"'")
|
|
411
|
+
if value.startswith("[") and value.endswith("]"):
|
|
412
|
+
inner = value[1:-1].strip()
|
|
413
|
+
return [] if not inner else [_scalar(item) for item in inner.split(",")]
|
|
414
|
+
if value == "true":
|
|
415
|
+
return True
|
|
416
|
+
if value == "false":
|
|
417
|
+
return False
|
|
418
|
+
return value
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _string_tuple(value: Any) -> tuple[str, ...]:
|
|
422
|
+
if not isinstance(value, list):
|
|
423
|
+
return ()
|
|
424
|
+
return tuple(str(item) for item in value)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Provider-neutral deferred tool exposure.
|
|
2
|
+
|
|
3
|
+
The model initially receives complete schemas only for ordinary tools and ``tool_search``. Deferred
|
|
4
|
+
tools are announced by name in that tool's description; selecting/searching one records a compact
|
|
5
|
+
activation marker in the transcript, from which a fresh wrapper can reconstruct the active set.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import builtins
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from inspect import isawaitable
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from langchain_core.messages import BaseMessage, ToolMessage
|
|
18
|
+
from semora.contracts import DynamicTools, Tools
|
|
19
|
+
|
|
20
|
+
__all__ = ["DeferredTools"]
|
|
21
|
+
|
|
22
|
+
_TOOL_NAME = "tool_search"
|
|
23
|
+
_ACTIVATED = re.compile(r"<activated_tools>(.*?)</activated_tools>", re.DOTALL)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DeferredTools:
|
|
27
|
+
"""Hide selected tool schemas until ``tool_search`` activates them."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
inner: Tools,
|
|
32
|
+
*,
|
|
33
|
+
deferred: set[str] | None = None,
|
|
34
|
+
initially_active: set[str] | None = None,
|
|
35
|
+
_discovered: set[str] | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Configure explicit deferrals and session-local activation state."""
|
|
38
|
+
if any(definition.get("name") == _TOOL_NAME for definition in inner.list()):
|
|
39
|
+
raise ValueError(f"the wrapped tool collection already defines {_TOOL_NAME!r}")
|
|
40
|
+
self._inner = inner
|
|
41
|
+
self._explicit = frozenset(deferred or ())
|
|
42
|
+
self._initially_active = frozenset(initially_active or ())
|
|
43
|
+
self._discovered = _discovered if _discovered is not None else set()
|
|
44
|
+
|
|
45
|
+
async def execute(self, name: str, call_id: str, arguments: Any) -> dict[str, Any]:
|
|
46
|
+
"""Search for a schema or execute an already exposed tool."""
|
|
47
|
+
if name == _TOOL_NAME:
|
|
48
|
+
return self._search(arguments)
|
|
49
|
+
definition = self._definition(name)
|
|
50
|
+
if definition is None:
|
|
51
|
+
return {"type": "error", "message": f"tool is not available: {name}"}
|
|
52
|
+
if self._is_deferred(definition) and name not in self._active_names():
|
|
53
|
+
return {
|
|
54
|
+
"type": "error",
|
|
55
|
+
"message": f"tool {name!r} is deferred; load it with {_TOOL_NAME} first",
|
|
56
|
+
}
|
|
57
|
+
return await self._inner.execute(name, call_id, arguments)
|
|
58
|
+
|
|
59
|
+
def get(self, name: str) -> dict[str, Any] | None:
|
|
60
|
+
"""Return a definition only when the model is allowed to call it."""
|
|
61
|
+
if name == _TOOL_NAME:
|
|
62
|
+
return self._search_definition() if self._deferred_definitions() else None
|
|
63
|
+
definition = self._definition(name)
|
|
64
|
+
if definition is None:
|
|
65
|
+
return None
|
|
66
|
+
if self._is_deferred(definition) and name not in self._active_names():
|
|
67
|
+
return None
|
|
68
|
+
return definition
|
|
69
|
+
|
|
70
|
+
def list(self) -> builtins.list[dict[str, Any]]:
|
|
71
|
+
"""List non-deferred and previously discovered schemas."""
|
|
72
|
+
active = self._active_names()
|
|
73
|
+
definitions = [
|
|
74
|
+
definition
|
|
75
|
+
for definition in self._inner.list()
|
|
76
|
+
if not self._is_deferred(definition) or str(definition.get("name")) in active
|
|
77
|
+
]
|
|
78
|
+
if self._deferred_definitions():
|
|
79
|
+
definitions.append(self._search_definition())
|
|
80
|
+
return definitions
|
|
81
|
+
|
|
82
|
+
async def prepare(self, messages: builtins.list[BaseMessage]) -> None:
|
|
83
|
+
"""Recover activated names from durable tool results in the transcript."""
|
|
84
|
+
if isinstance(self._inner, DynamicTools):
|
|
85
|
+
prepared = self._inner.prepare(messages)
|
|
86
|
+
if isawaitable(prepared):
|
|
87
|
+
await prepared
|
|
88
|
+
available = {str(definition.get("name")) for definition in self._inner.list()}
|
|
89
|
+
for message in messages:
|
|
90
|
+
if not isinstance(message, ToolMessage) or not isinstance(message.content, str):
|
|
91
|
+
continue
|
|
92
|
+
for match in _ACTIVATED.finditer(message.content):
|
|
93
|
+
try:
|
|
94
|
+
names = json.loads(match.group(1))
|
|
95
|
+
except json.JSONDecodeError:
|
|
96
|
+
continue
|
|
97
|
+
if isinstance(names, list):
|
|
98
|
+
self._discovered.update(
|
|
99
|
+
name for name in names if isinstance(name, str) and name in available
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def reset(self) -> None:
|
|
103
|
+
"""Clear session-local discoveries while retaining explicit initial tools."""
|
|
104
|
+
self._discovered.clear()
|
|
105
|
+
|
|
106
|
+
def get_context(self) -> Any:
|
|
107
|
+
"""Forward the workspace context when the wrapped collection supports it."""
|
|
108
|
+
get_context = getattr(self._inner, "get_context", None)
|
|
109
|
+
if get_context is None:
|
|
110
|
+
raise TypeError("wrapped tools do not expose a workspace context")
|
|
111
|
+
return get_context()
|
|
112
|
+
|
|
113
|
+
def with_context(self, context: Any) -> DeferredTools:
|
|
114
|
+
"""Rebind the wrapped tools and retain this conversation's discoveries."""
|
|
115
|
+
with_context = getattr(self._inner, "with_context", None)
|
|
116
|
+
if with_context is None:
|
|
117
|
+
raise TypeError("wrapped tools cannot be rebound to a workspace context")
|
|
118
|
+
return DeferredTools(
|
|
119
|
+
with_context(context),
|
|
120
|
+
deferred=set(self._explicit),
|
|
121
|
+
initially_active=set(self._initially_active),
|
|
122
|
+
_discovered=self._discovered,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def _search(self, arguments: Any) -> dict[str, Any]:
|
|
126
|
+
if not isinstance(arguments, Mapping) or not isinstance(arguments.get("query"), str):
|
|
127
|
+
return {"type": "error", "message": "tool_search requires a string 'query'"}
|
|
128
|
+
query = str(arguments["query"]).strip()
|
|
129
|
+
raw_limit = arguments.get("max_results", 5)
|
|
130
|
+
if not isinstance(raw_limit, int) or isinstance(raw_limit, bool):
|
|
131
|
+
return {"type": "error", "message": "tool_search 'max_results' must be an integer"}
|
|
132
|
+
maximum = min(max(raw_limit, 1), 20)
|
|
133
|
+
matches = self._matches(query, maximum)
|
|
134
|
+
self._discovered.update(matches)
|
|
135
|
+
encoded = json.dumps(matches, separators=(",", ":"), ensure_ascii=False)
|
|
136
|
+
visible = (
|
|
137
|
+
"Activated deferred tools:\n" + "\n".join(f"- {name}" for name in matches)
|
|
138
|
+
if matches
|
|
139
|
+
else "No matching deferred tools found."
|
|
140
|
+
)
|
|
141
|
+
return {"type": "text", "text": f"{visible}\n<activated_tools>{encoded}</activated_tools>"}
|
|
142
|
+
|
|
143
|
+
def _matches(self, query: str, maximum: int) -> builtins.list[str]:
|
|
144
|
+
definitions = self._deferred_definitions()
|
|
145
|
+
by_name = {str(definition.get("name")): definition for definition in definitions}
|
|
146
|
+
if query.lower().startswith("select:"):
|
|
147
|
+
requested = [item.strip() for item in query[7:].split(",") if item.strip()]
|
|
148
|
+
lowered = {name.lower(): name for name in by_name}
|
|
149
|
+
selected = dict.fromkeys(
|
|
150
|
+
lowered[item.lower()] for item in requested if item.lower() in lowered
|
|
151
|
+
)
|
|
152
|
+
return list(selected)[:maximum]
|
|
153
|
+
|
|
154
|
+
terms = [term.lower() for term in query.split() if term]
|
|
155
|
+
required = [term[1:] for term in terms if term.startswith("+") and len(term) > 1]
|
|
156
|
+
optional = [term for term in terms if not term.startswith("+")]
|
|
157
|
+
scored: builtins.list[tuple[int, str]] = []
|
|
158
|
+
for name, definition in by_name.items():
|
|
159
|
+
haystack = " ".join(
|
|
160
|
+
(
|
|
161
|
+
name,
|
|
162
|
+
str(definition.get("description", "")),
|
|
163
|
+
str(definition.get("search_hint", "")),
|
|
164
|
+
)
|
|
165
|
+
).lower()
|
|
166
|
+
if required and not all(term in haystack for term in required):
|
|
167
|
+
continue
|
|
168
|
+
score = sum(
|
|
169
|
+
10 if term in name.lower() else 2
|
|
170
|
+
for term in [*required, *optional]
|
|
171
|
+
if term in haystack
|
|
172
|
+
)
|
|
173
|
+
if score:
|
|
174
|
+
scored.append((score, name))
|
|
175
|
+
ranked = sorted(scored, key=lambda item: (-item[0], item[1]))[:maximum]
|
|
176
|
+
return [name for _score, name in ranked]
|
|
177
|
+
|
|
178
|
+
def _active_names(self) -> set[str]:
|
|
179
|
+
return set(self._initially_active) | self._discovered
|
|
180
|
+
|
|
181
|
+
def _definition(self, name: str) -> dict[str, Any] | None:
|
|
182
|
+
return next(
|
|
183
|
+
(definition for definition in self._inner.list() if definition.get("name") == name),
|
|
184
|
+
None,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def _deferred_definitions(self) -> builtins.list[dict[str, Any]]:
|
|
188
|
+
return [definition for definition in self._inner.list() if self._is_deferred(definition)]
|
|
189
|
+
|
|
190
|
+
def _is_deferred(self, definition: dict[str, Any]) -> bool:
|
|
191
|
+
if definition.get("always_load") is True:
|
|
192
|
+
return False
|
|
193
|
+
name = str(definition.get("name", ""))
|
|
194
|
+
return bool(
|
|
195
|
+
name in self._explicit
|
|
196
|
+
or name.startswith("mcp__")
|
|
197
|
+
or definition.get("is_mcp") is True
|
|
198
|
+
or definition.get("should_defer") is True
|
|
199
|
+
or definition.get("defer_loading") is True
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def _search_definition(self) -> dict[str, Any]:
|
|
203
|
+
names = sorted(str(definition.get("name")) for definition in self._deferred_definitions())
|
|
204
|
+
return {
|
|
205
|
+
"name": _TOOL_NAME,
|
|
206
|
+
"description": (
|
|
207
|
+
"Load complete schemas for deferred tools. Until selected, only these names are "
|
|
208
|
+
"known and the tools cannot be called. Use 'select:<name>' for exact selection "
|
|
209
|
+
"or capability keywords to search.\n\n<available_deferred_tools>\n"
|
|
210
|
+
+ "\n".join(names)
|
|
211
|
+
+ "\n</available_deferred_tools>"
|
|
212
|
+
),
|
|
213
|
+
"parameters": {
|
|
214
|
+
"type": "object",
|
|
215
|
+
"properties": {
|
|
216
|
+
"query": {"type": "string"},
|
|
217
|
+
"max_results": {"type": "integer", "minimum": 1, "maximum": 20},
|
|
218
|
+
},
|
|
219
|
+
"required": ["query"],
|
|
220
|
+
"additionalProperties": False,
|
|
221
|
+
},
|
|
222
|
+
"is_exclusive": True,
|
|
223
|
+
"is_concurrency_safe": True,
|
|
224
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: semora-coding
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A coding agent's toolset, prompts and policies, assembled over the semora core.
|
|
5
|
+
Project-URL: Homepage, https://github.com/donggyun112/semora
|
|
6
|
+
Project-URL: Source, https://github.com/donggyun112/semora
|
|
7
|
+
Project-URL: Changelog, https://github.com/donggyun112/semora/blob/main/CHANGELOG.md
|
|
8
|
+
Author: donggyun112
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Requires-Dist: langchain-core<2,>=1
|
|
17
|
+
Requires-Dist: semora==0.2.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# semora-coding
|
|
21
|
+
|
|
22
|
+
The coding-agent layer over the `semora` core: built-in tools (`read`, `write`, `edit`, `grep`,
|
|
23
|
+
`glob`, `Bash`, `web_fetch`), system-prompt assembly, plan mode, goals, the skill catalog and
|
|
24
|
+
deferred tool search.
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
uv add "semora[coding]"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
It is a reference assembly. The core promises that a tool's effect happens once and that every
|
|
31
|
+
decision about it has a seam; this package is one answer to what those tools and seams say to a
|
|
32
|
+
model. Replace any of it — the core does not know it is here.
|