wowbits-cli 0.1.0a1__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.
- cli/.env +1 -0
- cli/__init__.py +12 -0
- cli/agents.py +1288 -0
- cli/connectors.py +583 -0
- cli/data/providers.json +157 -0
- cli/functions.py +201 -0
- cli/setup.py +495 -0
- cli/wowbits.py +380 -0
- db/__init__.py +0 -0
- db/alembic/README.md +52 -0
- db/alembic/alembic.ini +46 -0
- db/alembic/env.py +104 -0
- db/alembic/script.py.mako +28 -0
- db/alembic/versions/2026_01_04_1404-a17f8d0d25fe_adding_table_for_connectors.py +32 -0
- db/schema.py +288 -0
- pylibs/__init__.py +0 -0
- pylibs/connector.py +142 -0
- pylibs/database_manager.py +186 -0
- pylibs/env_loader.py +31 -0
- wowbits_cli-0.1.0a1.dist-info/METADATA +185 -0
- wowbits_cli-0.1.0a1.dist-info/RECORD +23 -0
- wowbits_cli-0.1.0a1.dist-info/WHEEL +4 -0
- wowbits_cli-0.1.0a1.dist-info/entry_points.txt +2 -0
cli/agents.py
ADDED
|
@@ -0,0 +1,1288 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
WowBits CLI - Agent Command
|
|
4
|
+
|
|
5
|
+
Handles agent management including creating agents from YAML configuration files.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
17
|
+
|
|
18
|
+
from pylibs.database_manager import get_db_manager, get_session_context
|
|
19
|
+
from db.schema import (
|
|
20
|
+
Agent,
|
|
21
|
+
AgentSkill,
|
|
22
|
+
AgentStatus,
|
|
23
|
+
MCPConfig,
|
|
24
|
+
PythonFunction,
|
|
25
|
+
Skill,
|
|
26
|
+
SkillSkill,
|
|
27
|
+
SkillTool,
|
|
28
|
+
Tool,
|
|
29
|
+
ToolType,
|
|
30
|
+
ExecMode,
|
|
31
|
+
SequentialAgentExecOrder,
|
|
32
|
+
SequentialSkillExecOrder,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
DEFAULT_MODEL = "gpt-4.1"
|
|
36
|
+
|
|
37
|
+
# Agent code template - embedded to avoid file path issues when installed as a package
|
|
38
|
+
AGENT_CODE_TEMPLATE = '''from uuid import UUID
|
|
39
|
+
import logging
|
|
40
|
+
from google.adk.agents import LlmAgent, SequentialAgent, ParallelAgent
|
|
41
|
+
from google.adk.tools.mcp_tool.mcp_toolset import MCPToolset, StreamableHTTPConnectionParams, SseConnectionParams
|
|
42
|
+
from pylibs.database_manager import get_db_manager
|
|
43
|
+
from db.schema import (
|
|
44
|
+
Agent, AgentSkill, Skill, SkillSkill, SkillTool, Tool,
|
|
45
|
+
PythonFunction, MCPConfig, ToolType, ExecMode,
|
|
46
|
+
SequentialAgentExecOrder, SequentialSkillExecOrder
|
|
47
|
+
)
|
|
48
|
+
from google.adk.models.lite_llm import LiteLlm
|
|
49
|
+
from google.genai import types
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
logger.setLevel(logging.INFO)
|
|
53
|
+
|
|
54
|
+
agent_id = "{agent_id}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_python_function(session, python_function_id):
|
|
58
|
+
"""Load and execute Python function code from database."""
|
|
59
|
+
pf = session.get(PythonFunction, python_function_id)
|
|
60
|
+
if not pf:
|
|
61
|
+
logger.warning(f"Python function {{python_function_id}} not found")
|
|
62
|
+
return None
|
|
63
|
+
ns = {{}}
|
|
64
|
+
try:
|
|
65
|
+
exec(pf.code, ns)
|
|
66
|
+
except SyntaxError as e:
|
|
67
|
+
logger.exception(f"Syntax error in tool {{pf.name}} at line {{e.lineno}}: {{e.text}}")
|
|
68
|
+
raise
|
|
69
|
+
return ns.get(pf.name)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_tools_for_skill(session, skill_id):
|
|
73
|
+
"""Load all tools (Python functions and MCP servers) for a skill."""
|
|
74
|
+
tools = []
|
|
75
|
+
links = session.query(SkillTool).filter(SkillTool.skill_id == skill_id).all()
|
|
76
|
+
for link in links:
|
|
77
|
+
tool_ob = session.get(Tool, link.tool_id)
|
|
78
|
+
if not tool_ob:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
if tool_ob.type == ToolType.PYTHON_FUNCTION:
|
|
82
|
+
try:
|
|
83
|
+
fn = load_python_function(session, tool_ob.python_function_id)
|
|
84
|
+
if fn:
|
|
85
|
+
tools.append(fn)
|
|
86
|
+
except Exception:
|
|
87
|
+
logger.exception(f"Failed loading python function tool for skill {{skill_id}}")
|
|
88
|
+
elif tool_ob.type == ToolType.MCP_SERVER:
|
|
89
|
+
try:
|
|
90
|
+
cfg = session.get(MCPConfig, tool_ob.mcp_config_id)
|
|
91
|
+
if not cfg:
|
|
92
|
+
continue
|
|
93
|
+
c = cfg.config or {{}}
|
|
94
|
+
transport_mode = c.get("transport_mode")
|
|
95
|
+
if not transport_mode:
|
|
96
|
+
continue
|
|
97
|
+
if transport_mode == "http":
|
|
98
|
+
url = cfg.url
|
|
99
|
+
tools.append(MCPToolset(connection_params=StreamableHTTPConnectionParams(url=url)))
|
|
100
|
+
elif transport_mode == "sse":
|
|
101
|
+
url = cfg.url
|
|
102
|
+
tools.append(MCPToolset(connection_params=SseConnectionParams(url=url)))
|
|
103
|
+
else:
|
|
104
|
+
logger.warning(f"Unknown transport mode: {{transport_mode}}")
|
|
105
|
+
continue
|
|
106
|
+
except Exception:
|
|
107
|
+
logger.exception(f"Failed loading MCP tool for skill {{skill_id}}")
|
|
108
|
+
return tools
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _build_safety_settings(raw):
|
|
112
|
+
"""Convert stored JSON into google.genai.types.SafetySetting objects."""
|
|
113
|
+
if not raw:
|
|
114
|
+
return []
|
|
115
|
+
out = []
|
|
116
|
+
for item in raw:
|
|
117
|
+
try:
|
|
118
|
+
cat = item.get("category")
|
|
119
|
+
thr = item.get("threshold")
|
|
120
|
+
category_enum = (
|
|
121
|
+
getattr(types.HarmCategory, cat)
|
|
122
|
+
if isinstance(cat, str) and hasattr(types.HarmCategory, cat)
|
|
123
|
+
else None
|
|
124
|
+
)
|
|
125
|
+
threshold_enum = (
|
|
126
|
+
getattr(types.HarmBlockThreshold, thr)
|
|
127
|
+
if isinstance(thr, str) and hasattr(types.HarmBlockThreshold, thr)
|
|
128
|
+
else None
|
|
129
|
+
)
|
|
130
|
+
if category_enum and threshold_enum:
|
|
131
|
+
out.append(
|
|
132
|
+
types.SafetySetting(category=category_enum, threshold=threshold_enum)
|
|
133
|
+
)
|
|
134
|
+
except Exception:
|
|
135
|
+
continue
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _build_generate_content_config(obj):
|
|
140
|
+
"""Build GenerateContentConfig from agent/skill configuration."""
|
|
141
|
+
conf_json = (
|
|
142
|
+
(obj.default_model_config or {{}}) if hasattr(obj, "default_model_config") else {{}}
|
|
143
|
+
)
|
|
144
|
+
temp = (
|
|
145
|
+
obj.temperature
|
|
146
|
+
if getattr(obj, "temperature", None) is not None
|
|
147
|
+
else conf_json.get("temperature", 0.2)
|
|
148
|
+
)
|
|
149
|
+
max_tokens = (
|
|
150
|
+
obj.max_output_tokens
|
|
151
|
+
if getattr(obj, "max_output_tokens", None)
|
|
152
|
+
else conf_json.get("max_output_tokens", 32000)
|
|
153
|
+
)
|
|
154
|
+
raw_safety = (
|
|
155
|
+
obj.safety_settings
|
|
156
|
+
if getattr(obj, "safety_settings", None)
|
|
157
|
+
else conf_json.get("safety_settings", [])
|
|
158
|
+
)
|
|
159
|
+
safety_objects = _build_safety_settings(raw_safety)
|
|
160
|
+
return types.GenerateContentConfig(
|
|
161
|
+
temperature=temp,
|
|
162
|
+
max_output_tokens=max_tokens,
|
|
163
|
+
safety_settings=safety_objects
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_skill_agent(session, skill, skill_cache, visiting_set):
|
|
168
|
+
"""
|
|
169
|
+
Recursively build a skill agent based on its exec_mode.
|
|
170
|
+
Returns an LlmAgent, SequentialAgent, or ParallelAgent.
|
|
171
|
+
"""
|
|
172
|
+
if skill.id in skill_cache:
|
|
173
|
+
logger.info(f"Reusing cached skill: {{skill.name}}")
|
|
174
|
+
return skill_cache[skill.id]
|
|
175
|
+
|
|
176
|
+
if skill.id in visiting_set:
|
|
177
|
+
cycle_path = " -> ".join([str(sid) for sid in visiting_set]) + f" -> {{skill.id}}"
|
|
178
|
+
error_msg = f"Cycle detected in skill hierarchy: {{cycle_path}}"
|
|
179
|
+
logger.error(error_msg)
|
|
180
|
+
raise RuntimeError(error_msg)
|
|
181
|
+
|
|
182
|
+
visiting_set.add(skill.id)
|
|
183
|
+
logger.info(f"Building skill: {{skill.name}} (exec_mode={{skill.exec_mode.value}})")
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
tools = load_tools_for_skill(session, skill.id)
|
|
187
|
+
child_skills = []
|
|
188
|
+
|
|
189
|
+
if skill.exec_mode == ExecMode.SEQUENTIAL:
|
|
190
|
+
orders = (
|
|
191
|
+
session.query(SequentialSkillExecOrder)
|
|
192
|
+
.filter(SequentialSkillExecOrder.parent_skill_id == skill.id)
|
|
193
|
+
.order_by(SequentialSkillExecOrder.sequence_num)
|
|
194
|
+
.all()
|
|
195
|
+
)
|
|
196
|
+
for order in orders:
|
|
197
|
+
child_skill = session.get(Skill, order.child_skill_id)
|
|
198
|
+
if child_skill:
|
|
199
|
+
child_agent = build_skill_agent(session, child_skill, skill_cache, visiting_set)
|
|
200
|
+
child_skills.append(child_agent)
|
|
201
|
+
|
|
202
|
+
elif skill.exec_mode == ExecMode.PARALLEL:
|
|
203
|
+
skill_relations = (
|
|
204
|
+
session.query(SkillSkill)
|
|
205
|
+
.filter(SkillSkill.parent_skill_id == skill.id)
|
|
206
|
+
.all()
|
|
207
|
+
)
|
|
208
|
+
for relation in skill_relations:
|
|
209
|
+
child_skill = session.get(Skill, relation.child_skill_id)
|
|
210
|
+
if child_skill:
|
|
211
|
+
child_agent = build_skill_agent(session, child_skill, skill_cache, visiting_set)
|
|
212
|
+
child_skills.append(child_agent)
|
|
213
|
+
|
|
214
|
+
else:
|
|
215
|
+
skill_relations = (
|
|
216
|
+
session.query(SkillSkill)
|
|
217
|
+
.filter(SkillSkill.parent_skill_id == skill.id)
|
|
218
|
+
.all()
|
|
219
|
+
)
|
|
220
|
+
for relation in skill_relations:
|
|
221
|
+
child_skill = session.get(Skill, relation.child_skill_id)
|
|
222
|
+
if child_skill:
|
|
223
|
+
child_agent = build_skill_agent(session, child_skill, skill_cache, visiting_set)
|
|
224
|
+
child_skills.append(child_agent)
|
|
225
|
+
|
|
226
|
+
if skill.exec_mode == ExecMode.SEQUENTIAL and child_skills:
|
|
227
|
+
agent = SequentialAgent(
|
|
228
|
+
name=skill.name,
|
|
229
|
+
sub_agents=child_skills,
|
|
230
|
+
description=skill.description or "",
|
|
231
|
+
)
|
|
232
|
+
elif skill.exec_mode == ExecMode.PARALLEL and child_skills:
|
|
233
|
+
agent = ParallelAgent(
|
|
234
|
+
name=skill.name,
|
|
235
|
+
sub_agents=child_skills,
|
|
236
|
+
description=skill.description or "",
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
llm_kwargs = {{
|
|
240
|
+
"name": skill.name,
|
|
241
|
+
"model": LiteLlm(model=skill.default_model),
|
|
242
|
+
"description": skill.description or "",
|
|
243
|
+
"instruction": skill.instructions or "",
|
|
244
|
+
"tools": tools,
|
|
245
|
+
"generate_content_config": _build_generate_content_config(skill)
|
|
246
|
+
}}
|
|
247
|
+
if child_skills:
|
|
248
|
+
llm_kwargs["sub_agents"] = child_skills
|
|
249
|
+
if skill.output_key:
|
|
250
|
+
llm_kwargs["output_key"] = skill.output_key
|
|
251
|
+
agent = LlmAgent(**llm_kwargs)
|
|
252
|
+
|
|
253
|
+
skill_cache[skill.id] = agent
|
|
254
|
+
logger.info(f"Built skill agent: {{skill.name}} (type={{type(agent).__name__}})")
|
|
255
|
+
return agent
|
|
256
|
+
|
|
257
|
+
finally:
|
|
258
|
+
visiting_set.discard(skill.id)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def create_agent():
|
|
262
|
+
"""Create hierarchical agent from database with support for sequential/parallel execution."""
|
|
263
|
+
session = get_db_manager().get_session()
|
|
264
|
+
try:
|
|
265
|
+
agent = session.get(Agent, UUID(agent_id))
|
|
266
|
+
if not agent:
|
|
267
|
+
raise RuntimeError(f"Agent not found: {{agent_id}}")
|
|
268
|
+
|
|
269
|
+
logger.info(f"Creating agent: {{agent.name}} (exec_mode={{agent.exec_mode.value}})")
|
|
270
|
+
|
|
271
|
+
skill_cache = {{}}
|
|
272
|
+
visiting_set = set()
|
|
273
|
+
child_agents = []
|
|
274
|
+
|
|
275
|
+
if agent.exec_mode == ExecMode.SEQUENTIAL:
|
|
276
|
+
orders = (
|
|
277
|
+
session.query(SequentialAgentExecOrder)
|
|
278
|
+
.filter(SequentialAgentExecOrder.agent_id == agent.id)
|
|
279
|
+
.order_by(SequentialAgentExecOrder.sequence_num)
|
|
280
|
+
.all()
|
|
281
|
+
)
|
|
282
|
+
for order in orders:
|
|
283
|
+
skill = session.get(Skill, order.skill_id)
|
|
284
|
+
if skill:
|
|
285
|
+
skill_agent = build_skill_agent(session, skill, skill_cache, visiting_set)
|
|
286
|
+
child_agents.append(skill_agent)
|
|
287
|
+
|
|
288
|
+
elif agent.exec_mode == ExecMode.PARALLEL:
|
|
289
|
+
links = session.query(AgentSkill).filter(AgentSkill.agent_id == agent.id).all()
|
|
290
|
+
for link in links:
|
|
291
|
+
skill = session.get(Skill, link.skill_id)
|
|
292
|
+
if skill:
|
|
293
|
+
skill_agent = build_skill_agent(session, skill, skill_cache, visiting_set)
|
|
294
|
+
child_agents.append(skill_agent)
|
|
295
|
+
|
|
296
|
+
else:
|
|
297
|
+
links = session.query(AgentSkill).filter(AgentSkill.agent_id == agent.id).all()
|
|
298
|
+
for link in links:
|
|
299
|
+
skill = session.get(Skill, link.skill_id)
|
|
300
|
+
if skill:
|
|
301
|
+
skill_agent = build_skill_agent(session, skill, skill_cache, visiting_set)
|
|
302
|
+
child_agents.append(skill_agent)
|
|
303
|
+
|
|
304
|
+
if agent.exec_mode == ExecMode.SEQUENTIAL:
|
|
305
|
+
root = SequentialAgent(
|
|
306
|
+
name=agent.name,
|
|
307
|
+
sub_agents=child_agents,
|
|
308
|
+
description=agent.description or "",
|
|
309
|
+
)
|
|
310
|
+
elif agent.exec_mode == ExecMode.PARALLEL:
|
|
311
|
+
root = ParallelAgent(
|
|
312
|
+
name=agent.name,
|
|
313
|
+
sub_agents=child_agents,
|
|
314
|
+
description=agent.description or "",
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
llm_kwargs = {{
|
|
318
|
+
"name": agent.name,
|
|
319
|
+
"model": LiteLlm(model=agent.default_model),
|
|
320
|
+
"description": agent.description or "",
|
|
321
|
+
"instruction": agent.instructions or "",
|
|
322
|
+
"generate_content_config": _build_generate_content_config(agent)
|
|
323
|
+
}}
|
|
324
|
+
if child_agents:
|
|
325
|
+
llm_kwargs["sub_agents"] = child_agents
|
|
326
|
+
if agent.output_key:
|
|
327
|
+
llm_kwargs["output_key"] = agent.output_key
|
|
328
|
+
root = LlmAgent(**llm_kwargs)
|
|
329
|
+
|
|
330
|
+
logger.info(f"Successfully created root agent: {{agent.name}} (type={{type(root).__name__}})")
|
|
331
|
+
logger.info(f"Total unique skills in hierarchy: {{len(skill_cache)}}")
|
|
332
|
+
return root
|
|
333
|
+
|
|
334
|
+
except RuntimeError as e:
|
|
335
|
+
logger.error(f"Failed to create agent due to cycle: {{e}}")
|
|
336
|
+
raise
|
|
337
|
+
except Exception as e:
|
|
338
|
+
logger.exception(f"Error creating agent: {{e}}")
|
|
339
|
+
raise
|
|
340
|
+
finally:
|
|
341
|
+
session.close()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
root_agent = create_agent()
|
|
345
|
+
'''
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# =============================================================================
|
|
349
|
+
# YAML Parsing & Validation
|
|
350
|
+
# =============================================================================
|
|
351
|
+
|
|
352
|
+
def parse_exec_mode(mode_str: str) -> ExecMode:
|
|
353
|
+
"""Parse execution mode from string to enum."""
|
|
354
|
+
if not mode_str:
|
|
355
|
+
return ExecMode.LLM
|
|
356
|
+
mode_key = str(mode_str).upper()
|
|
357
|
+
try:
|
|
358
|
+
return ExecMode[mode_key]
|
|
359
|
+
except KeyError:
|
|
360
|
+
valid = ", ".join([member.name for member in ExecMode])
|
|
361
|
+
raise ValueError(f"Unknown exec_mode '{mode_str}'. Valid options: {valid}")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def load_yaml_config(config_path: Path) -> Dict[str, Any]:
|
|
365
|
+
"""Parse YAML configuration file into tools, skills and agents."""
|
|
366
|
+
with config_path.open("r", encoding="utf-8") as handle:
|
|
367
|
+
docs = [doc for doc in yaml.safe_load_all(handle) if doc]
|
|
368
|
+
|
|
369
|
+
if not docs:
|
|
370
|
+
raise ValueError(f"No YAML documents found in {config_path}")
|
|
371
|
+
|
|
372
|
+
tools: Dict[str, Dict[str, Any]] = {}
|
|
373
|
+
skills: Dict[str, Dict[str, Any]] = {}
|
|
374
|
+
agents: List[Dict[str, Any]] = []
|
|
375
|
+
|
|
376
|
+
for idx, doc in enumerate(docs, start=1):
|
|
377
|
+
if not isinstance(doc, dict):
|
|
378
|
+
raise ValueError(f"Document #{idx} is not a mapping: {doc}")
|
|
379
|
+
|
|
380
|
+
kind = str(doc.get("kind", "")).strip().lower()
|
|
381
|
+
if not kind:
|
|
382
|
+
raise ValueError(f"Document #{idx} is missing 'kind'")
|
|
383
|
+
|
|
384
|
+
name = doc.get("name")
|
|
385
|
+
if not name:
|
|
386
|
+
raise ValueError(f"Document #{idx} ({kind}) is missing 'name'")
|
|
387
|
+
|
|
388
|
+
config = doc.get("config", {}) or {}
|
|
389
|
+
|
|
390
|
+
if kind == "tool":
|
|
391
|
+
# Parse tool definition
|
|
392
|
+
tool_type = doc.get("type", "PYTHON_FUNCTION")
|
|
393
|
+
tool_entry = {
|
|
394
|
+
"description": doc.get("description", ""),
|
|
395
|
+
"type": tool_type,
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
# Add type-specific fields
|
|
399
|
+
if tool_type.upper() == "PYTHON_FUNCTION":
|
|
400
|
+
tool_entry["python_function_name"] = doc.get("python_function_name", name)
|
|
401
|
+
elif tool_type.upper() == "MCP_SERVER":
|
|
402
|
+
tool_entry["mcp_config_name"] = doc.get("mcp_config_name")
|
|
403
|
+
tool_entry["url"] = doc.get("url")
|
|
404
|
+
tool_entry["mcp_config"] = doc.get("mcp_config", {})
|
|
405
|
+
|
|
406
|
+
tools[name] = tool_entry
|
|
407
|
+
|
|
408
|
+
elif kind == "skill":
|
|
409
|
+
exec_mode_str = config.get("exec_mode", "llm")
|
|
410
|
+
exec_mode = parse_exec_mode(exec_mode_str)
|
|
411
|
+
|
|
412
|
+
skills[name] = {
|
|
413
|
+
"description": doc.get("description", ""),
|
|
414
|
+
"instructions": doc.get("instructions", ""),
|
|
415
|
+
"default_model": config.get("default_model")
|
|
416
|
+
or config.get("model")
|
|
417
|
+
or DEFAULT_MODEL,
|
|
418
|
+
"default_model_config": config.get("default_model_config")
|
|
419
|
+
or config.get("model_config")
|
|
420
|
+
or {},
|
|
421
|
+
"temperature": config.get("temperature", 0.2),
|
|
422
|
+
"max_output_tokens": config.get("max_output_tokens", 32000),
|
|
423
|
+
"safety_settings": config.get("safety_settings", []),
|
|
424
|
+
"exec_mode": exec_mode,
|
|
425
|
+
"output_key": config.get("output_key"),
|
|
426
|
+
"tools": doc.get("tools", []) or [],
|
|
427
|
+
"skills": doc.get("skills", []) or [],
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
elif kind == "agent":
|
|
431
|
+
exec_mode_str = config.get("exec_mode", "llm")
|
|
432
|
+
exec_mode = parse_exec_mode(exec_mode_str)
|
|
433
|
+
|
|
434
|
+
agents.append(
|
|
435
|
+
{
|
|
436
|
+
"name": name,
|
|
437
|
+
"description": doc.get("description", ""),
|
|
438
|
+
"instructions": doc.get("instructions", ""),
|
|
439
|
+
"status": doc.get("status", "ACTIVE"),
|
|
440
|
+
"default_model": config.get("default_model")
|
|
441
|
+
or config.get("model")
|
|
442
|
+
or DEFAULT_MODEL,
|
|
443
|
+
"default_model_config": config.get("default_model_config")
|
|
444
|
+
or config.get("model_config")
|
|
445
|
+
or {},
|
|
446
|
+
"temperature": config.get("temperature", 0.2),
|
|
447
|
+
"max_output_tokens": config.get("max_output_tokens", 32000),
|
|
448
|
+
"safety_settings": config.get("safety_settings", []),
|
|
449
|
+
"exec_mode": exec_mode,
|
|
450
|
+
"output_key": config.get("output_key"),
|
|
451
|
+
"skills": doc.get("skills", []) or [],
|
|
452
|
+
}
|
|
453
|
+
)
|
|
454
|
+
else:
|
|
455
|
+
raise ValueError(
|
|
456
|
+
f"Unsupported kind '{doc.get('kind')}' in document #{idx}; "
|
|
457
|
+
"only 'tool', 'skill' and 'agent' are supported."
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
if not agents:
|
|
461
|
+
raise ValueError("At least one 'agent' document is required in the YAML file.")
|
|
462
|
+
|
|
463
|
+
return {"tools": tools, "skills": skills, "agents": agents}
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# =============================================================================
|
|
467
|
+
# Tool Management
|
|
468
|
+
# =============================================================================
|
|
469
|
+
|
|
470
|
+
def get_or_create_tool(session, tool_name: str, tool_def: Dict[str, Any]) -> Tool:
|
|
471
|
+
"""
|
|
472
|
+
Create or update a Tool from YAML definition.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
session: Database session
|
|
476
|
+
tool_name: Name of the tool
|
|
477
|
+
tool_def: Tool definition from YAML with keys:
|
|
478
|
+
- type: PYTHON_FUNCTION, MCP_SERVER, API, DATABASE
|
|
479
|
+
- description: Tool description
|
|
480
|
+
- python_function_name: name to lookup in python_functions table
|
|
481
|
+
- mcp_config_name: name to lookup in mcp_configs table
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
Tool: The created or updated tool
|
|
485
|
+
|
|
486
|
+
Raises:
|
|
487
|
+
ValueError: If referenced python_function or mcp_config not found in DB
|
|
488
|
+
"""
|
|
489
|
+
print(f" š§ Processing tool '{tool_name}'...")
|
|
490
|
+
|
|
491
|
+
# Parse tool type
|
|
492
|
+
type_str = str(tool_def.get("type")).upper()
|
|
493
|
+
try:
|
|
494
|
+
tool_type = ToolType[type_str]
|
|
495
|
+
except KeyError:
|
|
496
|
+
valid = ", ".join([m.name for m in ToolType])
|
|
497
|
+
raise ValueError(f"Unknown tool type '{type_str}'. Valid: {valid}")
|
|
498
|
+
|
|
499
|
+
description = tool_def.get("description") or f"{tool_name} tool"
|
|
500
|
+
|
|
501
|
+
# Get foreign key IDs based on tool type
|
|
502
|
+
python_function_id = None
|
|
503
|
+
mcp_config_id = None
|
|
504
|
+
|
|
505
|
+
if tool_type == ToolType.PYTHON_FUNCTION:
|
|
506
|
+
function_name = tool_def.get("python_function_name") or tool_name
|
|
507
|
+
python_function = (
|
|
508
|
+
session.query(PythonFunction)
|
|
509
|
+
.filter(PythonFunction.name == function_name)
|
|
510
|
+
.first()
|
|
511
|
+
)
|
|
512
|
+
if not python_function:
|
|
513
|
+
raise ValueError(
|
|
514
|
+
f"ā Python function '{function_name}' not found in database.\n"
|
|
515
|
+
f" Run 'wowbits sync functions' to populate python_functions table first."
|
|
516
|
+
)
|
|
517
|
+
python_function_id = python_function.id
|
|
518
|
+
print(f" āā š Found python_function '{function_name}' (id={python_function_id})")
|
|
519
|
+
|
|
520
|
+
elif tool_type == ToolType.MCP_SERVER:
|
|
521
|
+
mcp_name = tool_def.get("mcp_config_name") or tool_name
|
|
522
|
+
mcp_config = (
|
|
523
|
+
session.query(MCPConfig)
|
|
524
|
+
.filter(MCPConfig.name == mcp_name)
|
|
525
|
+
.first()
|
|
526
|
+
)
|
|
527
|
+
if not mcp_config:
|
|
528
|
+
raise ValueError(
|
|
529
|
+
f"ā MCP config '{mcp_name}' not found in database.\n"
|
|
530
|
+
f" Create the MCP config first using 'wowbits create mcp'."
|
|
531
|
+
)
|
|
532
|
+
mcp_config_id = mcp_config.id
|
|
533
|
+
print(f" āā š Found mcp_config '{mcp_name}' (id={mcp_config_id})")
|
|
534
|
+
|
|
535
|
+
# Check if tool already exists
|
|
536
|
+
tool = session.query(Tool).filter(Tool.name == tool_name).first()
|
|
537
|
+
|
|
538
|
+
if not tool:
|
|
539
|
+
# Create new tool
|
|
540
|
+
tool = Tool(
|
|
541
|
+
id=uuid4(),
|
|
542
|
+
name=tool_name,
|
|
543
|
+
description=description,
|
|
544
|
+
type=tool_type,
|
|
545
|
+
python_function_id=python_function_id,
|
|
546
|
+
mcp_config_id=mcp_config_id,
|
|
547
|
+
)
|
|
548
|
+
session.add(tool)
|
|
549
|
+
session.flush()
|
|
550
|
+
print(f" āā ā
Created tool '{tool_name}' (id={tool.id}, type={type_str})")
|
|
551
|
+
else:
|
|
552
|
+
# Update existing tool
|
|
553
|
+
print(f" āā ā¹ļø Tool '{tool_name}' already exists (id={tool.id}), updating...")
|
|
554
|
+
updated_fields = []
|
|
555
|
+
|
|
556
|
+
if tool.description != description:
|
|
557
|
+
tool.description = description
|
|
558
|
+
updated_fields.append("description")
|
|
559
|
+
if tool.type != tool_type:
|
|
560
|
+
tool.type = tool_type
|
|
561
|
+
updated_fields.append("type")
|
|
562
|
+
if tool.python_function_id != python_function_id:
|
|
563
|
+
tool.python_function_id = python_function_id
|
|
564
|
+
updated_fields.append("python_function_id")
|
|
565
|
+
if tool.mcp_config_id != mcp_config_id:
|
|
566
|
+
tool.mcp_config_id = mcp_config_id
|
|
567
|
+
updated_fields.append("mcp_config_id")
|
|
568
|
+
|
|
569
|
+
if updated_fields:
|
|
570
|
+
session.flush()
|
|
571
|
+
print(f" āā ā
Updated tool '{tool_name}' (fields: {', '.join(updated_fields)})")
|
|
572
|
+
else:
|
|
573
|
+
print(f" āā ā¹ļø Tool '{tool_name}' unchanged")
|
|
574
|
+
|
|
575
|
+
return tool
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# =============================================================================
|
|
579
|
+
# Skill Management
|
|
580
|
+
# =============================================================================
|
|
581
|
+
|
|
582
|
+
def get_or_create_skill(
|
|
583
|
+
session,
|
|
584
|
+
skill_name: str,
|
|
585
|
+
skill_config: Dict[str, Any],
|
|
586
|
+
all_skills: Dict[str, Dict[str, Any]],
|
|
587
|
+
skill_registry: Dict[str, Skill],
|
|
588
|
+
all_tools: Dict[str, Dict[str, Any]] = None,
|
|
589
|
+
depth: int = 0,
|
|
590
|
+
visiting_path: List[str] = None,
|
|
591
|
+
) -> Skill:
|
|
592
|
+
"""
|
|
593
|
+
Recursively upsert a skill and its child skills with PER-PATH cycle detection.
|
|
594
|
+
|
|
595
|
+
Relationship Storage Strategy:
|
|
596
|
+
- ALL child relationships ā skill_skills table (always)
|
|
597
|
+
- SEQUENTIAL mode only ā ALSO add to sequential_skill_exec_order (for ordering)
|
|
598
|
+
|
|
599
|
+
This means:
|
|
600
|
+
- skill_skills contains ALL parent-child relationships
|
|
601
|
+
- sequential_skill_exec_order contains ONLY sequential relationships with order info
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
session: Database session
|
|
605
|
+
skill_name: Name of the skill to process
|
|
606
|
+
skill_config: Configuration dict for this skill from YAML
|
|
607
|
+
all_skills: Complete dict of all skills from YAML
|
|
608
|
+
skill_registry: Cache of already-built skills (allows DAG reuse)
|
|
609
|
+
depth: Current recursion depth (for logging)
|
|
610
|
+
visiting_path: List of skill names in current path (detects cycles)
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
Skill object (created or retrieved from cache)
|
|
614
|
+
|
|
615
|
+
Raises:
|
|
616
|
+
ValueError: If a cycle is detected in the skill hierarchy
|
|
617
|
+
"""
|
|
618
|
+
if visiting_path is None:
|
|
619
|
+
visiting_path = []
|
|
620
|
+
|
|
621
|
+
indent = " " * depth
|
|
622
|
+
|
|
623
|
+
# š„ CYCLE DETECTION: Check if skill appears in CURRENT PATH
|
|
624
|
+
if skill_name in visiting_path:
|
|
625
|
+
cycle = " -> ".join(visiting_path) + f" -> {skill_name}"
|
|
626
|
+
raise ValueError(
|
|
627
|
+
f"ā Cycle detected in skill hierarchy:\n"
|
|
628
|
+
f" Path: {cycle}\n"
|
|
629
|
+
f" Skills cannot reference their ancestors."
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
# ā
Check cache (allows skill reuse in different branches)
|
|
633
|
+
if skill_name in skill_registry:
|
|
634
|
+
print(f"{indent}ā»ļø Skill '{skill_name}' already processed (reusing)")
|
|
635
|
+
return skill_registry[skill_name]
|
|
636
|
+
|
|
637
|
+
print(
|
|
638
|
+
f"{indent}š¦ Processing skill '{skill_name}' "
|
|
639
|
+
f"[exec_mode={skill_config.get('exec_mode', ExecMode.LLM).value}]"
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
skill = session.query(Skill).filter(Skill.name == skill_name).first()
|
|
643
|
+
|
|
644
|
+
standard_fields = {
|
|
645
|
+
"description": skill_config.get("description", ""),
|
|
646
|
+
"instructions": skill_config.get("instructions", ""),
|
|
647
|
+
"default_model": skill_config.get("default_model", DEFAULT_MODEL),
|
|
648
|
+
"default_model_config": skill_config.get("default_model_config", {}),
|
|
649
|
+
"temperature": skill_config.get("temperature", 0.2),
|
|
650
|
+
"max_output_tokens": skill_config.get("max_output_tokens", 32000),
|
|
651
|
+
"safety_settings": skill_config.get("safety_settings", []),
|
|
652
|
+
"exec_mode": skill_config.get("exec_mode", ExecMode.LLM),
|
|
653
|
+
"output_key": skill_config.get("output_key"),
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if not skill:
|
|
657
|
+
skill = Skill(id=uuid4(), name=skill_name, **standard_fields)
|
|
658
|
+
session.add(skill)
|
|
659
|
+
session.flush()
|
|
660
|
+
print(f"{indent} ā
Created skill '{skill_name}' ({skill.id})")
|
|
661
|
+
else:
|
|
662
|
+
updated = False
|
|
663
|
+
for field, value in standard_fields.items():
|
|
664
|
+
if getattr(skill, field) != value:
|
|
665
|
+
setattr(skill, field, value)
|
|
666
|
+
updated = True
|
|
667
|
+
if updated:
|
|
668
|
+
session.flush()
|
|
669
|
+
print(f"{indent} ā
Updated skill '{skill_name}' ({skill.id})")
|
|
670
|
+
else:
|
|
671
|
+
print(f"{indent} ā¹ļø Skill '{skill_name}' unchanged")
|
|
672
|
+
|
|
673
|
+
skill_registry[skill_name] = skill
|
|
674
|
+
|
|
675
|
+
# ========================================
|
|
676
|
+
# TOOL ASSOCIATIONS
|
|
677
|
+
# ========================================
|
|
678
|
+
deleted_tools = (
|
|
679
|
+
session.query(SkillTool)
|
|
680
|
+
.filter(SkillTool.skill_id == skill.id)
|
|
681
|
+
.delete(synchronize_session=False)
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
if deleted_tools > 0:
|
|
685
|
+
print(f"{indent} šļø Cleared {deleted_tools} old tool associations")
|
|
686
|
+
|
|
687
|
+
tools = skill_config.get("tools", []) or []
|
|
688
|
+
if all_tools is None:
|
|
689
|
+
all_tools = {}
|
|
690
|
+
|
|
691
|
+
if tools:
|
|
692
|
+
print(f"{indent} š§ Linking {len(tools)} tools...")
|
|
693
|
+
|
|
694
|
+
for tool_name in tools:
|
|
695
|
+
# Check if tool is defined in YAML (kind: tool)
|
|
696
|
+
tool_def = all_tools.get(tool_name)
|
|
697
|
+
|
|
698
|
+
if tool_def:
|
|
699
|
+
# Tool is defined in YAML, create/update it
|
|
700
|
+
tool = get_or_create_tool(session, tool_name, tool_def)
|
|
701
|
+
else:
|
|
702
|
+
# Tool not defined in YAML, check if it exists in DB
|
|
703
|
+
tool = session.query(Tool).filter(Tool.name == tool_name).first()
|
|
704
|
+
if not tool:
|
|
705
|
+
raise ValueError(
|
|
706
|
+
f"ā Tool '{tool_name}' referenced by skill '{skill_name}' not found.\n"
|
|
707
|
+
f" Either define it in the YAML file (kind: tool) or ensure it exists in the database."
|
|
708
|
+
)
|
|
709
|
+
print(f"{indent} āā š§ Found existing tool '{tool_name}' in DB")
|
|
710
|
+
|
|
711
|
+
session.add(SkillTool(skill_id=skill.id, tool_id=tool.id))
|
|
712
|
+
print(f"{indent} āā ā
Linked tool '{tool_name}' to skill '{skill_name}'")
|
|
713
|
+
|
|
714
|
+
# ========================================
|
|
715
|
+
# CHILD SKILL RELATIONSHIPS (with cycle detection)
|
|
716
|
+
# ========================================
|
|
717
|
+
child_skill_names = skill_config.get("skills", []) or []
|
|
718
|
+
|
|
719
|
+
if child_skill_names:
|
|
720
|
+
exec_mode = skill_config.get("exec_mode", ExecMode.LLM)
|
|
721
|
+
print(f"{indent} š³ Processing {len(child_skill_names)} child skills...")
|
|
722
|
+
|
|
723
|
+
# š„ ALWAYS clear BOTH tables (handles exec_mode changes)
|
|
724
|
+
deleted_sequential = (
|
|
725
|
+
session.query(SequentialSkillExecOrder)
|
|
726
|
+
.filter(SequentialSkillExecOrder.parent_skill_id == skill.id)
|
|
727
|
+
.delete(synchronize_session=False)
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
deleted_skill_skill = (
|
|
731
|
+
session.query(SkillSkill)
|
|
732
|
+
.filter(SkillSkill.parent_skill_id == skill.id)
|
|
733
|
+
.delete(synchronize_session=False)
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
if deleted_sequential > 0 or deleted_skill_skill > 0:
|
|
737
|
+
print(
|
|
738
|
+
f"{indent} šļø Cleared {deleted_sequential} sequential + "
|
|
739
|
+
f"{deleted_skill_skill} skill_skill relationships"
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
# š„ Add current skill to path before processing children
|
|
743
|
+
visiting_path.append(skill_name)
|
|
744
|
+
|
|
745
|
+
try:
|
|
746
|
+
# Process each child skill
|
|
747
|
+
for idx, child_skill_name in enumerate(child_skill_names, start=1):
|
|
748
|
+
if child_skill_name not in all_skills:
|
|
749
|
+
print(
|
|
750
|
+
f"{indent} ā ļø Warning: Child skill '{child_skill_name}' not defined in YAML"
|
|
751
|
+
)
|
|
752
|
+
continue
|
|
753
|
+
|
|
754
|
+
# š„ Pass visiting_path.copy() to detect cycles in THIS path
|
|
755
|
+
# (copy allows different branches to reuse skills)
|
|
756
|
+
child_skill = get_or_create_skill(
|
|
757
|
+
session,
|
|
758
|
+
child_skill_name,
|
|
759
|
+
all_skills[child_skill_name],
|
|
760
|
+
all_skills,
|
|
761
|
+
skill_registry,
|
|
762
|
+
all_tools,
|
|
763
|
+
depth + 1,
|
|
764
|
+
visiting_path.copy(), # Pass copy to allow branching
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
# š KEY CHANGE: ALWAYS create skill_skills entry for ALL exec_modes
|
|
768
|
+
skill_skill = SkillSkill(
|
|
769
|
+
id=uuid4(), parent_skill_id=skill.id, child_skill_id=child_skill.id
|
|
770
|
+
)
|
|
771
|
+
session.add(skill_skill)
|
|
772
|
+
|
|
773
|
+
# š For SEQUENTIAL mode, ALSO add ordering information
|
|
774
|
+
if exec_mode == ExecMode.SEQUENTIAL:
|
|
775
|
+
seq_order = SequentialSkillExecOrder(
|
|
776
|
+
id=uuid4(),
|
|
777
|
+
parent_skill_id=skill.id,
|
|
778
|
+
child_skill_id=child_skill.id,
|
|
779
|
+
sequence_num=idx,
|
|
780
|
+
)
|
|
781
|
+
session.add(seq_order)
|
|
782
|
+
print(
|
|
783
|
+
f"{indent} āā āļø Sequential #{idx}: '{child_skill_name}' "
|
|
784
|
+
f"(stored in skill_skills + sequential_skill_exec_order)"
|
|
785
|
+
)
|
|
786
|
+
elif exec_mode == ExecMode.PARALLEL:
|
|
787
|
+
print(
|
|
788
|
+
f"{indent} āā ā” Parallel: '{child_skill_name}' "
|
|
789
|
+
f"(stored in skill_skills only)"
|
|
790
|
+
)
|
|
791
|
+
else: # LLM
|
|
792
|
+
print(
|
|
793
|
+
f"{indent} āā š¤ LLM sub-skill: '{child_skill_name}' "
|
|
794
|
+
f"(stored in skill_skills only)"
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
finally:
|
|
798
|
+
# š„ IMPORTANT: Remove current skill from path after processing
|
|
799
|
+
# (backtracking for DFS)
|
|
800
|
+
visiting_path.pop()
|
|
801
|
+
|
|
802
|
+
session.flush()
|
|
803
|
+
return skill
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
# =============================================================================
|
|
807
|
+
# Agent Management
|
|
808
|
+
# =============================================================================
|
|
809
|
+
|
|
810
|
+
def coerce_agent_status(status_value: Any) -> AgentStatus:
|
|
811
|
+
"""Convert status value to AgentStatus enum."""
|
|
812
|
+
if isinstance(status_value, AgentStatus):
|
|
813
|
+
return status_value
|
|
814
|
+
if not status_value:
|
|
815
|
+
return AgentStatus.ACTIVE
|
|
816
|
+
status_key = str(status_value).upper()
|
|
817
|
+
try:
|
|
818
|
+
return AgentStatus[status_key]
|
|
819
|
+
except KeyError as exc:
|
|
820
|
+
valid = ", ".join([member.name for member in AgentStatus])
|
|
821
|
+
raise ValueError(
|
|
822
|
+
f"Unknown agent status '{status_value}'. Valid options: {valid}"
|
|
823
|
+
) from exc
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def upsert_agent(
|
|
827
|
+
session, agent_config: Dict[str, Any], skill_registry: Dict[str, Skill]
|
|
828
|
+
) -> Agent:
|
|
829
|
+
"""
|
|
830
|
+
Create or update an agent and its skill associations.
|
|
831
|
+
|
|
832
|
+
Relationship Storage Strategy:
|
|
833
|
+
- ALL skill relationships ā agent_skills table (always)
|
|
834
|
+
- SEQUENTIAL mode only ā ALSO add to sequential_agent_exec_order (for ordering)
|
|
835
|
+
"""
|
|
836
|
+
agent_name = agent_config["name"]
|
|
837
|
+
print(
|
|
838
|
+
f"\nšÆ Processing agent '{agent_name}' "
|
|
839
|
+
f"[exec_mode={agent_config.get('exec_mode', ExecMode.LLM).value}]"
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
agent = session.query(Agent).filter(Agent.name == agent_name).first()
|
|
843
|
+
|
|
844
|
+
standard_fields = {
|
|
845
|
+
"description": agent_config.get("description", ""),
|
|
846
|
+
"instructions": agent_config.get("instructions", ""),
|
|
847
|
+
"status": coerce_agent_status(agent_config.get("status")),
|
|
848
|
+
"default_model": agent_config.get("default_model", DEFAULT_MODEL),
|
|
849
|
+
"default_model_config": agent_config.get("default_model_config", {}),
|
|
850
|
+
"temperature": agent_config.get("temperature", 0.2),
|
|
851
|
+
"max_output_tokens": agent_config.get("max_output_tokens", 32000),
|
|
852
|
+
"safety_settings": agent_config.get("safety_settings", []),
|
|
853
|
+
"exec_mode": agent_config.get("exec_mode", ExecMode.LLM),
|
|
854
|
+
"output_key": agent_config.get("output_key"),
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if not agent:
|
|
858
|
+
agent = Agent(id=uuid4(), name=agent_name, **standard_fields)
|
|
859
|
+
session.add(agent)
|
|
860
|
+
session.flush()
|
|
861
|
+
print(f" ā
Created agent '{agent_name}' ({agent.id})")
|
|
862
|
+
else:
|
|
863
|
+
updated = False
|
|
864
|
+
for field, value in standard_fields.items():
|
|
865
|
+
if getattr(agent, field) != value:
|
|
866
|
+
setattr(agent, field, value)
|
|
867
|
+
updated = True
|
|
868
|
+
if updated:
|
|
869
|
+
session.flush()
|
|
870
|
+
print(f" ā
Updated agent '{agent_name}' ({agent.id})")
|
|
871
|
+
else:
|
|
872
|
+
print(f" ā¹ļø Agent '{agent_name}' unchanged")
|
|
873
|
+
|
|
874
|
+
# ========================================
|
|
875
|
+
# SKILL ASSOCIATIONS
|
|
876
|
+
# ========================================
|
|
877
|
+
# š„ ALWAYS clear BOTH tables
|
|
878
|
+
deleted_agent_skills = (
|
|
879
|
+
session.query(AgentSkill)
|
|
880
|
+
.filter(AgentSkill.agent_id == agent.id)
|
|
881
|
+
.delete(synchronize_session=False)
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
deleted_sequential = (
|
|
885
|
+
session.query(SequentialAgentExecOrder)
|
|
886
|
+
.filter(SequentialAgentExecOrder.agent_id == agent.id)
|
|
887
|
+
.delete(synchronize_session=False)
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
if deleted_agent_skills > 0 or deleted_sequential > 0:
|
|
891
|
+
print(
|
|
892
|
+
f" šļø Cleared {deleted_agent_skills} agent_skill + "
|
|
893
|
+
f"{deleted_sequential} sequential_order relationships"
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
exec_mode = agent_config.get("exec_mode", ExecMode.LLM)
|
|
897
|
+
skill_names = agent_config.get("skills", [])
|
|
898
|
+
print(f" š Linking {len(skill_names)} skills...")
|
|
899
|
+
|
|
900
|
+
for idx, skill_name in enumerate(skill_names, start=1):
|
|
901
|
+
# First check skill_registry (skills defined in current YAML)
|
|
902
|
+
skill = skill_registry.get(skill_name)
|
|
903
|
+
|
|
904
|
+
if not skill:
|
|
905
|
+
# Check if skill exists in database
|
|
906
|
+
skill = session.query(Skill).filter(Skill.name == skill_name).first()
|
|
907
|
+
|
|
908
|
+
if not skill:
|
|
909
|
+
raise ValueError(
|
|
910
|
+
f"ā Skill '{skill_name}' referenced by agent '{agent_name}' not found.\n"
|
|
911
|
+
f" Either define it in the YAML file (kind: skill) or ensure it exists in the database."
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
# š ALWAYS create AgentSkill link for ALL exec_modes
|
|
915
|
+
session.add(AgentSkill(agent_id=agent.id, skill_id=skill.id))
|
|
916
|
+
|
|
917
|
+
# š For SEQUENTIAL mode, ALSO add ordering information
|
|
918
|
+
if exec_mode == ExecMode.SEQUENTIAL:
|
|
919
|
+
seq_order = SequentialAgentExecOrder(
|
|
920
|
+
id=uuid4(), agent_id=agent.id, skill_id=skill.id, sequence_num=idx
|
|
921
|
+
)
|
|
922
|
+
session.add(seq_order)
|
|
923
|
+
print(
|
|
924
|
+
f" āā āļø Sequential #{idx}: '{skill_name}' "
|
|
925
|
+
f"(stored in agent_skills + sequential_agent_exec_order)"
|
|
926
|
+
)
|
|
927
|
+
elif exec_mode == ExecMode.PARALLEL:
|
|
928
|
+
print(
|
|
929
|
+
f" āā ā” Parallel: '{skill_name}' " f"(stored in agent_skills only)"
|
|
930
|
+
)
|
|
931
|
+
else: # LLM
|
|
932
|
+
print(f" āā š¤ LLM: '{skill_name}' " f"(stored in agent_skills only)")
|
|
933
|
+
|
|
934
|
+
session.flush()
|
|
935
|
+
return agent
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
# =============================================================================
|
|
941
|
+
# CLI Functions
|
|
942
|
+
# =============================================================================
|
|
943
|
+
|
|
944
|
+
def get_agent_studio_dir() -> Optional[Path]:
|
|
945
|
+
"""
|
|
946
|
+
Get the agent_studio directory from WOWBITS_ROOT_DIR environment variable.
|
|
947
|
+
|
|
948
|
+
Returns:
|
|
949
|
+
Path to the agent_studio directory or None if not set
|
|
950
|
+
"""
|
|
951
|
+
root_dir = os.environ.get("WOWBITS_ROOT_DIR")
|
|
952
|
+
|
|
953
|
+
if not root_dir:
|
|
954
|
+
# Try to load from .env file
|
|
955
|
+
try:
|
|
956
|
+
from dotenv import load_dotenv
|
|
957
|
+
load_dotenv()
|
|
958
|
+
root_dir = os.environ.get("WOWBITS_ROOT_DIR")
|
|
959
|
+
except ImportError:
|
|
960
|
+
pass
|
|
961
|
+
|
|
962
|
+
if not root_dir:
|
|
963
|
+
print("ā Error: WOWBITS_ROOT_DIR environment variable is not set.")
|
|
964
|
+
print(" Please run 'wowbits setup' first or set the environment variable.")
|
|
965
|
+
return None
|
|
966
|
+
|
|
967
|
+
return Path(root_dir) / "agent_studio"
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def create_agent(agent_name: str, config_path: Optional[str] = None) -> Tuple[List[str], List[str]]:
|
|
971
|
+
"""
|
|
972
|
+
Create an agent from a YAML configuration file.
|
|
973
|
+
|
|
974
|
+
The YAML file should contain tool, skill, and agent definitions separated by '---'.
|
|
975
|
+
Processing order: tools ā skills ā agents
|
|
976
|
+
|
|
977
|
+
Args:
|
|
978
|
+
agent_name: Name of the agent to create
|
|
979
|
+
config_path: Optional custom path to the YAML config file.
|
|
980
|
+
If not provided, looks for WOWBITS_ROOT_DIR/agent_studio/<agent_name>.yaml
|
|
981
|
+
|
|
982
|
+
Returns:
|
|
983
|
+
Tuple of (created_skills, affected_agents)
|
|
984
|
+
"""
|
|
985
|
+
print("\nāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā")
|
|
986
|
+
print("ā Create Agent from YAML ā")
|
|
987
|
+
print("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā\n")
|
|
988
|
+
|
|
989
|
+
# Determine the config file path
|
|
990
|
+
if config_path:
|
|
991
|
+
yaml_path = Path(config_path).expanduser().resolve()
|
|
992
|
+
else:
|
|
993
|
+
# Use default path: WOWBITS_ROOT_DIR/agent_studio/<agent_name>.yaml
|
|
994
|
+
agent_studio_dir = get_agent_studio_dir()
|
|
995
|
+
if not agent_studio_dir:
|
|
996
|
+
sys.exit(1)
|
|
997
|
+
yaml_path = agent_studio_dir / f"{agent_name}.yaml"
|
|
998
|
+
|
|
999
|
+
# Validate the file exists
|
|
1000
|
+
if not yaml_path.exists():
|
|
1001
|
+
print(f"ā Error: Configuration file not found: {yaml_path}")
|
|
1002
|
+
if not config_path:
|
|
1003
|
+
print(f"\n Expected location: WOWBITS_ROOT_DIR/agent_studio/{agent_name}.yaml")
|
|
1004
|
+
print(" You can also specify a custom path with -c <path>")
|
|
1005
|
+
sys.exit(1)
|
|
1006
|
+
|
|
1007
|
+
print(f"š Loading agent configuration from: {yaml_path}\n")
|
|
1008
|
+
|
|
1009
|
+
# Parse YAML config
|
|
1010
|
+
try:
|
|
1011
|
+
config = load_yaml_config(yaml_path)
|
|
1012
|
+
except ValueError as e:
|
|
1013
|
+
print(f"ā YAML parsing error: {e}")
|
|
1014
|
+
sys.exit(1)
|
|
1015
|
+
|
|
1016
|
+
# Get database session
|
|
1017
|
+
db_manager = get_db_manager()
|
|
1018
|
+
session = db_manager.get_session()
|
|
1019
|
+
|
|
1020
|
+
created_tools: List[str] = []
|
|
1021
|
+
created_skills: List[str] = []
|
|
1022
|
+
affected_agents: List[str] = []
|
|
1023
|
+
|
|
1024
|
+
try:
|
|
1025
|
+
# =============================================
|
|
1026
|
+
# PHASE 1: Process Tools
|
|
1027
|
+
# =============================================
|
|
1028
|
+
all_tools = config.get("tools", {})
|
|
1029
|
+
if all_tools:
|
|
1030
|
+
print(f"š§ PHASE 1: Processing {len(all_tools)} tools...")
|
|
1031
|
+
print("-" * 60)
|
|
1032
|
+
|
|
1033
|
+
for tool_name, tool_def in all_tools.items():
|
|
1034
|
+
tool = get_or_create_tool(session, tool_name, tool_def)
|
|
1035
|
+
created_tools.append(tool.name)
|
|
1036
|
+
|
|
1037
|
+
print()
|
|
1038
|
+
|
|
1039
|
+
# =============================================
|
|
1040
|
+
# PHASE 2: Process Skills
|
|
1041
|
+
# =============================================
|
|
1042
|
+
all_skills = config.get("skills", {})
|
|
1043
|
+
skill_registry: Dict[str, Skill] = {}
|
|
1044
|
+
|
|
1045
|
+
if all_skills:
|
|
1046
|
+
print(f"š PHASE 2: Processing {len(all_skills)} skills...")
|
|
1047
|
+
print("-" * 60)
|
|
1048
|
+
|
|
1049
|
+
for skill_name, skill_config in all_skills.items():
|
|
1050
|
+
if skill_name not in skill_registry:
|
|
1051
|
+
skill = get_or_create_skill(
|
|
1052
|
+
session=session,
|
|
1053
|
+
skill_name=skill_name,
|
|
1054
|
+
skill_config=skill_config,
|
|
1055
|
+
all_skills=all_skills,
|
|
1056
|
+
skill_registry=skill_registry,
|
|
1057
|
+
all_tools=all_tools,
|
|
1058
|
+
depth=0,
|
|
1059
|
+
visiting_path=[],
|
|
1060
|
+
)
|
|
1061
|
+
created_skills.append(skill.name)
|
|
1062
|
+
|
|
1063
|
+
print()
|
|
1064
|
+
|
|
1065
|
+
# =============================================
|
|
1066
|
+
# PHASE 3: Process Agents
|
|
1067
|
+
# =============================================
|
|
1068
|
+
agents_config = config.get("agents", [])
|
|
1069
|
+
if agents_config:
|
|
1070
|
+
print(f"šÆ PHASE 3: Processing {len(agents_config)} agents...")
|
|
1071
|
+
print("-" * 60)
|
|
1072
|
+
|
|
1073
|
+
for agent_config in agents_config:
|
|
1074
|
+
agent = upsert_agent(session, agent_config, skill_registry)
|
|
1075
|
+
affected_agents.append(agent.name)
|
|
1076
|
+
|
|
1077
|
+
# Commit all changes
|
|
1078
|
+
session.commit()
|
|
1079
|
+
|
|
1080
|
+
# Print summary
|
|
1081
|
+
print("\n" + "=" * 60)
|
|
1082
|
+
print("ā
Agent creation completed successfully!")
|
|
1083
|
+
print("=" * 60)
|
|
1084
|
+
print(f"š Summary:")
|
|
1085
|
+
print(f" ⢠Tools processed: {len(created_tools)}")
|
|
1086
|
+
print(f" ⢠Skills processed: {len(created_skills)}")
|
|
1087
|
+
print(f" ⢠Agents processed: {len(affected_agents)}")
|
|
1088
|
+
print("=" * 60 + "\n")
|
|
1089
|
+
|
|
1090
|
+
return created_skills, affected_agents
|
|
1091
|
+
|
|
1092
|
+
except ValueError as e:
|
|
1093
|
+
session.rollback()
|
|
1094
|
+
print(f"\nā Validation Error: {e}")
|
|
1095
|
+
print(" All changes have been rolled back.")
|
|
1096
|
+
sys.exit(1)
|
|
1097
|
+
except Exception as e:
|
|
1098
|
+
session.rollback()
|
|
1099
|
+
print(f"\nā Error during agent creation: {e}")
|
|
1100
|
+
print(" All changes have been rolled back.")
|
|
1101
|
+
raise
|
|
1102
|
+
finally:
|
|
1103
|
+
session.close()
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def list_agents() -> None:
|
|
1108
|
+
"""List all agents in the database."""
|
|
1109
|
+
print("\nāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā")
|
|
1110
|
+
print("ā Agents ā")
|
|
1111
|
+
print("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā\n")
|
|
1112
|
+
|
|
1113
|
+
try:
|
|
1114
|
+
with get_session_context() as session:
|
|
1115
|
+
agents = session.query(Agent).all()
|
|
1116
|
+
|
|
1117
|
+
if not agents:
|
|
1118
|
+
print(" ā¹ļø No agents found in database")
|
|
1119
|
+
return
|
|
1120
|
+
|
|
1121
|
+
print(f"Found {len(agents)} agent(s):\n")
|
|
1122
|
+
print(f"{'Name':<30} {'Status':<12} {'Exec Mode':<12} {'Description':<30}")
|
|
1123
|
+
print("-" * 84)
|
|
1124
|
+
|
|
1125
|
+
for agent in agents:
|
|
1126
|
+
name = agent.name[:28] + ".." if len(agent.name) > 30 else agent.name
|
|
1127
|
+
status = agent.status.value if agent.status else "-"
|
|
1128
|
+
exec_mode = agent.exec_mode.value if agent.exec_mode else "-"
|
|
1129
|
+
desc = (agent.description or "")[:28] + ".." if len(agent.description or "") > 30 else (agent.description or "-")
|
|
1130
|
+
print(f"{name:<30} {status:<12} {exec_mode:<12} {desc:<30}")
|
|
1131
|
+
|
|
1132
|
+
print()
|
|
1133
|
+
|
|
1134
|
+
except Exception as e:
|
|
1135
|
+
print(f"\nā Error listing agents: {e}")
|
|
1136
|
+
raise
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def get_agent_runner_dir() -> Optional[Path]:
|
|
1140
|
+
"""
|
|
1141
|
+
Get the agent_runner directory from WOWBITS_ROOT_DIR environment variable.
|
|
1142
|
+
|
|
1143
|
+
Returns:
|
|
1144
|
+
Path to the agent_runner directory or None if not set
|
|
1145
|
+
"""
|
|
1146
|
+
root_dir = os.environ.get("WOWBITS_ROOT_DIR")
|
|
1147
|
+
|
|
1148
|
+
if not root_dir:
|
|
1149
|
+
# Try to load from .env file
|
|
1150
|
+
try:
|
|
1151
|
+
from dotenv import load_dotenv
|
|
1152
|
+
load_dotenv()
|
|
1153
|
+
root_dir = os.environ.get("WOWBITS_ROOT_DIR")
|
|
1154
|
+
except ImportError:
|
|
1155
|
+
pass
|
|
1156
|
+
|
|
1157
|
+
if not root_dir:
|
|
1158
|
+
print("ā Error: WOWBITS_ROOT_DIR environment variable is not set.")
|
|
1159
|
+
print(" Please run 'wowbits setup' first or set the environment variable.")
|
|
1160
|
+
return None
|
|
1161
|
+
|
|
1162
|
+
return Path(root_dir) / "agent_runner"
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def create_agent_code(agent_id: str, agent_name: str, agent_runner_path: Path) -> Path:
|
|
1166
|
+
"""
|
|
1167
|
+
Create the agent.py file for an agent in the agent_runner directory.
|
|
1168
|
+
|
|
1169
|
+
Args:
|
|
1170
|
+
agent_id: UUID of the agent
|
|
1171
|
+
agent_name: Name of the agent
|
|
1172
|
+
agent_runner_path: Path to the agent_runner directory
|
|
1173
|
+
|
|
1174
|
+
Returns:
|
|
1175
|
+
Path to the created agent folder
|
|
1176
|
+
"""
|
|
1177
|
+
# Sanitize folder name
|
|
1178
|
+
safe_folder_name = _sanitize_folder_name(agent_name)
|
|
1179
|
+
agent_folder = agent_runner_path / safe_folder_name
|
|
1180
|
+
|
|
1181
|
+
# Create agent folder
|
|
1182
|
+
agent_folder.mkdir(parents=True, exist_ok=True)
|
|
1183
|
+
|
|
1184
|
+
# Generate agent.py from template
|
|
1185
|
+
agent_code = AGENT_CODE_TEMPLATE.format(agent_id=agent_id)
|
|
1186
|
+
|
|
1187
|
+
# Write agent.py file
|
|
1188
|
+
agent_file_path = agent_folder / "agent.py"
|
|
1189
|
+
with open(agent_file_path, "w") as f:
|
|
1190
|
+
f.write(agent_code)
|
|
1191
|
+
|
|
1192
|
+
print(f"ā
Generated agent code: {agent_file_path}")
|
|
1193
|
+
return agent_folder
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def run_agent(agent_name: str, exec_mode: str = "web") -> None:
|
|
1197
|
+
"""
|
|
1198
|
+
Run an agent in the specified execution mode.
|
|
1199
|
+
|
|
1200
|
+
Args:
|
|
1201
|
+
agent_name: Name of the agent to run
|
|
1202
|
+
exec_mode: Execution mode - 'web' or 'api'
|
|
1203
|
+
"""
|
|
1204
|
+
print(f"\nš Running agent '{agent_name}' in {exec_mode} mode...\n")
|
|
1205
|
+
|
|
1206
|
+
if exec_mode not in ["web", "api"]:
|
|
1207
|
+
print(f"ā Invalid exec_mode '{exec_mode}'. Must be 'web' or 'api'")
|
|
1208
|
+
sys.exit(1)
|
|
1209
|
+
|
|
1210
|
+
try:
|
|
1211
|
+
# Get agent_runner directory from WOWBITS_ROOT_DIR
|
|
1212
|
+
agent_runner_path = get_agent_runner_dir()
|
|
1213
|
+
if not agent_runner_path:
|
|
1214
|
+
sys.exit(1)
|
|
1215
|
+
|
|
1216
|
+
# Create agent_runner directory if it doesn't exist
|
|
1217
|
+
agent_runner_path.mkdir(parents=True, exist_ok=True)
|
|
1218
|
+
|
|
1219
|
+
with get_session_context() as session:
|
|
1220
|
+
# Verify agent exists
|
|
1221
|
+
agent = session.query(Agent).filter(Agent.name == agent_name).first()
|
|
1222
|
+
|
|
1223
|
+
if not agent:
|
|
1224
|
+
print(f"ā Agent '{agent_name}' not found in database")
|
|
1225
|
+
print(" Run 'wowbits list agents' to see available agents")
|
|
1226
|
+
sys.exit(1)
|
|
1227
|
+
|
|
1228
|
+
print(f"ā
Found agent '{agent_name}' (ID: {agent.id})")
|
|
1229
|
+
|
|
1230
|
+
# Create agent code in agent_runner directory
|
|
1231
|
+
agent_folder = create_agent_code(str(agent.id), agent.name, agent_runner_path)
|
|
1232
|
+
|
|
1233
|
+
if exec_mode == "web":
|
|
1234
|
+
print(f"\nš Starting ADK web server...")
|
|
1235
|
+
print(f" Working directory: {agent_runner_path}")
|
|
1236
|
+
|
|
1237
|
+
# Change to agent_runner directory and run adk web command
|
|
1238
|
+
import subprocess
|
|
1239
|
+
result = subprocess.run(
|
|
1240
|
+
["adk", "web"],
|
|
1241
|
+
cwd=str(agent_runner_path),
|
|
1242
|
+
check=False
|
|
1243
|
+
)
|
|
1244
|
+
|
|
1245
|
+
if result.returncode != 0:
|
|
1246
|
+
print(f"\nā ADK web command failed with exit code {result.returncode}")
|
|
1247
|
+
sys.exit(result.returncode)
|
|
1248
|
+
|
|
1249
|
+
elif exec_mode == "api":
|
|
1250
|
+
print(f"\nš API mode not yet implemented")
|
|
1251
|
+
sys.exit(1)
|
|
1252
|
+
|
|
1253
|
+
except Exception as e:
|
|
1254
|
+
print(f"ā Error: {e}")
|
|
1255
|
+
sys.exit(1)
|
|
1256
|
+
|
|
1257
|
+
def _sanitize_folder_name(name: str) -> str:
|
|
1258
|
+
"""Convert agent name to safe filesystem folder name."""
|
|
1259
|
+
import re
|
|
1260
|
+
safe_name = re.sub(r"[^\w\s-]", "", name)
|
|
1261
|
+
safe_name = re.sub(r"[-\s]+", "_", safe_name)
|
|
1262
|
+
safe_name = safe_name.lower().strip("_")
|
|
1263
|
+
return safe_name if safe_name else "unnamed_agent"
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
if __name__ == "__main__":
|
|
1267
|
+
# For testing purposes
|
|
1268
|
+
import argparse
|
|
1269
|
+
|
|
1270
|
+
parser = argparse.ArgumentParser(description="Manage agents")
|
|
1271
|
+
subparsers = parser.add_subparsers(dest="action", help="Action to perform")
|
|
1272
|
+
|
|
1273
|
+
# create subcommand
|
|
1274
|
+
create_parser = subparsers.add_parser("create", help="Create an agent from YAML")
|
|
1275
|
+
create_parser.add_argument("name", help="Agent name")
|
|
1276
|
+
create_parser.add_argument("-c", "--config", help="Custom path to YAML config file")
|
|
1277
|
+
|
|
1278
|
+
# list subcommand
|
|
1279
|
+
subparsers.add_parser("list", help="List all agents")
|
|
1280
|
+
|
|
1281
|
+
args = parser.parse_args()
|
|
1282
|
+
|
|
1283
|
+
if args.action == "create":
|
|
1284
|
+
create_agent(args.name, args.config)
|
|
1285
|
+
elif args.action == "list":
|
|
1286
|
+
list_agents()
|
|
1287
|
+
else:
|
|
1288
|
+
parser.print_help()
|