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,177 @@
|
|
|
1
|
+
"""AgentGenerator -- manifest and system prompt generation (RFC-0005) -- community edition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from langchain_core.language_models import BaseChatModel
|
|
13
|
+
|
|
14
|
+
import anyio
|
|
15
|
+
|
|
16
|
+
from .models import AgentBlueprint, AgentManifest
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_SYSTEM_PROMPT_GENERATION = """\
|
|
21
|
+
You are generating a system prompt for a specialist AI agent.
|
|
22
|
+
|
|
23
|
+
Agent name: {agent_name}
|
|
24
|
+
Agent description: {description}
|
|
25
|
+
|
|
26
|
+
The agent must fulfil this objective:
|
|
27
|
+
{objective}
|
|
28
|
+
|
|
29
|
+
Expected input: {expected_input}
|
|
30
|
+
Expected output: {expected_output}
|
|
31
|
+
Constraints: {constraints}
|
|
32
|
+
|
|
33
|
+
The agent has access to these skills (integrated instructions):
|
|
34
|
+
{skills_text}
|
|
35
|
+
|
|
36
|
+
{bridge_instructions}
|
|
37
|
+
|
|
38
|
+
The agent has these tools available: {tools}
|
|
39
|
+
|
|
40
|
+
Generate a comprehensive system prompt for this agent. The prompt should:
|
|
41
|
+
1. Define the agent's role and responsibilities clearly
|
|
42
|
+
2. Incorporate the skill instructions naturally (do not reference them as "skills")
|
|
43
|
+
3. Include the bridge instructions as part of the workflow
|
|
44
|
+
4. Specify the expected input/output format
|
|
45
|
+
5. Include any constraints as operational rules
|
|
46
|
+
|
|
47
|
+
Output ONLY the system prompt text (markdown format). Do not include meta-commentary."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AgentGenerator:
|
|
51
|
+
"""Generates agent packages (manifest + system prompt) from blueprints.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
model: Chat model for system prompt generation.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, model: BaseChatModel) -> None:
|
|
58
|
+
"""Initialize the agent generator.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
model: Chat model for system prompt generation.
|
|
62
|
+
"""
|
|
63
|
+
self._model = model
|
|
64
|
+
|
|
65
|
+
async def generate(
|
|
66
|
+
self,
|
|
67
|
+
blueprint: AgentBlueprint,
|
|
68
|
+
output_dir: Path,
|
|
69
|
+
) -> AgentManifest:
|
|
70
|
+
"""Generate an agent package from a blueprint.
|
|
71
|
+
|
|
72
|
+
Creates the output directory with ``manifest.yml``, ``system_prompt.md``,
|
|
73
|
+
and a ``skills/`` subdirectory with copied skill files.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
blueprint: Complete agent specification.
|
|
77
|
+
output_dir: Directory to write the agent package into.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The generated ``AgentManifest``.
|
|
81
|
+
"""
|
|
82
|
+
adir = anyio.Path(output_dir)
|
|
83
|
+
await adir.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
|
|
85
|
+
skills_dir = output_dir / "skills"
|
|
86
|
+
await anyio.Path(skills_dir).mkdir(exist_ok=True)
|
|
87
|
+
copied_skills = self._copy_skills(blueprint, skills_dir)
|
|
88
|
+
|
|
89
|
+
system_prompt = await self._generate_system_prompt(blueprint)
|
|
90
|
+
|
|
91
|
+
prompt_path = output_dir / "system_prompt.md"
|
|
92
|
+
await anyio.Path(prompt_path).write_text(system_prompt, encoding="utf-8")
|
|
93
|
+
|
|
94
|
+
manifest = AgentManifest(
|
|
95
|
+
name=blueprint.agent_name,
|
|
96
|
+
description=blueprint.capability.description,
|
|
97
|
+
skills=copied_skills,
|
|
98
|
+
tools=blueprint.tools,
|
|
99
|
+
capabilities=blueprint.capability.required_capabilities,
|
|
100
|
+
created_at=datetime.now(UTC),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
manifest_path = output_dir / "manifest.yml"
|
|
104
|
+
self._write_manifest(manifest, manifest_path)
|
|
105
|
+
|
|
106
|
+
logger.info("Generated agent '%s' at %s", blueprint.agent_name, output_dir)
|
|
107
|
+
return manifest
|
|
108
|
+
|
|
109
|
+
async def _generate_system_prompt(self, blueprint: AgentBlueprint) -> str:
|
|
110
|
+
"""Use LLM to craft a system prompt from the blueprint."""
|
|
111
|
+
skills_text = (
|
|
112
|
+
"\n\n".join(f"### {sid}\n{content[:2000]}" for sid, content in blueprint.harmonized.skill_contents.items())
|
|
113
|
+
or "(no specific skills)"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
bridge = ""
|
|
117
|
+
if blueprint.harmonized.bridge_instructions:
|
|
118
|
+
bridge = f"Additional integration instructions:\n{blueprint.harmonized.bridge_instructions}"
|
|
119
|
+
|
|
120
|
+
constraints = ", ".join(blueprint.capability.constraints) or "none"
|
|
121
|
+
tools = ", ".join(blueprint.tools) or "standard file and shell tools"
|
|
122
|
+
|
|
123
|
+
prompt = _SYSTEM_PROMPT_GENERATION.format(
|
|
124
|
+
agent_name=blueprint.agent_name,
|
|
125
|
+
description=blueprint.capability.description,
|
|
126
|
+
objective=blueprint.capability.description,
|
|
127
|
+
expected_input=blueprint.capability.expected_input or "user request",
|
|
128
|
+
expected_output=blueprint.capability.expected_output or "task result",
|
|
129
|
+
constraints=constraints,
|
|
130
|
+
skills_text=skills_text,
|
|
131
|
+
bridge_instructions=bridge,
|
|
132
|
+
tools=tools,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
resp = await self._model.ainvoke([{"role": "user", "content": prompt}])
|
|
137
|
+
return str(resp.content).strip()
|
|
138
|
+
except Exception:
|
|
139
|
+
logger.exception("System prompt generation failed")
|
|
140
|
+
return self._fallback_prompt(blueprint)
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _fallback_prompt(blueprint: AgentBlueprint) -> str:
|
|
144
|
+
"""Generate a minimal system prompt when LLM call fails."""
|
|
145
|
+
return (
|
|
146
|
+
f"You are {blueprint.agent_name}, a specialist agent.\n\n"
|
|
147
|
+
f"## Objective\n{blueprint.capability.description}\n\n"
|
|
148
|
+
f"## Expected Input\n{blueprint.capability.expected_input}\n\n"
|
|
149
|
+
f"## Expected Output\n{blueprint.capability.expected_output}\n"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _copy_skills(blueprint: AgentBlueprint, skills_dir: Path) -> list[str]:
|
|
154
|
+
"""Copy skill files into the agent's skills directory."""
|
|
155
|
+
copied: list[str] = []
|
|
156
|
+
for sid, content in blueprint.harmonized.skill_contents.items():
|
|
157
|
+
skill_subdir = skills_dir / sid
|
|
158
|
+
skill_subdir.mkdir(exist_ok=True)
|
|
159
|
+
(skill_subdir / "SKILL.md").write_text(content, encoding="utf-8")
|
|
160
|
+
copied.append(str(skill_subdir))
|
|
161
|
+
return copied
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _write_manifest(manifest: AgentManifest, path: Path) -> None:
|
|
165
|
+
"""Write the manifest as YAML."""
|
|
166
|
+
try:
|
|
167
|
+
import yaml
|
|
168
|
+
|
|
169
|
+
data = manifest.model_dump(mode="json")
|
|
170
|
+
if "created_at" in data and hasattr(data["created_at"], "isoformat"):
|
|
171
|
+
data["created_at"] = data["created_at"].isoformat()
|
|
172
|
+
path.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False), encoding="utf-8")
|
|
173
|
+
except ImportError:
|
|
174
|
+
import json
|
|
175
|
+
|
|
176
|
+
data = manifest.model_dump(mode="json")
|
|
177
|
+
path.with_suffix(".json").write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Weaver data models (RFC-0005) -- community edition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CapabilitySignature(BaseModel):
|
|
12
|
+
"""Structured analysis of what the user request requires.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
description: One-paragraph summary of what the agent should do.
|
|
16
|
+
required_capabilities: Capability keywords (e.g. ``arxiv_search``).
|
|
17
|
+
constraints: Operational limits or requirements.
|
|
18
|
+
expected_input: What the agent receives from the user.
|
|
19
|
+
expected_output: What the agent should produce.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
description: str
|
|
23
|
+
required_capabilities: list[str] = Field(default_factory=list)
|
|
24
|
+
constraints: list[str] = Field(default_factory=list)
|
|
25
|
+
expected_input: str = ""
|
|
26
|
+
expected_output: str = ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AgentManifest(BaseModel):
|
|
30
|
+
"""Metadata file for a generated agent package.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
name: Agent identifier (lowercase, hyphenated).
|
|
34
|
+
description: Human-readable description for the ``task`` tool.
|
|
35
|
+
type: Agent type (currently always ``subagent``).
|
|
36
|
+
system_prompt_file: Filename of the system prompt markdown.
|
|
37
|
+
skills: Skill paths copied into the package.
|
|
38
|
+
tools: Langchain tool group names enabled for this agent.
|
|
39
|
+
capabilities: Capability keywords this agent provides.
|
|
40
|
+
created_at: Creation timestamp.
|
|
41
|
+
version: Monotonically increasing version number.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
description: str
|
|
46
|
+
type: Literal["subagent"] = "subagent"
|
|
47
|
+
system_prompt_file: str = "system_prompt.md"
|
|
48
|
+
skills: list[str] = Field(default_factory=list)
|
|
49
|
+
tools: list[str] = Field(default_factory=list)
|
|
50
|
+
capabilities: list[str] = Field(default_factory=list)
|
|
51
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
52
|
+
version: int = 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ReuseCandidate(BaseModel):
|
|
56
|
+
"""A previously generated agent that may fulfill the current request.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
manifest: The agent's manifest metadata.
|
|
60
|
+
confidence: Semantic similarity score in [0, 1].
|
|
61
|
+
path: Absolute path to the agent directory.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
manifest: AgentManifest
|
|
65
|
+
confidence: float
|
|
66
|
+
path: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SkillConflict(BaseModel):
|
|
70
|
+
"""A detected conflict between two skills.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
skill_a_id: First skill identifier.
|
|
74
|
+
skill_b_id: Second skill identifier.
|
|
75
|
+
conflict_type: Nature of the conflict.
|
|
76
|
+
description: Human-readable conflict description.
|
|
77
|
+
severity: Impact level.
|
|
78
|
+
resolution: LLM-proposed resolution strategy.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
skill_a_id: str
|
|
82
|
+
skill_b_id: str
|
|
83
|
+
conflict_type: Literal["contradictory", "ambiguous", "version_mismatch"]
|
|
84
|
+
description: str
|
|
85
|
+
severity: Literal["low", "medium", "high"]
|
|
86
|
+
resolution: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SkillConflictReport(BaseModel):
|
|
90
|
+
"""Full analysis of conflicts, overlaps, and gaps in a candidate skill set.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
conflicts: Typed conflict entries between skill pairs.
|
|
94
|
+
overlaps: Pairs of skill IDs with redundant coverage.
|
|
95
|
+
gaps: Missing capabilities identified by analysis.
|
|
96
|
+
harmonization_summary: Human-readable summary of the analysis.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
conflicts: list[SkillConflict] = Field(default_factory=list)
|
|
100
|
+
overlaps: list[list[str]] = Field(default_factory=list)
|
|
101
|
+
gaps: list[str] = Field(default_factory=list)
|
|
102
|
+
harmonization_summary: str = ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class HarmonizedSkillSet(BaseModel):
|
|
106
|
+
"""Output of skill harmonization -- a clean, unified instruction corpus.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
skills: Final skill IDs retained after harmonization.
|
|
110
|
+
skill_contents: Mapping of skill_id to merged/cleaned content.
|
|
111
|
+
bridge_instructions: LLM-generated glue logic connecting skills.
|
|
112
|
+
dropped_skills: Skill IDs removed during deduplication.
|
|
113
|
+
merge_log: Human-readable log of merge decisions.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
skills: list[str] = Field(default_factory=list)
|
|
117
|
+
skill_contents: dict[str, str] = Field(default_factory=dict)
|
|
118
|
+
bridge_instructions: str = ""
|
|
119
|
+
dropped_skills: list[str] = Field(default_factory=list)
|
|
120
|
+
merge_log: list[str] = Field(default_factory=list)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class AgentBlueprint(BaseModel):
|
|
124
|
+
"""Complete specification for agent generation.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
capability: The analysed capability signature.
|
|
128
|
+
harmonized: The harmonized skill set.
|
|
129
|
+
tools: Resolved tool group names.
|
|
130
|
+
agent_name: Generated agent name (lowercase, hyphenated).
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
capability: CapabilitySignature
|
|
134
|
+
harmonized: HarmonizedSkillSet
|
|
135
|
+
tools: list[str] = Field(default_factory=list)
|
|
136
|
+
agent_name: str = ""
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""GeneratedAgentRegistry -- filesystem-based agent registry (RFC-0005) -- community edition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from .models import AgentManifest
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from deepagents.middleware.subagents import SubAgent
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GeneratedAgentRegistry:
|
|
18
|
+
"""Manages generated agent packages under a base directory.
|
|
19
|
+
|
|
20
|
+
Each agent is a subdirectory containing ``manifest.yml`` (or
|
|
21
|
+
``manifest.json``) and ``system_prompt.md``.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
base_dir: Root directory for generated agents
|
|
25
|
+
(e.g. ``~/.soothe/generated_agents``).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, base_dir: Path) -> None:
|
|
29
|
+
"""Initialize the generated agent registry.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
base_dir: Root directory for generated agents
|
|
33
|
+
(e.g. ``~/.soothe/generated_agents``).
|
|
34
|
+
"""
|
|
35
|
+
self._base_dir = Path(base_dir).expanduser().resolve()
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def base_dir(self) -> Path:
|
|
39
|
+
"""The base directory for generated agents."""
|
|
40
|
+
return self._base_dir
|
|
41
|
+
|
|
42
|
+
def list_agents(self) -> list[AgentManifest]:
|
|
43
|
+
"""Scan base_dir for ``*/manifest.yml`` and return parsed manifests.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
List of valid ``AgentManifest`` objects found.
|
|
47
|
+
"""
|
|
48
|
+
manifests: list[AgentManifest] = []
|
|
49
|
+
if not self._base_dir.is_dir():
|
|
50
|
+
return manifests
|
|
51
|
+
|
|
52
|
+
for manifest_path in self._base_dir.glob("*/manifest.yml"):
|
|
53
|
+
try:
|
|
54
|
+
manifest = self._load_manifest(manifest_path)
|
|
55
|
+
if manifest:
|
|
56
|
+
manifests.append(manifest)
|
|
57
|
+
except Exception:
|
|
58
|
+
logger.warning("Failed to load manifest %s", manifest_path, exc_info=True)
|
|
59
|
+
|
|
60
|
+
for manifest_path in self._base_dir.glob("*/manifest.json"):
|
|
61
|
+
try:
|
|
62
|
+
manifest = self._load_manifest_json(manifest_path)
|
|
63
|
+
if manifest:
|
|
64
|
+
manifests.append(manifest)
|
|
65
|
+
except Exception:
|
|
66
|
+
logger.warning("Failed to load manifest %s", manifest_path, exc_info=True)
|
|
67
|
+
|
|
68
|
+
return manifests
|
|
69
|
+
|
|
70
|
+
def get_agent(self, name: str) -> tuple[AgentManifest, Path] | None:
|
|
71
|
+
"""Get a specific agent by name.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
name: Agent name to look up.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Tuple of (manifest, agent_dir) or ``None``.
|
|
78
|
+
"""
|
|
79
|
+
agent_dir = self._base_dir / name
|
|
80
|
+
if not agent_dir.is_dir():
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
for ext in ("yml", "json"):
|
|
84
|
+
manifest_path = agent_dir / f"manifest.{ext}"
|
|
85
|
+
if manifest_path.is_file():
|
|
86
|
+
loader = self._load_manifest if ext == "yml" else self._load_manifest_json
|
|
87
|
+
manifest = loader(manifest_path)
|
|
88
|
+
if manifest:
|
|
89
|
+
return manifest, agent_dir
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def register(self, manifest: AgentManifest, path: Path) -> None:
|
|
93
|
+
"""Record a new agent entry (idempotent).
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
manifest: The agent manifest.
|
|
97
|
+
path: Absolute path to the agent directory.
|
|
98
|
+
"""
|
|
99
|
+
logger.info("Registered generated agent '%s' at %s", manifest.name, path)
|
|
100
|
+
|
|
101
|
+
def load_as_subagent(self, name: str) -> "SubAgent" | None:
|
|
102
|
+
"""Load a generated agent as a deepagents SubAgent dict.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
name: Agent name to load.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
A ``SubAgent`` dict or ``None`` if not found.
|
|
109
|
+
"""
|
|
110
|
+
result = self.get_agent(name)
|
|
111
|
+
if result is None:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
manifest, agent_dir = result
|
|
115
|
+
prompt_path = agent_dir / manifest.system_prompt_file
|
|
116
|
+
system_prompt = ""
|
|
117
|
+
if prompt_path.is_file():
|
|
118
|
+
try:
|
|
119
|
+
system_prompt = prompt_path.read_text(encoding="utf-8")
|
|
120
|
+
except Exception:
|
|
121
|
+
logger.warning("Failed to read system prompt for '%s'", name, exc_info=True)
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
"name": manifest.name,
|
|
125
|
+
"description": manifest.description,
|
|
126
|
+
"system_prompt": system_prompt,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
def cleanup_old_agents(self, max_age_days: int = 30, max_agents: int = 100) -> int:
|
|
130
|
+
"""Remove old/unused generated agents.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
max_age_days: Delete agents not accessed for N days.
|
|
134
|
+
max_agents: Keep at most N agents (delete oldest first).
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Number of agents deleted.
|
|
138
|
+
"""
|
|
139
|
+
import shutil
|
|
140
|
+
from datetime import UTC, datetime, timedelta
|
|
141
|
+
|
|
142
|
+
deleted = 0
|
|
143
|
+
|
|
144
|
+
if not self._base_dir.is_dir():
|
|
145
|
+
return deleted
|
|
146
|
+
|
|
147
|
+
# Get all agents with their access times
|
|
148
|
+
agents: list[tuple[Path, datetime]] = []
|
|
149
|
+
try:
|
|
150
|
+
for manifest_file in self._base_dir.glob("*/manifest.yml"):
|
|
151
|
+
try:
|
|
152
|
+
stat = manifest_file.stat()
|
|
153
|
+
atime = datetime.fromtimestamp(stat.st_atime, tz=UTC)
|
|
154
|
+
agents.append((manifest_file.parent, atime))
|
|
155
|
+
except Exception:
|
|
156
|
+
logger.debug("Failed to stat %s", manifest_file, exc_info=True)
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
# Sort by access time (oldest first)
|
|
160
|
+
agents.sort(key=lambda x: x[1])
|
|
161
|
+
|
|
162
|
+
# Delete by age
|
|
163
|
+
cutoff = datetime.now(UTC) - timedelta(days=max_age_days)
|
|
164
|
+
for agent_dir, atime in agents:
|
|
165
|
+
if atime < cutoff:
|
|
166
|
+
try:
|
|
167
|
+
shutil.rmtree(agent_dir)
|
|
168
|
+
deleted += 1
|
|
169
|
+
logger.info("Deleted old generated agent: %s", agent_dir.name)
|
|
170
|
+
except Exception:
|
|
171
|
+
logger.warning("Failed to delete agent %s", agent_dir, exc_info=True)
|
|
172
|
+
|
|
173
|
+
# Delete by count (if still over limit)
|
|
174
|
+
remaining = [(d, t) for d, t in agents if t >= cutoff]
|
|
175
|
+
if len(remaining) > max_agents:
|
|
176
|
+
for agent_dir, _ in remaining[:-max_agents]:
|
|
177
|
+
try:
|
|
178
|
+
shutil.rmtree(agent_dir)
|
|
179
|
+
deleted += 1
|
|
180
|
+
logger.info("Deleted excess generated agent: %s", agent_dir.name)
|
|
181
|
+
except Exception:
|
|
182
|
+
logger.warning("Failed to delete agent %s", agent_dir, exc_info=True)
|
|
183
|
+
except Exception:
|
|
184
|
+
logger.warning("Agent cleanup failed", exc_info=True)
|
|
185
|
+
|
|
186
|
+
return deleted
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def _load_manifest(path: Path) -> AgentManifest | None:
|
|
190
|
+
"""Load a YAML manifest file."""
|
|
191
|
+
try:
|
|
192
|
+
import yaml
|
|
193
|
+
|
|
194
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
195
|
+
if not isinstance(data, dict):
|
|
196
|
+
return None
|
|
197
|
+
return AgentManifest(**data)
|
|
198
|
+
except Exception:
|
|
199
|
+
logger.debug("Failed to parse YAML manifest %s", path, exc_info=True)
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _load_manifest_json(path: Path) -> AgentManifest | None:
|
|
204
|
+
"""Load a JSON manifest file."""
|
|
205
|
+
import json
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
209
|
+
if not isinstance(data, dict):
|
|
210
|
+
return None
|
|
211
|
+
return AgentManifest(**data)
|
|
212
|
+
except Exception:
|
|
213
|
+
logger.debug("Failed to parse JSON manifest %s", path, exc_info=True)
|
|
214
|
+
return None
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""ReuseIndex -- vector search over previously generated agents (RFC-0005) -- community edition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from .models import AgentManifest, CapabilitySignature, ReuseCandidate
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from langchain_core.embeddings import Embeddings
|
|
12
|
+
|
|
13
|
+
from soothe_sdk.protocols import VectorStoreProtocol
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ReuseIndex:
|
|
19
|
+
"""Semantic index of previously generated agents for reuse-first strategy.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
vector_store: Vector store for agent description embeddings.
|
|
23
|
+
embeddings: Embedding model for vectorization.
|
|
24
|
+
threshold: Minimum confidence score to consider a reuse hit.
|
|
25
|
+
collection: Vector store collection name.
|
|
26
|
+
embedding_dims: Embedding vector dimensionality.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
vector_store: "VectorStoreProtocol",
|
|
32
|
+
embeddings: Embeddings,
|
|
33
|
+
threshold: float = 0.85,
|
|
34
|
+
collection: str = "soothe_weaver_reuse",
|
|
35
|
+
embedding_dims: int = 1536,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Initialize the reuse index.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
vector_store: Vector store for agent description embeddings.
|
|
41
|
+
embeddings: Embedding model for vectorization.
|
|
42
|
+
threshold: Minimum confidence score to consider a reuse hit.
|
|
43
|
+
collection: Vector store collection name.
|
|
44
|
+
embedding_dims: Embedding vector dimensionality.
|
|
45
|
+
"""
|
|
46
|
+
self._vector_store = vector_store
|
|
47
|
+
self._embeddings = embeddings
|
|
48
|
+
self._threshold = threshold
|
|
49
|
+
self._collection = collection
|
|
50
|
+
self._embedding_dims = embedding_dims
|
|
51
|
+
self._initialized = False
|
|
52
|
+
|
|
53
|
+
async def _ensure_collection(self) -> None:
|
|
54
|
+
if self._initialized:
|
|
55
|
+
return
|
|
56
|
+
try:
|
|
57
|
+
await self._vector_store.create_collection(
|
|
58
|
+
vector_size=self._embedding_dims,
|
|
59
|
+
distance="cosine",
|
|
60
|
+
)
|
|
61
|
+
except Exception:
|
|
62
|
+
logger.debug("Reuse collection creation failed (may already exist)", exc_info=True)
|
|
63
|
+
self._initialized = True
|
|
64
|
+
|
|
65
|
+
async def find_reusable(self, capability: CapabilitySignature) -> ReuseCandidate | None:
|
|
66
|
+
"""Search for an existing generated agent matching the capability.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
capability: The analysed capability signature.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
A ``ReuseCandidate`` if confidence >= threshold, else ``None``.
|
|
73
|
+
"""
|
|
74
|
+
await self._ensure_collection()
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
vector = await self._embeddings.aembed_query(capability.description)
|
|
78
|
+
except Exception:
|
|
79
|
+
logger.exception("Failed to embed capability description")
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
results = await self._vector_store.search(
|
|
84
|
+
query=capability.description,
|
|
85
|
+
vector=vector,
|
|
86
|
+
limit=5,
|
|
87
|
+
)
|
|
88
|
+
except Exception:
|
|
89
|
+
logger.exception("Reuse index search failed")
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
if not results:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
best = results[0]
|
|
96
|
+
confidence = best.score or 0.0
|
|
97
|
+
if confidence < self._threshold:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
payload = best.payload
|
|
101
|
+
try:
|
|
102
|
+
manifest = AgentManifest(**payload.get("manifest", {}))
|
|
103
|
+
except Exception:
|
|
104
|
+
logger.warning("Failed to parse reuse candidate manifest", exc_info=True)
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
return ReuseCandidate(
|
|
108
|
+
manifest=manifest,
|
|
109
|
+
confidence=confidence,
|
|
110
|
+
path=payload.get("path", ""),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
async def index_agent(self, manifest: AgentManifest, path: str) -> None:
|
|
114
|
+
"""Add a newly generated agent to the reuse index.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
manifest: The agent's manifest.
|
|
118
|
+
path: Absolute path to the agent directory.
|
|
119
|
+
"""
|
|
120
|
+
await self._ensure_collection()
|
|
121
|
+
|
|
122
|
+
text = f"{manifest.name}: {manifest.description}"
|
|
123
|
+
if manifest.capabilities:
|
|
124
|
+
text += f"\nCapabilities: {', '.join(manifest.capabilities)}"
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
vector = await self._embeddings.aembed_query(text)
|
|
128
|
+
except Exception:
|
|
129
|
+
logger.exception("Failed to embed agent description for reuse index")
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
payload: dict[str, Any] = {
|
|
133
|
+
"manifest": manifest.model_dump(mode="json"),
|
|
134
|
+
"path": path,
|
|
135
|
+
"agent_name": manifest.name,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
await self._vector_store.insert(
|
|
140
|
+
vectors=[vector],
|
|
141
|
+
payloads=[payload],
|
|
142
|
+
ids=[manifest.name],
|
|
143
|
+
)
|
|
144
|
+
except Exception:
|
|
145
|
+
logger.exception("Failed to index agent in reuse store")
|
|
146
|
+
|
|
147
|
+
async def close(self) -> None:
|
|
148
|
+
"""Close the underlying vector store if supported."""
|
|
149
|
+
close_method = getattr(self._vector_store, "close", None)
|
|
150
|
+
if callable(close_method):
|
|
151
|
+
await close_method()
|