soothe-plugins 0.2.6__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.
- soothe_plugins/.plugin_template/PLUGIN_TEMPLATE.md +40 -0
- soothe_plugins/.plugin_template/README.md.template +152 -0
- soothe_plugins/.plugin_template/__init__.py.template +174 -0
- soothe_plugins/.plugin_template/events.py.template +34 -0
- soothe_plugins/.plugin_template/implementation.py.template +112 -0
- soothe_plugins/.plugin_template/models.py.template +39 -0
- soothe_plugins/.plugin_template/state.py.template +48 -0
- soothe_plugins/README.md +150 -0
- soothe_plugins/__init__.py +20 -0
- soothe_plugins/_paths.py +17 -0
- soothe_plugins/sample_echo/__init__.py +44 -0
- soothe_plugins/sample_echo/implementation.py +47 -0
- soothe_plugins/skillify/__init__.py +288 -0
- soothe_plugins/skillify/events.py +148 -0
- soothe_plugins/skillify/indexer.py +312 -0
- soothe_plugins/skillify/models.py +36 -0
- soothe_plugins/skillify/retriever.py +165 -0
- soothe_plugins/skillify/warehouse.py +96 -0
- soothe_plugins/weaver/__init__.py +507 -0
- soothe_plugins/weaver/analyzer.py +81 -0
- soothe_plugins/weaver/composer.py +322 -0
- soothe_plugins/weaver/events.py +223 -0
- soothe_plugins/weaver/generator.py +177 -0
- soothe_plugins/weaver/models.py +136 -0
- soothe_plugins/weaver/registry.py +214 -0
- soothe_plugins/weaver/reuse.py +151 -0
- soothe_plugins-0.2.6.dist-info/METADATA +156 -0
- soothe_plugins-0.2.6.dist-info/RECORD +30 -0
- soothe_plugins-0.2.6.dist-info/WHEEL +4 -0
- soothe_plugins-0.2.6.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""SkillWarehouse -- scan directories, parse SKILL.md, compute hashes (RFC-0004)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .models import SkillRecord
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SkillWarehouse:
|
|
19
|
+
"""Scans configured directories for deepagents-compatible skill packages."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, paths: list[str]) -> None:
|
|
22
|
+
"""Initialize the skill warehouse.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
paths: List of directory paths to scan for skill packages.
|
|
26
|
+
"""
|
|
27
|
+
self._paths = [Path(p).expanduser().resolve() for p in paths]
|
|
28
|
+
|
|
29
|
+
def scan(self) -> list[SkillRecord]:
|
|
30
|
+
"""Scan all warehouse paths and return skill records."""
|
|
31
|
+
records: list[SkillRecord] = []
|
|
32
|
+
for base in self._paths:
|
|
33
|
+
if not base.is_dir():
|
|
34
|
+
logger.debug("Warehouse path does not exist: %s", base)
|
|
35
|
+
continue
|
|
36
|
+
for skill_md in base.rglob("SKILL.md"):
|
|
37
|
+
try:
|
|
38
|
+
record = self._parse_skill(skill_md)
|
|
39
|
+
records.append(record)
|
|
40
|
+
except Exception:
|
|
41
|
+
logger.warning("Failed to parse %s", skill_md, exc_info=True)
|
|
42
|
+
return records
|
|
43
|
+
|
|
44
|
+
def _parse_skill(self, skill_md: Path) -> SkillRecord:
|
|
45
|
+
"""Parse a single SKILL.md into a SkillRecord."""
|
|
46
|
+
content = skill_md.read_text(encoding="utf-8")
|
|
47
|
+
frontmatter, _body = self.parse_skill_md(content)
|
|
48
|
+
|
|
49
|
+
name = frontmatter.get("name", skill_md.parent.name)
|
|
50
|
+
description = frontmatter.get("description", "")
|
|
51
|
+
if isinstance(description, str):
|
|
52
|
+
description = description.strip()
|
|
53
|
+
tags_raw = frontmatter.get("tags", [])
|
|
54
|
+
tags = tags_raw if isinstance(tags_raw, list) else []
|
|
55
|
+
|
|
56
|
+
skill_dir = str(skill_md.parent.resolve())
|
|
57
|
+
skill_id = self.path_id(skill_dir)
|
|
58
|
+
chash = self.content_hash(content)
|
|
59
|
+
|
|
60
|
+
return SkillRecord(
|
|
61
|
+
id=skill_id,
|
|
62
|
+
name=str(name),
|
|
63
|
+
description=str(description),
|
|
64
|
+
path=skill_dir,
|
|
65
|
+
tags=[str(t) for t in tags],
|
|
66
|
+
status="indexed",
|
|
67
|
+
indexed_at=datetime.now(UTC),
|
|
68
|
+
content_hash=chash,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def parse_skill_md(content: str) -> tuple[dict, str]:
|
|
73
|
+
"""Parse SKILL.md content into (frontmatter_dict, body_text)."""
|
|
74
|
+
match = _FRONTMATTER_RE.match(content)
|
|
75
|
+
if not match:
|
|
76
|
+
return {}, content
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
import yaml
|
|
80
|
+
|
|
81
|
+
fm = yaml.safe_load(match.group(1)) or {}
|
|
82
|
+
except Exception:
|
|
83
|
+
fm = {}
|
|
84
|
+
|
|
85
|
+
body = content[match.end() :]
|
|
86
|
+
return fm, body
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def content_hash(content: str) -> str:
|
|
90
|
+
"""Compute SHA-256 hex digest of content."""
|
|
91
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def path_id(path: str) -> str:
|
|
95
|
+
"""Compute a deterministic ID from an absolute path."""
|
|
96
|
+
return hashlib.sha256(path.encode("utf-8")).hexdigest()[:16]
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
"""Weaver subagent plugin -- generative agent framework with skill harmonization (RFC-0005).
|
|
2
|
+
|
|
3
|
+
Community plugin for Soothe that composes skills from Skillify, resolves
|
|
4
|
+
conflicts/overlaps/gaps, and generates Soothe-compatible SubAgent packages
|
|
5
|
+
that can be loaded dynamically at startup or executed inline during the session.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Annotated, Any, TypedDict
|
|
15
|
+
|
|
16
|
+
from langchain_core.messages import AIMessage
|
|
17
|
+
from langgraph.graph import END, START, StateGraph
|
|
18
|
+
from langgraph.graph.message import add_messages
|
|
19
|
+
from soothe_sdk.core.exceptions import PluginError
|
|
20
|
+
from soothe_sdk.plugin import plugin, subagent
|
|
21
|
+
from soothe_sdk.protocols import ActionRequest, PermissionSet, PolicyContext
|
|
22
|
+
|
|
23
|
+
from .analyzer import RequirementAnalyzer
|
|
24
|
+
from .composer import AgentComposer
|
|
25
|
+
from .events import (
|
|
26
|
+
WeaverAnalysisCompletedEvent,
|
|
27
|
+
WeaverAnalysisStartedEvent,
|
|
28
|
+
WeaverCompletedEvent,
|
|
29
|
+
WeaverDispatchedEvent,
|
|
30
|
+
WeaverExecuteCompletedEvent,
|
|
31
|
+
WeaverExecuteStartedEvent,
|
|
32
|
+
WeaverGenerateCompletedEvent,
|
|
33
|
+
WeaverGenerateStartedEvent,
|
|
34
|
+
WeaverHarmonizeCompletedEvent,
|
|
35
|
+
WeaverHarmonizeStartedEvent,
|
|
36
|
+
WeaverRegistryUpdatedEvent,
|
|
37
|
+
WeaverReuseHitEvent,
|
|
38
|
+
WeaverReuseMissEvent,
|
|
39
|
+
WeaverSkillifyPendingEvent,
|
|
40
|
+
WeaverValidateCompletedEvent,
|
|
41
|
+
WeaverValidateStartedEvent,
|
|
42
|
+
)
|
|
43
|
+
from .generator import AgentGenerator
|
|
44
|
+
from .registry import GeneratedAgentRegistry
|
|
45
|
+
from .reuse import ReuseIndex
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from deepagents.middleware.subagents import CompiledSubAgent
|
|
49
|
+
from langchain_core.language_models import BaseChatModel
|
|
50
|
+
|
|
51
|
+
from .models import (
|
|
52
|
+
AgentManifest,
|
|
53
|
+
CapabilitySignature,
|
|
54
|
+
ReuseCandidate,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
logger = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
_MIN_CHUNK_TUPLE_LENGTH = 2
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _emit_event(event_dict: dict[str, Any], ctx_logger: logging.Logger) -> None:
|
|
63
|
+
"""Emit progress event via logger or event emission.
|
|
64
|
+
|
|
65
|
+
For community plugins, we use logger.info() for visibility.
|
|
66
|
+
Daemon may intercept and convert to progress events.
|
|
67
|
+
"""
|
|
68
|
+
event_type = event_dict.get("type", "unknown")
|
|
69
|
+
ctx_logger.info(f"[{event_type}] {event_dict}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
WEAVER_DESCRIPTION = (
|
|
73
|
+
"Generative agent framework that creates task-specific subagents on the fly. "
|
|
74
|
+
"Given a task that existing subagents cannot handle, Weaver analyses requirements, "
|
|
75
|
+
"fetches relevant skills, resolves conflicts between skills from different sources, "
|
|
76
|
+
"generates a new specialist agent, and executes it. Use when no existing subagent "
|
|
77
|
+
"fits the user's specialized task."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class WeaverState(TypedDict):
|
|
82
|
+
"""State for the Weaver LangGraph."""
|
|
83
|
+
|
|
84
|
+
messages: Annotated[list[Any], add_messages]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _build_weaver_graph(
|
|
88
|
+
analyzer: RequirementAnalyzer,
|
|
89
|
+
reuse_index: ReuseIndex,
|
|
90
|
+
composer: AgentComposer,
|
|
91
|
+
generator: AgentGenerator,
|
|
92
|
+
registry: GeneratedAgentRegistry,
|
|
93
|
+
skillify_retriever: Any | None,
|
|
94
|
+
model: BaseChatModel,
|
|
95
|
+
policy: Any | None = None,
|
|
96
|
+
policy_profile: str = "standard",
|
|
97
|
+
) -> Any:
|
|
98
|
+
"""Build and compile the Weaver LangGraph."""
|
|
99
|
+
|
|
100
|
+
def _check_policy(action: str, tool_name: str, tool_args: dict[str, Any] | None = None) -> None:
|
|
101
|
+
if policy is None:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
permissions = PermissionSet(frozenset())
|
|
105
|
+
get_profile = getattr(policy, "get_profile", None)
|
|
106
|
+
if callable(get_profile):
|
|
107
|
+
profile = get_profile(policy_profile)
|
|
108
|
+
if profile is not None:
|
|
109
|
+
permissions = profile.permissions
|
|
110
|
+
|
|
111
|
+
decision = policy.check(
|
|
112
|
+
ActionRequest(action_type=action, tool_name=tool_name, tool_args=tool_args or {}),
|
|
113
|
+
PolicyContext(active_permissions=permissions, thread_id=None),
|
|
114
|
+
)
|
|
115
|
+
if decision.verdict == "deny":
|
|
116
|
+
msg = f"Policy denied {action}:{tool_name} - {decision.reason}"
|
|
117
|
+
raise ValueError(msg)
|
|
118
|
+
|
|
119
|
+
async def _validate_package(
|
|
120
|
+
manifest: AgentManifest,
|
|
121
|
+
output_dir: Path,
|
|
122
|
+
capability: CapabilitySignature,
|
|
123
|
+
) -> None:
|
|
124
|
+
if not manifest.name.strip():
|
|
125
|
+
msg = "Generated manifest has empty name"
|
|
126
|
+
raise ValueError(msg)
|
|
127
|
+
if not manifest.system_prompt_file.strip():
|
|
128
|
+
msg = "Generated manifest has empty system_prompt_file"
|
|
129
|
+
raise ValueError(msg)
|
|
130
|
+
prompt_path = output_dir / manifest.system_prompt_file
|
|
131
|
+
if not prompt_path.is_file():
|
|
132
|
+
msg = "Generated package missing system prompt file"
|
|
133
|
+
raise ValueError(msg)
|
|
134
|
+
prompt_text = prompt_path.read_text(encoding="utf-8").strip()
|
|
135
|
+
if not prompt_text:
|
|
136
|
+
msg = "Generated system prompt is empty"
|
|
137
|
+
raise ValueError(msg)
|
|
138
|
+
|
|
139
|
+
for tool in manifest.tools:
|
|
140
|
+
_check_policy(action="tool_call", tool_name=tool, tool_args={"path": "*"})
|
|
141
|
+
_check_policy(action="subagent_spawn", tool_name=manifest.name, tool_args={"goal": capability.description})
|
|
142
|
+
|
|
143
|
+
async def _analyze_and_route(state: dict[str, Any]) -> dict[str, Any]:
|
|
144
|
+
messages = state.get("messages", [])
|
|
145
|
+
task_text = ""
|
|
146
|
+
for msg in reversed(messages):
|
|
147
|
+
if hasattr(msg, "type") and msg.type == "human":
|
|
148
|
+
task_text = msg.content if hasattr(msg, "content") else str(msg)
|
|
149
|
+
break
|
|
150
|
+
if not task_text and messages:
|
|
151
|
+
last = messages[-1]
|
|
152
|
+
task_text = last.content if hasattr(last, "content") else str(last)
|
|
153
|
+
|
|
154
|
+
_emit_event(WeaverDispatchedEvent(task=task_text[:200]).to_dict(), logger)
|
|
155
|
+
|
|
156
|
+
_emit_event(WeaverAnalysisStartedEvent(task_preview=task_text[:200]).to_dict(), logger)
|
|
157
|
+
capability = await analyzer.analyze(task_text)
|
|
158
|
+
_emit_event(
|
|
159
|
+
WeaverAnalysisCompletedEvent(
|
|
160
|
+
capabilities=capability.required_capabilities,
|
|
161
|
+
constraints=capability.constraints,
|
|
162
|
+
).to_dict(),
|
|
163
|
+
logger,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
reuse_candidate = await reuse_index.find_reusable(capability)
|
|
167
|
+
|
|
168
|
+
if reuse_candidate:
|
|
169
|
+
_emit_event(
|
|
170
|
+
WeaverReuseHitEvent(
|
|
171
|
+
agent_name=reuse_candidate.manifest.name,
|
|
172
|
+
confidence=round(reuse_candidate.confidence, 3),
|
|
173
|
+
).to_dict(),
|
|
174
|
+
logger,
|
|
175
|
+
)
|
|
176
|
+
return await _execute_existing(reuse_candidate, task_text)
|
|
177
|
+
|
|
178
|
+
best_conf = 0.0
|
|
179
|
+
_emit_event(WeaverReuseMissEvent(best_confidence=round(best_conf, 3)).to_dict(), logger)
|
|
180
|
+
|
|
181
|
+
# Fetch skills (with indexing-not-ready tolerance)
|
|
182
|
+
from soothe_plugins.skillify.models import SkillBundle
|
|
183
|
+
|
|
184
|
+
skill_bundle = SkillBundle(query=capability.description)
|
|
185
|
+
if skillify_retriever:
|
|
186
|
+
if hasattr(skillify_retriever, "is_ready") and not skillify_retriever.is_ready:
|
|
187
|
+
_emit_event(WeaverSkillifyPendingEvent().to_dict(), logger)
|
|
188
|
+
ready_event = getattr(skillify_retriever, "_ready_event", None)
|
|
189
|
+
if ready_event is not None:
|
|
190
|
+
try:
|
|
191
|
+
await asyncio.wait_for(ready_event.wait(), timeout=30.0)
|
|
192
|
+
except TimeoutError:
|
|
193
|
+
logger.warning("Skillify index not ready after 30s, proceeding best-effort")
|
|
194
|
+
try:
|
|
195
|
+
skill_bundle = await skillify_retriever.retrieve(capability.description)
|
|
196
|
+
if skill_bundle.query.startswith("[Indexing in progress]"):
|
|
197
|
+
logger.warning("Skillify still indexing; Weaver proceeding with empty skills")
|
|
198
|
+
skill_bundle = SkillBundle(query=capability.description)
|
|
199
|
+
except Exception:
|
|
200
|
+
logger.warning("Skillify retrieval failed", exc_info=True)
|
|
201
|
+
|
|
202
|
+
_emit_event(
|
|
203
|
+
WeaverHarmonizeStartedEvent(
|
|
204
|
+
skill_count=len(skill_bundle.results),
|
|
205
|
+
).to_dict(),
|
|
206
|
+
logger,
|
|
207
|
+
)
|
|
208
|
+
blueprint = await composer.compose(capability, skill_bundle)
|
|
209
|
+
_emit_event(
|
|
210
|
+
WeaverHarmonizeCompletedEvent(
|
|
211
|
+
retained=len(blueprint.harmonized.skills),
|
|
212
|
+
dropped=len(blueprint.harmonized.dropped_skills),
|
|
213
|
+
bridge_length=len(blueprint.harmonized.bridge_instructions),
|
|
214
|
+
).to_dict(),
|
|
215
|
+
logger,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
_check_policy(action="subagent_spawn", tool_name="weaver.generate", tool_args={"goal": capability.description})
|
|
219
|
+
_emit_event(WeaverGenerateStartedEvent(agent_name=blueprint.agent_name).to_dict(), logger)
|
|
220
|
+
output_dir = registry.base_dir / blueprint.agent_name
|
|
221
|
+
manifest = await generator.generate(blueprint, output_dir)
|
|
222
|
+
_emit_event(
|
|
223
|
+
WeaverGenerateCompletedEvent(
|
|
224
|
+
agent_name=manifest.name,
|
|
225
|
+
path=str(output_dir),
|
|
226
|
+
).to_dict(),
|
|
227
|
+
logger,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
_emit_event(WeaverValidateStartedEvent(agent_name=manifest.name).to_dict(), logger)
|
|
231
|
+
await _validate_package(manifest, output_dir, capability)
|
|
232
|
+
_emit_event(WeaverValidateCompletedEvent(agent_name=manifest.name).to_dict(), logger)
|
|
233
|
+
|
|
234
|
+
_check_policy(action="subagent_spawn", tool_name="weaver.register", tool_args={"agent_name": manifest.name})
|
|
235
|
+
registry.register(manifest, output_dir)
|
|
236
|
+
await reuse_index.index_agent(manifest, str(output_dir))
|
|
237
|
+
_emit_event(
|
|
238
|
+
WeaverRegistryUpdatedEvent(
|
|
239
|
+
agent_name=manifest.name,
|
|
240
|
+
version=manifest.version,
|
|
241
|
+
).to_dict(),
|
|
242
|
+
logger,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
return await _execute_generated(manifest, output_dir, task_text, model)
|
|
246
|
+
|
|
247
|
+
async def _execute_existing(candidate: ReuseCandidate, task: str) -> dict[str, Any]:
|
|
248
|
+
agent_dir = Path(candidate.path)
|
|
249
|
+
return await _execute_generated(candidate.manifest, agent_dir, task, model)
|
|
250
|
+
|
|
251
|
+
async def _execute_generated(
|
|
252
|
+
manifest: AgentManifest,
|
|
253
|
+
agent_dir: Path,
|
|
254
|
+
task: str,
|
|
255
|
+
llm: BaseChatModel,
|
|
256
|
+
) -> dict[str, Any]:
|
|
257
|
+
_emit_event(
|
|
258
|
+
WeaverExecuteStartedEvent(
|
|
259
|
+
agent_name=manifest.name,
|
|
260
|
+
task_preview=task[:200],
|
|
261
|
+
).to_dict(),
|
|
262
|
+
logger,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
prompt_path = agent_dir / manifest.system_prompt_file
|
|
266
|
+
system_prompt = ""
|
|
267
|
+
if prompt_path.is_file():
|
|
268
|
+
system_prompt = prompt_path.read_text(encoding="utf-8")
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
from deepagents import create_deep_agent
|
|
272
|
+
from langchain_core.messages import HumanMessage
|
|
273
|
+
|
|
274
|
+
agent = create_deep_agent(
|
|
275
|
+
model=llm,
|
|
276
|
+
system_prompt=system_prompt,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
result_text = ""
|
|
280
|
+
async for chunk in agent.astream(
|
|
281
|
+
{"messages": [HumanMessage(content=task)]},
|
|
282
|
+
stream_mode=["messages"],
|
|
283
|
+
):
|
|
284
|
+
if isinstance(chunk, tuple) and len(chunk) >= _MIN_CHUNK_TUPLE_LENGTH:
|
|
285
|
+
_, data = chunk[0] if len(chunk) == 1 else (chunk[0], chunk[1])
|
|
286
|
+
if isinstance(data, tuple) and len(data) >= 1:
|
|
287
|
+
msg = data[0]
|
|
288
|
+
if hasattr(msg, "content") and isinstance(msg.content, str):
|
|
289
|
+
result_text += msg.content
|
|
290
|
+
|
|
291
|
+
if not result_text:
|
|
292
|
+
result = await agent.ainvoke({"messages": [HumanMessage(content=task)]})
|
|
293
|
+
result_chunks = [
|
|
294
|
+
str(msg.content)
|
|
295
|
+
for msg in result.get("messages", [])
|
|
296
|
+
if hasattr(msg, "content") and hasattr(msg, "type") and msg.type == "ai"
|
|
297
|
+
]
|
|
298
|
+
result_text = "\n".join(result_chunks) or "Agent completed but produced no output."
|
|
299
|
+
|
|
300
|
+
except Exception:
|
|
301
|
+
logger.exception("Generated agent execution failed")
|
|
302
|
+
result_text = f"Generated agent '{manifest.name}' encountered an error during execution."
|
|
303
|
+
|
|
304
|
+
_emit_event(
|
|
305
|
+
WeaverExecuteCompletedEvent(
|
|
306
|
+
agent_name=manifest.name,
|
|
307
|
+
result_length=len(result_text),
|
|
308
|
+
).to_dict(),
|
|
309
|
+
logger,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
_emit_event(
|
|
313
|
+
WeaverCompletedEvent(
|
|
314
|
+
duration_ms=0,
|
|
315
|
+
agent_name=manifest.name,
|
|
316
|
+
).to_dict(),
|
|
317
|
+
logger,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return {"messages": [AIMessage(content=result_text)]}
|
|
321
|
+
|
|
322
|
+
def run_sync(state: dict[str, Any]) -> dict[str, Any]:
|
|
323
|
+
try:
|
|
324
|
+
loop = asyncio.get_event_loop()
|
|
325
|
+
except RuntimeError:
|
|
326
|
+
loop = asyncio.new_event_loop()
|
|
327
|
+
asyncio.set_event_loop(loop)
|
|
328
|
+
|
|
329
|
+
if loop.is_running():
|
|
330
|
+
new_loop = asyncio.new_event_loop()
|
|
331
|
+
try:
|
|
332
|
+
return new_loop.run_until_complete(_analyze_and_route(state))
|
|
333
|
+
finally:
|
|
334
|
+
new_loop.close()
|
|
335
|
+
else:
|
|
336
|
+
return loop.run_until_complete(_analyze_and_route(state))
|
|
337
|
+
|
|
338
|
+
graph = StateGraph(WeaverState)
|
|
339
|
+
graph.add_node("weave", run_sync)
|
|
340
|
+
graph.add_edge(START, "weave")
|
|
341
|
+
graph.add_edge("weave", END)
|
|
342
|
+
return graph.compile()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _resolve_dependencies(cfg: Any, _collection: str) -> tuple[Any, Any]:
|
|
346
|
+
"""Resolve VectorStore and Embeddings for the reuse index."""
|
|
347
|
+
vs = cfg.create_vector_store_for_role("weaver_reuse")
|
|
348
|
+
embeddings = cfg.create_embedding_model()
|
|
349
|
+
return vs, embeddings
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@plugin(
|
|
353
|
+
name="weaver",
|
|
354
|
+
version="1.0.0",
|
|
355
|
+
description="Generative agent framework with skill harmonization",
|
|
356
|
+
dependencies=[
|
|
357
|
+
"langgraph>=0.2.0",
|
|
358
|
+
],
|
|
359
|
+
trust_level="standard",
|
|
360
|
+
)
|
|
361
|
+
class WeaverPlugin:
|
|
362
|
+
"""Weaver community plugin for generative agent creation."""
|
|
363
|
+
|
|
364
|
+
def __init__(self) -> None:
|
|
365
|
+
self._reuse_index: ReuseIndex | None = None
|
|
366
|
+
|
|
367
|
+
async def on_load(self, context: Any) -> None:
|
|
368
|
+
"""Verify Skillify is available and trigger event registration."""
|
|
369
|
+
import soothe_plugins.weaver.events # noqa: F401
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
from soothe_plugins.skillify.models import SkillBundle # noqa: F401
|
|
373
|
+
|
|
374
|
+
context.logger.info("Weaver plugin loaded (Skillify available)")
|
|
375
|
+
except ImportError:
|
|
376
|
+
raise PluginError(
|
|
377
|
+
"Weaver requires Skillify plugin. Install soothe-plugins with skillify support.",
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
async def on_unload(self) -> None:
|
|
381
|
+
"""Close the reuse index."""
|
|
382
|
+
if self._reuse_index is not None:
|
|
383
|
+
try:
|
|
384
|
+
await self._reuse_index.close()
|
|
385
|
+
except Exception:
|
|
386
|
+
pass
|
|
387
|
+
self._reuse_index = None
|
|
388
|
+
|
|
389
|
+
@subagent(
|
|
390
|
+
name="weaver",
|
|
391
|
+
description=WEAVER_DESCRIPTION,
|
|
392
|
+
model="openai:gpt-4o-mini",
|
|
393
|
+
)
|
|
394
|
+
async def create_weaver(
|
|
395
|
+
self,
|
|
396
|
+
model: str | BaseChatModel | None,
|
|
397
|
+
config: Any,
|
|
398
|
+
context: Any,
|
|
399
|
+
**_kwargs: Any,
|
|
400
|
+
) -> CompiledSubAgent:
|
|
401
|
+
"""Create a Weaver subagent.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
model: LLM model for analysis, composition, and generation.
|
|
405
|
+
config: SootheConfig instance.
|
|
406
|
+
context: Plugin context with weaver-specific config.
|
|
407
|
+
**_kwargs: Additional config (ignored).
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
CompiledSubAgent dict.
|
|
411
|
+
"""
|
|
412
|
+
from langchain.chat_models import init_chat_model
|
|
413
|
+
|
|
414
|
+
soothe_cfg = context.soothe_config
|
|
415
|
+
plugin_cfg = context.config if hasattr(context, "config") and context.config is not None else {}
|
|
416
|
+
if not isinstance(plugin_cfg, dict):
|
|
417
|
+
plugin_cfg = {}
|
|
418
|
+
|
|
419
|
+
if model is None:
|
|
420
|
+
msg = "Weaver subagent requires a model."
|
|
421
|
+
raise ValueError(msg)
|
|
422
|
+
|
|
423
|
+
resolved_model: BaseChatModel
|
|
424
|
+
if isinstance(model, str):
|
|
425
|
+
provider_name = model.split(":", 1)[0]
|
|
426
|
+
provider_names = [p.name for p in soothe_cfg.providers] if soothe_cfg.providers else []
|
|
427
|
+
if provider_name in provider_names:
|
|
428
|
+
cache_key = model
|
|
429
|
+
if cache_key in soothe_cfg._model_cache:
|
|
430
|
+
resolved_model = soothe_cfg._model_cache[cache_key]
|
|
431
|
+
else:
|
|
432
|
+
_, _, model_name = model.partition(":")
|
|
433
|
+
provider_type, kwargs = soothe_cfg._provider_kwargs(provider_name)
|
|
434
|
+
init_str = f"{provider_type}:{model_name}" if provider_name else model
|
|
435
|
+
resolved_model = init_chat_model(init_str, **kwargs)
|
|
436
|
+
soothe_cfg._model_cache[cache_key] = resolved_model
|
|
437
|
+
else:
|
|
438
|
+
model_kwargs: dict[str, Any] = {}
|
|
439
|
+
base_url = os.environ.get("OPENAI_BASE_URL")
|
|
440
|
+
if base_url:
|
|
441
|
+
model_kwargs["base_url"] = base_url
|
|
442
|
+
model_kwargs["use_responses_api"] = False
|
|
443
|
+
resolved_model = init_chat_model(model, **model_kwargs)
|
|
444
|
+
else:
|
|
445
|
+
resolved_model = model
|
|
446
|
+
|
|
447
|
+
weaver_cfg = plugin_cfg.get("weaver") or {}
|
|
448
|
+
generated_agents_dir = weaver_cfg.get("generated_agents_dir") or str(
|
|
449
|
+
Path.home() / ".soothe" / "generated_agents"
|
|
450
|
+
)
|
|
451
|
+
reuse_threshold = float(weaver_cfg.get("reuse_threshold", 0.85))
|
|
452
|
+
reuse_collection = weaver_cfg.get("reuse_collection", "soothe_weaver_reuse")
|
|
453
|
+
allowed_tools = weaver_cfg.get("allowed_tool_groups", [])
|
|
454
|
+
|
|
455
|
+
vector_store, embeddings = _resolve_dependencies(soothe_cfg, reuse_collection)
|
|
456
|
+
|
|
457
|
+
embedding_dims = int(weaver_cfg.get("embedding_dims", plugin_cfg.get("embedding_dims", 1536)))
|
|
458
|
+
|
|
459
|
+
analyzer_inst = RequirementAnalyzer(model=resolved_model)
|
|
460
|
+
self._reuse_index = ReuseIndex(
|
|
461
|
+
vector_store=vector_store,
|
|
462
|
+
embeddings=embeddings,
|
|
463
|
+
threshold=reuse_threshold,
|
|
464
|
+
collection=reuse_collection,
|
|
465
|
+
embedding_dims=embedding_dims,
|
|
466
|
+
)
|
|
467
|
+
composer_inst = AgentComposer(
|
|
468
|
+
model=resolved_model,
|
|
469
|
+
allowed_tool_groups=allowed_tools,
|
|
470
|
+
)
|
|
471
|
+
generator_inst = AgentGenerator(model=resolved_model)
|
|
472
|
+
registry_inst = GeneratedAgentRegistry(base_dir=Path(generated_agents_dir))
|
|
473
|
+
|
|
474
|
+
# Try to create skillify retriever from plugin config
|
|
475
|
+
skillify_retriever = None
|
|
476
|
+
skillify_cfg = plugin_cfg.get("skillify") or {}
|
|
477
|
+
if skillify_cfg.get("enabled", False):
|
|
478
|
+
try:
|
|
479
|
+
from soothe_plugins.skillify.retriever import SkillRetriever
|
|
480
|
+
|
|
481
|
+
vs = soothe_cfg.create_vector_store_for_role("skillify")
|
|
482
|
+
skill_embeddings = soothe_cfg.create_embedding_model()
|
|
483
|
+
skillify_retriever = SkillRetriever(vector_store=vs, embeddings=skill_embeddings)
|
|
484
|
+
except Exception:
|
|
485
|
+
logger.debug("Failed to create Skillify retriever for Weaver", exc_info=True)
|
|
486
|
+
|
|
487
|
+
runnable = _build_weaver_graph(
|
|
488
|
+
analyzer=analyzer_inst,
|
|
489
|
+
reuse_index=self._reuse_index,
|
|
490
|
+
composer=composer_inst,
|
|
491
|
+
generator=generator_inst,
|
|
492
|
+
registry=registry_inst,
|
|
493
|
+
skillify_retriever=skillify_retriever,
|
|
494
|
+
model=resolved_model,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
spec: CompiledSubAgent = {
|
|
498
|
+
"name": "weaver",
|
|
499
|
+
"description": WEAVER_DESCRIPTION,
|
|
500
|
+
"runnable": runnable,
|
|
501
|
+
}
|
|
502
|
+
spec["_weaver_reuse_index"] = self._reuse_index # type: ignore[typeddict-unknown-key]
|
|
503
|
+
return spec
|
|
504
|
+
|
|
505
|
+
def get_subagents(self) -> list[Any]:
|
|
506
|
+
"""Get list of subagent factory functions."""
|
|
507
|
+
return [self.create_weaver]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""RequirementAnalyzer -- LLM-based capability extraction (RFC-0005) -- community edition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from .models import CapabilitySignature
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from langchain_core.language_models import BaseChatModel
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
_ANALYSIS_PROMPT = """\
|
|
17
|
+
You are analysing a user request to determine what kind of specialist agent \
|
|
18
|
+
is needed. Extract a structured capability signature.
|
|
19
|
+
|
|
20
|
+
User request:
|
|
21
|
+
{request}
|
|
22
|
+
|
|
23
|
+
Output valid JSON with these fields:
|
|
24
|
+
{{
|
|
25
|
+
"description": "One-paragraph summary of what the agent should do",
|
|
26
|
+
"required_capabilities": ["capability_keyword_1", "capability_keyword_2"],
|
|
27
|
+
"constraints": ["constraint_1"],
|
|
28
|
+
"expected_input": "What the agent receives from the user",
|
|
29
|
+
"expected_output": "What the agent should produce"
|
|
30
|
+
}}
|
|
31
|
+
|
|
32
|
+
Be specific about capabilities. Use lowercase snake_case for capability keywords."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RequirementAnalyzer:
|
|
36
|
+
"""Extracts a structured capability signature from a user request via LLM.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
model: Chat model for analysis.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, model: BaseChatModel) -> None:
|
|
43
|
+
"""Initialize the requirement analyzer.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
model: Chat model for analysis.
|
|
47
|
+
"""
|
|
48
|
+
self._model = model
|
|
49
|
+
|
|
50
|
+
async def analyze(self, request: str) -> CapabilitySignature:
|
|
51
|
+
"""Extract a ``CapabilitySignature`` from the user request.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
request: Raw user request text.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Structured capability signature.
|
|
58
|
+
"""
|
|
59
|
+
prompt = _ANALYSIS_PROMPT.format(request=request)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
resp = await self._model.ainvoke([{"role": "user", "content": prompt}])
|
|
63
|
+
content = str(resp.content)
|
|
64
|
+
parsed = json.loads(content)
|
|
65
|
+
return CapabilitySignature(**parsed)
|
|
66
|
+
except json.JSONDecodeError:
|
|
67
|
+
logger.warning("Failed to parse analysis response as JSON, using fallback")
|
|
68
|
+
return CapabilitySignature(
|
|
69
|
+
description=request,
|
|
70
|
+
required_capabilities=[],
|
|
71
|
+
expected_input="user request",
|
|
72
|
+
expected_output="task result",
|
|
73
|
+
)
|
|
74
|
+
except Exception:
|
|
75
|
+
logger.exception("Requirement analysis failed")
|
|
76
|
+
return CapabilitySignature(
|
|
77
|
+
description=request,
|
|
78
|
+
required_capabilities=[],
|
|
79
|
+
expected_input="user request",
|
|
80
|
+
expected_output="task result",
|
|
81
|
+
)
|