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/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()