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.
@@ -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
+ )