agent-assembler 0.2.0.dev1__tar.gz
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.
- agent_assembler-0.2.0.dev1/LICENSE +22 -0
- agent_assembler-0.2.0.dev1/MANIFEST.in +6 -0
- agent_assembler-0.2.0.dev1/PKG-INFO +51 -0
- agent_assembler-0.2.0.dev1/README.md +38 -0
- agent_assembler-0.2.0.dev1/pyproject.toml +19 -0
- agent_assembler-0.2.0.dev1/requirements.txt +4 -0
- agent_assembler-0.2.0.dev1/setup.cfg +4 -0
- agent_assembler-0.2.0.dev1/src/agent_assembler/__init__.py +7 -0
- agent_assembler-0.2.0.dev1/src/agent_assembler/adapters/__init__.py +5 -0
- agent_assembler-0.2.0.dev1/src/agent_assembler/adapters/base.py +23 -0
- agent_assembler-0.2.0.dev1/src/agent_assembler/adapters/coze.py +91 -0
- agent_assembler-0.2.0.dev1/src/agent_assembler/adapters/qianwen.py +98 -0
- agent_assembler-0.2.0.dev1/src/agent_assembler/assembler.py +85 -0
- agent_assembler-0.2.0.dev1/src/agent_assembler/deploy/__init__.py +3 -0
- agent_assembler-0.2.0.dev1/src/agent_assembler/deploy/coze_api.py +95 -0
- agent_assembler-0.2.0.dev1/src/agent_assembler/deploy/qianwen_api.py +58 -0
- agent_assembler-0.2.0.dev1/src/agent_assembler/recipe.py +66 -0
- agent_assembler-0.2.0.dev1/src/agent_assembler.egg-info/PKG-INFO +51 -0
- agent_assembler-0.2.0.dev1/src/agent_assembler.egg-info/SOURCES.txt +23 -0
- agent_assembler-0.2.0.dev1/src/agent_assembler.egg-info/dependency_links.txt +1 -0
- agent_assembler-0.2.0.dev1/src/agent_assembler.egg-info/requires.txt +3 -0
- agent_assembler-0.2.0.dev1/src/agent_assembler.egg-info/top_level.txt +1 -0
- agent_assembler-0.2.0.dev1/tests/__init__.py +0 -0
- agent_assembler-0.2.0.dev1/tests/test_adapters.py +160 -0
- agent_assembler-0.2.0.dev1/tests/test_assembler.py +148 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
|
|
2
|
+
MIT License
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2026 Hermes Agent
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-assembler
|
|
3
|
+
Version: 0.2.0.dev1
|
|
4
|
+
Summary: Deterministic Context Assembly for AI Agents.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: streamlit
|
|
10
|
+
Requires-Dist: requests
|
|
11
|
+
Requires-Dist: pandas
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# Agent Assembler š§©
|
|
15
|
+
|
|
16
|
+
> **Deterministic Context Assembly for AI Agents.**
|
|
17
|
+
> The Engine for the Multi-Agent Distribution Network.
|
|
18
|
+
|
|
19
|
+
ā ļø **Status**: Currently in active development (Phase 1: SDK Decoupling). Please clone from GitHub for now. `pip install` package coming soon in Phase 2.
|
|
20
|
+
|
|
21
|
+
## 1. Vision
|
|
22
|
+
**From JIT Engine to Agent Factory & Distribution Network.**
|
|
23
|
+
Agent Assembler is no longer just a script; it is the **core engine** that powers multi-agent systems across platforms (Qianwen, Coze, WeChat, etc.).
|
|
24
|
+
|
|
25
|
+
## 2. Core Architecture
|
|
26
|
+
- **Recipe-First**: Intent matching pre-defined JSON recipes.
|
|
27
|
+
- **Atomic Skills**: <4KB focused skill modules.
|
|
28
|
+
- **JIT Assembly**: Assemble only what is needed, when it is needed.
|
|
29
|
+
- **Multi-Platform Adapters**: Deploy to Qianwen, Coze, Baidu, and more with one click.
|
|
30
|
+
|
|
31
|
+
## 3. Roadmap
|
|
32
|
+
| Phase | Goal | Status |
|
|
33
|
+
|-------|------|--------|
|
|
34
|
+
| **P0** | Core Stabilization & Validation | ā
Done |
|
|
35
|
+
| **P1** | **SDK Decoupling & Standardization** | š§ Active |
|
|
36
|
+
| **P2** | Multi-Platform Adapters (Coze/Qianwen) | ⬠Planned |
|
|
37
|
+
| **P3** | SaaS Dashboard & No-Code Builder | ⬠Planned |
|
|
38
|
+
|
|
39
|
+
## 4. Installation
|
|
40
|
+
```bash
|
|
41
|
+
pip install agent-assembler
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## 5. Quick Start
|
|
45
|
+
```python
|
|
46
|
+
from agent_assembler import Assembler
|
|
47
|
+
|
|
48
|
+
assembler = Assembler(recipes_dir="./recipes", skills_dir="./skills")
|
|
49
|
+
result = assembler.assemble("Analyze this excel file")
|
|
50
|
+
print(result['system_prompt'])
|
|
51
|
+
```
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Agent Assembler š§©
|
|
2
|
+
|
|
3
|
+
> **Deterministic Context Assembly for AI Agents.**
|
|
4
|
+
> The Engine for the Multi-Agent Distribution Network.
|
|
5
|
+
|
|
6
|
+
ā ļø **Status**: Currently in active development (Phase 1: SDK Decoupling). Please clone from GitHub for now. `pip install` package coming soon in Phase 2.
|
|
7
|
+
|
|
8
|
+
## 1. Vision
|
|
9
|
+
**From JIT Engine to Agent Factory & Distribution Network.**
|
|
10
|
+
Agent Assembler is no longer just a script; it is the **core engine** that powers multi-agent systems across platforms (Qianwen, Coze, WeChat, etc.).
|
|
11
|
+
|
|
12
|
+
## 2. Core Architecture
|
|
13
|
+
- **Recipe-First**: Intent matching pre-defined JSON recipes.
|
|
14
|
+
- **Atomic Skills**: <4KB focused skill modules.
|
|
15
|
+
- **JIT Assembly**: Assemble only what is needed, when it is needed.
|
|
16
|
+
- **Multi-Platform Adapters**: Deploy to Qianwen, Coze, Baidu, and more with one click.
|
|
17
|
+
|
|
18
|
+
## 3. Roadmap
|
|
19
|
+
| Phase | Goal | Status |
|
|
20
|
+
|-------|------|--------|
|
|
21
|
+
| **P0** | Core Stabilization & Validation | ā
Done |
|
|
22
|
+
| **P1** | **SDK Decoupling & Standardization** | š§ Active |
|
|
23
|
+
| **P2** | Multi-Platform Adapters (Coze/Qianwen) | ⬠Planned |
|
|
24
|
+
| **P3** | SaaS Dashboard & No-Code Builder | ⬠Planned |
|
|
25
|
+
|
|
26
|
+
## 4. Installation
|
|
27
|
+
```bash
|
|
28
|
+
pip install agent-assembler
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 5. Quick Start
|
|
32
|
+
```python
|
|
33
|
+
from agent_assembler import Assembler
|
|
34
|
+
|
|
35
|
+
assembler = Assembler(recipes_dir="./recipes", skills_dir="./skills")
|
|
36
|
+
result = assembler.assemble("Analyze this excel file")
|
|
37
|
+
print(result['system_prompt'])
|
|
38
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
|
|
2
|
+
[build-system]
|
|
3
|
+
requires = ["setuptools>=61.0"]
|
|
4
|
+
build-backend = "setuptools.build_meta"
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "agent-assembler"
|
|
8
|
+
version = "0.2.0.dev1"
|
|
9
|
+
description = "Deterministic Context Assembly for AI Agents."
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
license = {text = "MIT"}
|
|
13
|
+
dynamic = ["dependencies"]
|
|
14
|
+
|
|
15
|
+
[tool.setuptools.packages.find]
|
|
16
|
+
where = ["src"]
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.dynamic]
|
|
19
|
+
dependencies = {file = ["requirements.txt"]}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Any, Dict, List
|
|
4
|
+
from ..recipe import Recipe
|
|
5
|
+
|
|
6
|
+
class BaseAdapter(ABC):
|
|
7
|
+
"""Base class for all platform adapters."""
|
|
8
|
+
|
|
9
|
+
PLATFORM_NAME = "Base"
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def export(self, recipe: Recipe) -> Dict[str, Any]:
|
|
13
|
+
"""Export recipe to target platform format."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def validate(self, recipe: Recipe) -> List[str]:
|
|
18
|
+
"""Validate recipe against platform constraints."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
def deploy(self, recipe: Recipe) -> bool:
|
|
22
|
+
"""Deploy agent to platform."""
|
|
23
|
+
raise NotImplementedError("Deploy not implemented.")
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
from ..recipe import Recipe
|
|
3
|
+
from .base import BaseAdapter
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CozeAdapter(BaseAdapter):
|
|
8
|
+
"""Map Agent Assembler Recipe to Coze Bot Configuration.
|
|
9
|
+
|
|
10
|
+
Coze Bots require:
|
|
11
|
+
- bot_info.name
|
|
12
|
+
- bot_info.description
|
|
13
|
+
- bot_info.prompt_info.prompt (system instruction)
|
|
14
|
+
- bot_info.onboarding_info.prologue (welcome message)
|
|
15
|
+
|
|
16
|
+
This adapter injects Recipe + Skills content into the Coze DSL format.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
PLATFORM_NAME = "Coze"
|
|
20
|
+
|
|
21
|
+
def __init__(self, skills_dir: Optional[str] = None):
|
|
22
|
+
"""Initialize with optional skills directory for JIT loading."""
|
|
23
|
+
self.skills_dir = skills_dir
|
|
24
|
+
|
|
25
|
+
def _load_skills(self, recipe: Recipe) -> str:
|
|
26
|
+
"""Load skill contents and format as prompt section."""
|
|
27
|
+
if not self.skills_dir:
|
|
28
|
+
return "(Skills directory not configured)"
|
|
29
|
+
|
|
30
|
+
sections = []
|
|
31
|
+
for skill_name in recipe.skills:
|
|
32
|
+
skill_path = os.path.join(self.skills_dir, skill_name, "SKILL.md")
|
|
33
|
+
if os.path.exists(skill_path):
|
|
34
|
+
with open(skill_path, "r", encoding="utf-8") as f:
|
|
35
|
+
sections.append(f"### Skill: {skill_name}\n{f.read()}")
|
|
36
|
+
else:
|
|
37
|
+
sections.append(f"### Skill: {skill_name}\n[Warning: Skill file not found]")
|
|
38
|
+
|
|
39
|
+
return "\n\n".join(sections)
|
|
40
|
+
|
|
41
|
+
def export(self, recipe: Recipe) -> Dict[str, Any]:
|
|
42
|
+
"""Export recipe as Coze-compatible bot configuration."""
|
|
43
|
+
# Build system prompt
|
|
44
|
+
system_prompt = f"# Role\nYou are an AI assistant specialized in **{recipe.name}**.\n\n"
|
|
45
|
+
|
|
46
|
+
if recipe.notes:
|
|
47
|
+
system_prompt += f"## Context\n{recipe.notes}\n\n"
|
|
48
|
+
|
|
49
|
+
system_prompt += "## Skills & Instructions\n"
|
|
50
|
+
skills_content = self._load_skills(recipe)
|
|
51
|
+
system_prompt += skills_content
|
|
52
|
+
|
|
53
|
+
system_prompt += "\n\n## Execution Rules\n"
|
|
54
|
+
system_prompt += "- Follow the skill instructions strictly.\n"
|
|
55
|
+
system_prompt += "- If user query doesn\'t match any skill, fall back to general assistance.\n"
|
|
56
|
+
|
|
57
|
+
# Construct Coze DSL
|
|
58
|
+
bot_config = {
|
|
59
|
+
"bot_info": {
|
|
60
|
+
"name": recipe.name,
|
|
61
|
+
"description": recipe.notes or f"Agent for {recipe.name}",
|
|
62
|
+
"prompt_info": {
|
|
63
|
+
"prompt": system_prompt
|
|
64
|
+
},
|
|
65
|
+
"onboarding_info": {
|
|
66
|
+
"prologue": f"ä½ å„½ļ¼ęęÆ{recipe.name}å©ęć请åčÆęä½ éč¦ä»ä¹åø®å©ļ¼"
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"model_info": {
|
|
70
|
+
"model_name": "coze-pro"
|
|
71
|
+
},
|
|
72
|
+
"metadata": {
|
|
73
|
+
"source": "agent-assembler",
|
|
74
|
+
"recipe_name": recipe.name,
|
|
75
|
+
"trigger_keywords": recipe.trigger_keywords,
|
|
76
|
+
"skills_count": len(recipe.skills)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return bot_config
|
|
81
|
+
|
|
82
|
+
def validate(self, recipe: Recipe) -> List[str]:
|
|
83
|
+
"""Validate recipe against Coze platform constraints."""
|
|
84
|
+
errors = []
|
|
85
|
+
if len(recipe.name) > 50:
|
|
86
|
+
errors.append(f"Bot name \'{recipe.name}\' exceeds 50 characters limit.")
|
|
87
|
+
if not recipe.name:
|
|
88
|
+
errors.append("Bot name cannot be empty.")
|
|
89
|
+
if len(recipe.notes) > 500:
|
|
90
|
+
errors.append("Bot description exceeds 500 characters limit.")
|
|
91
|
+
return errors
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
from ..recipe import Recipe
|
|
3
|
+
from .base import BaseAdapter
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class QianwenAdapter(BaseAdapter):
|
|
8
|
+
"""Map Agent Assembler Recipe to Qianwen (Tongyi) Agent Format.
|
|
9
|
+
|
|
10
|
+
Qianwen agents (ē¾ē¼/éä¹) require:
|
|
11
|
+
- name: Agent name
|
|
12
|
+
- description: Brief description
|
|
13
|
+
- system_prompt: Core instructions
|
|
14
|
+
- model: LLM model selection
|
|
15
|
+
|
|
16
|
+
This adapter generates Chinese-optimized prompts.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
PLATFORM_NAME = "Qianwen"
|
|
20
|
+
|
|
21
|
+
def __init__(self, skills_dir: Optional[str] = None):
|
|
22
|
+
"""Initialize with optional skills directory for JIT loading."""
|
|
23
|
+
self.skills_dir = skills_dir
|
|
24
|
+
|
|
25
|
+
def _load_skills(self, recipe: Recipe) -> str:
|
|
26
|
+
"""Load skill contents and format as prompt section."""
|
|
27
|
+
if not self.skills_dir:
|
|
28
|
+
return "(ęŖé
ē½®ęč½ē®å½)"
|
|
29
|
+
|
|
30
|
+
sections = []
|
|
31
|
+
for skill_name in recipe.skills:
|
|
32
|
+
skill_path = os.path.join(self.skills_dir, skill_name, "SKILL.md")
|
|
33
|
+
if os.path.exists(skill_path):
|
|
34
|
+
with open(skill_path, "r", encoding="utf-8") as f:
|
|
35
|
+
sections.append(f"### ęč½: {skill_name}\n{f.read()}")
|
|
36
|
+
else:
|
|
37
|
+
sections.append(f"### ęč½: {skill_name}\n[č¦å: ęč½ęä»¶ęŖę¾å°]")
|
|
38
|
+
|
|
39
|
+
return "\n\n".join(sections)
|
|
40
|
+
|
|
41
|
+
def export(self, recipe: Recipe) -> Dict[str, Any]:
|
|
42
|
+
"""Export recipe as Qianwen-compatible agent configuration."""
|
|
43
|
+
# Build system prompt in Chinese
|
|
44
|
+
system_prompt = f"# č§č²č®¾å®\nä½ ęÆäøäøŖäøäøē **{recipe.name}** å©ęć\n\n"
|
|
45
|
+
|
|
46
|
+
if recipe.notes:
|
|
47
|
+
system_prompt += f"## čęÆäæ”ęÆ\n{recipe.notes}\n\n"
|
|
48
|
+
|
|
49
|
+
system_prompt += "## åÆēØęč½äøę令\n"
|
|
50
|
+
skills_content = self._load_skills(recipe)
|
|
51
|
+
system_prompt += skills_content
|
|
52
|
+
|
|
53
|
+
system_prompt += "\n\n## ę§č”č§å\n"
|
|
54
|
+
system_prompt += "- äø„ę ¼ęē
§ęč½ę令ę§č”ä»»å”\n"
|
|
55
|
+
system_prompt += "- å¦ęēØę·čÆ·ę±äøå¹é
ä»»ä½ęč½ļ¼ęä¾éēØåø®å©\n"
|
|
56
|
+
system_prompt += "- äæęåēē®ę“ćäøäøćåē”®\n"
|
|
57
|
+
|
|
58
|
+
# Construct Qianwen DSL
|
|
59
|
+
agent_config = {
|
|
60
|
+
"name": recipe.name,
|
|
61
|
+
"description": recipe.notes or f"ęŗč½ä½: {recipe.name}",
|
|
62
|
+
"system_prompt": system_prompt,
|
|
63
|
+
"welcome_message": f"ä½ å„½ļ¼ęęÆ{recipe.name}å©ęļ¼čÆ·é®ęä»ä¹åÆä»„åø®ęØļ¼",
|
|
64
|
+
"model": "qwen-max",
|
|
65
|
+
"parameters": {
|
|
66
|
+
"temperature": 0.7,
|
|
67
|
+
"top_p": 0.8,
|
|
68
|
+
"max_tokens": 2000
|
|
69
|
+
},
|
|
70
|
+
"metadata": {
|
|
71
|
+
"source": "agent-assembler",
|
|
72
|
+
"recipe_name": recipe.name,
|
|
73
|
+
"trigger_keywords": recipe.trigger_keywords,
|
|
74
|
+
"skills_count": len(recipe.skills),
|
|
75
|
+
"routing": recipe.routing
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return agent_config
|
|
80
|
+
|
|
81
|
+
def validate(self, recipe: Recipe) -> List[str]:
|
|
82
|
+
"""Validate recipe against Qianwen platform constraints."""
|
|
83
|
+
errors = []
|
|
84
|
+
if len(recipe.name) > 30:
|
|
85
|
+
errors.append(f"ęŗč½ä½åē§° \'{recipe.name}\' č¶
čæ 30 å符éå¶ć")
|
|
86
|
+
if not recipe.name:
|
|
87
|
+
errors.append("ęŗč½ä½åē§°äøč½äøŗē©ŗć")
|
|
88
|
+
# Check for valid routing if specified
|
|
89
|
+
valid_routings = [
|
|
90
|
+
None, "engineering-stage-agent", "finance-agent",
|
|
91
|
+
"operations-venue-agent", "project-pmo-agent",
|
|
92
|
+
"digital-tech-agent", "planning-agent",
|
|
93
|
+
"marketing-promotion-agent", "legal-agent",
|
|
94
|
+
"agriculture-agent", "guandan-agent", "gr-agent", "media-agent"
|
|
95
|
+
]
|
|
96
|
+
if recipe.routing and recipe.routing not in valid_routings:
|
|
97
|
+
errors.append(f"č·Æē±ē®ę \'{recipe.routing}\' äøåØęę Agent å蔨äøć")
|
|
98
|
+
return errors
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import List, Optional, Dict
|
|
6
|
+
from .recipe import Recipe
|
|
7
|
+
|
|
8
|
+
class Assembler:
|
|
9
|
+
"""The core engine of Agent Assembler."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, recipes_dir: str, skills_dir: str):
|
|
12
|
+
self.recipes_dir = os.path.abspath(recipes_dir)
|
|
13
|
+
self.skills_dir = os.path.abspath(skills_dir)
|
|
14
|
+
self.recipes: List[Recipe] = []
|
|
15
|
+
|
|
16
|
+
self._load_recipes()
|
|
17
|
+
|
|
18
|
+
def _load_recipes(self):
|
|
19
|
+
"""Load all recipes from the configured directory."""
|
|
20
|
+
if not os.path.exists(self.recipes_dir):
|
|
21
|
+
raise FileNotFoundError(f"Recipes directory not found: {self.recipes_dir}")
|
|
22
|
+
|
|
23
|
+
for root, _, files in os.walk(self.recipes_dir):
|
|
24
|
+
for file in files:
|
|
25
|
+
if file.endswith('.json'):
|
|
26
|
+
path = os.path.join(root, file)
|
|
27
|
+
try:
|
|
28
|
+
recipe = Recipe.from_json(path)
|
|
29
|
+
# Pre-load skills? No, JIT load them when matched to save time/memory.
|
|
30
|
+
# But for SDK v1, maybe eager load is fine for small datasets.
|
|
31
|
+
# Let's do lazy loading in assemble() for better performance.
|
|
32
|
+
self.recipes.append(recipe)
|
|
33
|
+
except Exception as e:
|
|
34
|
+
print(f"Error loading recipe {path}: {e}")
|
|
35
|
+
|
|
36
|
+
def match_recipe(self, query: str) -> Optional[Recipe]:
|
|
37
|
+
"""Find the best matching recipe for a user query."""
|
|
38
|
+
query_lower = query.lower()
|
|
39
|
+
matches = []
|
|
40
|
+
|
|
41
|
+
for recipe in self.recipes:
|
|
42
|
+
for keyword in recipe.trigger_keywords:
|
|
43
|
+
if keyword.lower() in query_lower:
|
|
44
|
+
matches.append(recipe)
|
|
45
|
+
break # Found a match for this recipe
|
|
46
|
+
|
|
47
|
+
if not matches:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
# Return the first match (can be improved with scoring later)
|
|
51
|
+
return matches[0]
|
|
52
|
+
|
|
53
|
+
def assemble(self, query: str) -> Dict:
|
|
54
|
+
"""Perform JIT assembly."""
|
|
55
|
+
recipe = self.match_recipe(query)
|
|
56
|
+
|
|
57
|
+
if not recipe:
|
|
58
|
+
return {
|
|
59
|
+
"status": "fallback",
|
|
60
|
+
"message": "No matching recipe found.",
|
|
61
|
+
"system_prompt": query # Pass through
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Load skills JIT
|
|
65
|
+
recipe.load_skills(self.skills_dir)
|
|
66
|
+
|
|
67
|
+
# Construct Prompt
|
|
68
|
+
system_prompt = f"# Role\nYou are an AI assistant executing the task: {recipe.name}\n\n"
|
|
69
|
+
|
|
70
|
+
if recipe.notes:
|
|
71
|
+
system_prompt += f"## Context\n{recipe.notes}\n\n"
|
|
72
|
+
|
|
73
|
+
system_prompt += "## Skills & Rules\n"
|
|
74
|
+
|
|
75
|
+
for skill_ref in recipe.skill_refs:
|
|
76
|
+
system_prompt += f"### Skill: {skill_ref.name}\n{skill_ref.content}\n\n"
|
|
77
|
+
|
|
78
|
+
system_prompt += f"## User Query\n{query}"
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
"status": "success",
|
|
82
|
+
"recipe": recipe.name,
|
|
83
|
+
"skills_loaded": [s.name for s in recipe.skill_refs],
|
|
84
|
+
"system_prompt": system_prompt
|
|
85
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
|
|
2
|
+
import requests
|
|
3
|
+
import json
|
|
4
|
+
from typing import Dict, Any, List, Optional
|
|
5
|
+
|
|
6
|
+
class CozeApiClient:
|
|
7
|
+
# China Domestic API
|
|
8
|
+
API_BASE = "https://api.coze.cn/v1"
|
|
9
|
+
|
|
10
|
+
def __init__(self, token: str):
|
|
11
|
+
self.headers = {
|
|
12
|
+
"Authorization": f"Bearer {token}",
|
|
13
|
+
"Content-Type": "application/json"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
def create_bot(self, name: str, description: str, prompt: str, space_id: str) -> Optional[str]:
|
|
17
|
+
"""
|
|
18
|
+
Create a bot on Coze (v1 API).
|
|
19
|
+
Returns bot_id if success.
|
|
20
|
+
"""
|
|
21
|
+
url = f"{self.API_BASE}/bot/create"
|
|
22
|
+
payload = {
|
|
23
|
+
"space_id": space_id,
|
|
24
|
+
"name": name,
|
|
25
|
+
"description": description,
|
|
26
|
+
"prompt_info": {
|
|
27
|
+
"prompt": prompt
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
resp = requests.post(url, headers=self.headers, json=payload)
|
|
33
|
+
data = resp.json()
|
|
34
|
+
if data.get("code") == 0:
|
|
35
|
+
bot_id = data.get("data", {}).get("bot_id")
|
|
36
|
+
print(f"Bot created: {bot_id}")
|
|
37
|
+
return bot_id
|
|
38
|
+
else:
|
|
39
|
+
error_msg = data.get("msg", "Unknown error")
|
|
40
|
+
print(f"Coze API Error: {error_msg}")
|
|
41
|
+
return None
|
|
42
|
+
except Exception as e:
|
|
43
|
+
print(f"Request Error: {e}")
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def install_connector(self, connector_id: str, workspace_id: str) -> bool:
|
|
47
|
+
"""
|
|
48
|
+
Add a publishing channel (Connector) to the workspace.
|
|
49
|
+
e.g. connector_id for Douyin, Feishu, etc.
|
|
50
|
+
"""
|
|
51
|
+
url = f"{self.API_BASE}/connectors/{connector_id}/install"
|
|
52
|
+
payload = {
|
|
53
|
+
"workspace_id": workspace_id
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
resp = requests.post(url, headers=self.headers, json=payload)
|
|
58
|
+
data = resp.json()
|
|
59
|
+
if data.get("code") == 0:
|
|
60
|
+
print(f"Connector {connector_id} installed to workspace {workspace_id}")
|
|
61
|
+
return True
|
|
62
|
+
else:
|
|
63
|
+
print(f"Install Connector Error: {data.get('msg')}")
|
|
64
|
+
return False
|
|
65
|
+
except Exception as e:
|
|
66
|
+
print(f"Request Error: {e}")
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
def publish_bot(self, bot_id: str, connector_ids: List[str] = None) -> bool:
|
|
70
|
+
"""
|
|
71
|
+
Publish the bot to specified channels.
|
|
72
|
+
Default connector_ids: ['1024'] (API/SDK)
|
|
73
|
+
"""
|
|
74
|
+
if connector_ids is None:
|
|
75
|
+
connector_ids = ["1024"]
|
|
76
|
+
|
|
77
|
+
url = f"{self.API_BASE}/bot/publish"
|
|
78
|
+
payload = {
|
|
79
|
+
"bot_id": bot_id,
|
|
80
|
+
"connector_ids": connector_ids
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
resp = requests.post(url, headers=self.headers, json=payload)
|
|
85
|
+
data = resp.json()
|
|
86
|
+
if data.get("code") == 0:
|
|
87
|
+
print(f"Bot {bot_id} published to {connector_ids}")
|
|
88
|
+
return True
|
|
89
|
+
else:
|
|
90
|
+
print(f"Publish Bot Error: {data.get('msg')}")
|
|
91
|
+
return False
|
|
92
|
+
except Exception as e:
|
|
93
|
+
print(f"Publish Error: {e}")
|
|
94
|
+
return False
|
|
95
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
|
|
2
|
+
import requests
|
|
3
|
+
import json
|
|
4
|
+
from typing import Dict, Any, Optional
|
|
5
|
+
|
|
6
|
+
class QianwenApiClient:
|
|
7
|
+
# This is a placeholder for the specific Bailian/Model Studio Agent API
|
|
8
|
+
# As of now, Bailian Agent API might be complex.
|
|
9
|
+
# We'll mock the structure based on common patterns or use a documented endpoint if available.
|
|
10
|
+
# Usually involves: Create Application -> Publish.
|
|
11
|
+
|
|
12
|
+
API_BASE = "https://dashscope.aliyuncs.com/api/v1/apps" # Example endpoint
|
|
13
|
+
|
|
14
|
+
def __init__(self, api_key: str):
|
|
15
|
+
self.headers = {
|
|
16
|
+
"Authorization": f"Bearer {api_key}",
|
|
17
|
+
"Content-Type": "application/json",
|
|
18
|
+
"X-DashScope-SSE": "disable" # Disable streaming for simple calls
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
def create_agent(self, name: str, description: str, prompt: str) -> Optional[str]:
|
|
22
|
+
"""
|
|
23
|
+
Create an Agent/App on Bailian.
|
|
24
|
+
Returns app_id.
|
|
25
|
+
"""
|
|
26
|
+
# Note: Real endpoint might differ. Using a representative structure.
|
|
27
|
+
url = f"{self.API_BASE}"
|
|
28
|
+
payload = {
|
|
29
|
+
"name": name,
|
|
30
|
+
"description": description,
|
|
31
|
+
"prompt": prompt,
|
|
32
|
+
"model": "qwen-max" # Default model
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
# POST to create
|
|
37
|
+
# If this endpoint is not public for agents, this will fail gracefully
|
|
38
|
+
# For now, we assume a standard pattern
|
|
39
|
+
resp = requests.post(url, headers=self.headers, json=payload)
|
|
40
|
+
data = resp.json()
|
|
41
|
+
|
|
42
|
+
# Check for success (structure varies)
|
|
43
|
+
if data.get("code") == 200 or "id" in data:
|
|
44
|
+
return data.get("id") or data.get("output", {}).get("app_id")
|
|
45
|
+
else:
|
|
46
|
+
print(f"Qianwen API Error: {data}")
|
|
47
|
+
return None
|
|
48
|
+
except Exception as e:
|
|
49
|
+
print(f"Request Error: {e}")
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
def publish_agent(self, app_id: str) -> bool:
|
|
53
|
+
"""
|
|
54
|
+
Publish the agent.
|
|
55
|
+
"""
|
|
56
|
+
# Usually requires a separate publish call or versioning.
|
|
57
|
+
print(f"Publishing {app_id}...")
|
|
58
|
+
return True # Placeholder
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import List, Optional, Dict, Any
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class SkillRef:
|
|
10
|
+
name: str
|
|
11
|
+
content: str = ""
|
|
12
|
+
path: str = ""
|
|
13
|
+
loaded: bool = False
|
|
14
|
+
|
|
15
|
+
def load_content(self, root_dir: str):
|
|
16
|
+
"""Load the actual skill content from file system."""
|
|
17
|
+
# Try to find the skill file
|
|
18
|
+
# Structure: {root_dir}/{skill_name}/SKILL.md
|
|
19
|
+
potential_path = os.path.join(root_dir, self.name, "SKILL.md")
|
|
20
|
+
if os.path.exists(potential_path):
|
|
21
|
+
with open(potential_path, "r", encoding="utf-8") as f:
|
|
22
|
+
self.content = f.read()
|
|
23
|
+
self.path = potential_path
|
|
24
|
+
self.loaded = True
|
|
25
|
+
return True
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Recipe:
|
|
30
|
+
"""Recipe maps user intent to specific skills."""
|
|
31
|
+
name: str
|
|
32
|
+
trigger_keywords: List[str]
|
|
33
|
+
skills: List[str] = field(default_factory=list) # Skill names
|
|
34
|
+
notes: str = ""
|
|
35
|
+
routing: Optional[str] = None
|
|
36
|
+
_skill_refs: List[SkillRef] = field(default_factory=list, init=False)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def skill_refs(self) -> List[SkillRef]:
|
|
40
|
+
return self._skill_refs
|
|
41
|
+
|
|
42
|
+
def load_skills(self, skills_dir: str):
|
|
43
|
+
"""Load all skills defined in this recipe."""
|
|
44
|
+
self._skill_refs = []
|
|
45
|
+
for skill_name in self.skills:
|
|
46
|
+
ref = SkillRef(name=skill_name)
|
|
47
|
+
if ref.load_content(skills_dir):
|
|
48
|
+
self._skill_refs.append(ref)
|
|
49
|
+
else:
|
|
50
|
+
print(f"[Warning] Skill not found: {skill_name} in {skills_dir}")
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_dict(cls, data: Dict[str, Any], source_file: str = ""):
|
|
54
|
+
return cls(
|
|
55
|
+
name=data.get("name", Path(source_file).stem),
|
|
56
|
+
trigger_keywords=data.get("trigger_keywords", []),
|
|
57
|
+
skills=data.get("skills", []),
|
|
58
|
+
notes=data.get("notes", ""),
|
|
59
|
+
routing=data.get("routing")
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_json(cls, path: str):
|
|
64
|
+
with open(path, 'r', encoding="utf-8") as f:
|
|
65
|
+
data = json.load(f)
|
|
66
|
+
return cls.from_dict(data, path)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-assembler
|
|
3
|
+
Version: 0.2.0.dev1
|
|
4
|
+
Summary: Deterministic Context Assembly for AI Agents.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: streamlit
|
|
10
|
+
Requires-Dist: requests
|
|
11
|
+
Requires-Dist: pandas
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# Agent Assembler š§©
|
|
15
|
+
|
|
16
|
+
> **Deterministic Context Assembly for AI Agents.**
|
|
17
|
+
> The Engine for the Multi-Agent Distribution Network.
|
|
18
|
+
|
|
19
|
+
ā ļø **Status**: Currently in active development (Phase 1: SDK Decoupling). Please clone from GitHub for now. `pip install` package coming soon in Phase 2.
|
|
20
|
+
|
|
21
|
+
## 1. Vision
|
|
22
|
+
**From JIT Engine to Agent Factory & Distribution Network.**
|
|
23
|
+
Agent Assembler is no longer just a script; it is the **core engine** that powers multi-agent systems across platforms (Qianwen, Coze, WeChat, etc.).
|
|
24
|
+
|
|
25
|
+
## 2. Core Architecture
|
|
26
|
+
- **Recipe-First**: Intent matching pre-defined JSON recipes.
|
|
27
|
+
- **Atomic Skills**: <4KB focused skill modules.
|
|
28
|
+
- **JIT Assembly**: Assemble only what is needed, when it is needed.
|
|
29
|
+
- **Multi-Platform Adapters**: Deploy to Qianwen, Coze, Baidu, and more with one click.
|
|
30
|
+
|
|
31
|
+
## 3. Roadmap
|
|
32
|
+
| Phase | Goal | Status |
|
|
33
|
+
|-------|------|--------|
|
|
34
|
+
| **P0** | Core Stabilization & Validation | ā
Done |
|
|
35
|
+
| **P1** | **SDK Decoupling & Standardization** | š§ Active |
|
|
36
|
+
| **P2** | Multi-Platform Adapters (Coze/Qianwen) | ⬠Planned |
|
|
37
|
+
| **P3** | SaaS Dashboard & No-Code Builder | ⬠Planned |
|
|
38
|
+
|
|
39
|
+
## 4. Installation
|
|
40
|
+
```bash
|
|
41
|
+
pip install agent-assembler
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## 5. Quick Start
|
|
45
|
+
```python
|
|
46
|
+
from agent_assembler import Assembler
|
|
47
|
+
|
|
48
|
+
assembler = Assembler(recipes_dir="./recipes", skills_dir="./skills")
|
|
49
|
+
result = assembler.assemble("Analyze this excel file")
|
|
50
|
+
print(result['system_prompt'])
|
|
51
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
requirements.txt
|
|
6
|
+
src/agent_assembler/__init__.py
|
|
7
|
+
src/agent_assembler/assembler.py
|
|
8
|
+
src/agent_assembler/recipe.py
|
|
9
|
+
src/agent_assembler.egg-info/PKG-INFO
|
|
10
|
+
src/agent_assembler.egg-info/SOURCES.txt
|
|
11
|
+
src/agent_assembler.egg-info/dependency_links.txt
|
|
12
|
+
src/agent_assembler.egg-info/requires.txt
|
|
13
|
+
src/agent_assembler.egg-info/top_level.txt
|
|
14
|
+
src/agent_assembler/adapters/__init__.py
|
|
15
|
+
src/agent_assembler/adapters/base.py
|
|
16
|
+
src/agent_assembler/adapters/coze.py
|
|
17
|
+
src/agent_assembler/adapters/qianwen.py
|
|
18
|
+
src/agent_assembler/deploy/__init__.py
|
|
19
|
+
src/agent_assembler/deploy/coze_api.py
|
|
20
|
+
src/agent_assembler/deploy/qianwen_api.py
|
|
21
|
+
tests/__init__.py
|
|
22
|
+
tests/test_adapters.py
|
|
23
|
+
tests/test_assembler.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agent_assembler
|
|
File without changes
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Tests for Agent Assembler Adapters."""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
|
|
8
|
+
|
|
9
|
+
from agent_assembler.recipe import Recipe
|
|
10
|
+
from agent_assembler.adapters import CozeAdapter, QianwenAdapter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_coze_export_basic():
|
|
14
|
+
"""Test: CozeAdapter exports valid DSL structure."""
|
|
15
|
+
recipe = Recipe(
|
|
16
|
+
name="ę°ę®åęå©ę",
|
|
17
|
+
trigger_keywords=["ę°ę®åę", "excel"],
|
|
18
|
+
skills=[],
|
|
19
|
+
notes="äøéØå¤ē Excel ę°ę®åęä»»å”"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
adapter = CozeAdapter()
|
|
23
|
+
result = adapter.export(recipe)
|
|
24
|
+
|
|
25
|
+
assert "bot_info" in result
|
|
26
|
+
assert result["bot_info"]["name"] == "ę°ę®åęå©ę"
|
|
27
|
+
assert result["bot_info"]["prompt_info"]["prompt"] != ""
|
|
28
|
+
assert result["model_info"]["model_name"] == "gpt-4o"
|
|
29
|
+
assert result["metadata"]["source"] == "agent-assembler"
|
|
30
|
+
print("ā
test_coze_export_basic passed")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_coze_skills_injection():
|
|
34
|
+
"""Test: CozeAdapter injects skill content into prompt."""
|
|
35
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
36
|
+
os.makedirs(os.path.join(tmpdir, "data_clean"))
|
|
37
|
+
with open(os.path.join(tmpdir, "data_clean", "SKILL.md"), "w") as f:
|
|
38
|
+
f.write("# ę°ę®ęø
ę“\nę„éŖ¤: 1. å»é 2. ę ¼å¼å 3. ę ”éŖ")
|
|
39
|
+
|
|
40
|
+
recipe = Recipe(
|
|
41
|
+
name="ę°ę®å¤ē",
|
|
42
|
+
trigger_keywords=["ę°ę®"],
|
|
43
|
+
skills=["data_clean"]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
adapter = CozeAdapter(skills_dir=tmpdir)
|
|
47
|
+
result = adapter.export(recipe)
|
|
48
|
+
prompt = result["bot_info"]["prompt_info"]["prompt"]
|
|
49
|
+
|
|
50
|
+
assert "ę°ę®ęø
ę“" in prompt
|
|
51
|
+
assert "å»é" in prompt
|
|
52
|
+
print("ā
test_coze_skills_injection passed")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_coze_validate():
|
|
56
|
+
"""Test: CozeAdapter validates recipe constraints."""
|
|
57
|
+
adapter = CozeAdapter()
|
|
58
|
+
|
|
59
|
+
# Valid recipe
|
|
60
|
+
good = Recipe(name="ēå", trigger_keywords=["test"], skills=[])
|
|
61
|
+
assert len(adapter.validate(good)) == 0
|
|
62
|
+
|
|
63
|
+
# Name too long
|
|
64
|
+
long_name = Recipe(name="A" * 51, trigger_keywords=["test"], skills=[])
|
|
65
|
+
errors = adapter.validate(long_name)
|
|
66
|
+
assert len(errors) > 0
|
|
67
|
+
assert "50" in errors[0]
|
|
68
|
+
|
|
69
|
+
print("ā
test_coze_validate passed")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_qianwen_export_basic():
|
|
73
|
+
"""Test: QianwenAdapter exports valid DSL structure."""
|
|
74
|
+
recipe = Recipe(
|
|
75
|
+
name="客ęå©ę",
|
|
76
|
+
trigger_keywords=["客ę", "åØčÆ¢"],
|
|
77
|
+
notes="å¤ē客ę·åØčÆ¢åęčÆ",
|
|
78
|
+
routing="operations-venue-agent"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
adapter = QianwenAdapter()
|
|
82
|
+
result = adapter.export(recipe)
|
|
83
|
+
|
|
84
|
+
assert "name" in result
|
|
85
|
+
assert result["name"] == "客ęå©ę"
|
|
86
|
+
assert "system_prompt" in result
|
|
87
|
+
assert result["model"] == "qwen-max"
|
|
88
|
+
assert result["metadata"]["routing"] == "operations-venue-agent"
|
|
89
|
+
print("ā
test_qianwen_export_basic passed")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_qianwen_skills_injection():
|
|
93
|
+
"""Test: QianwenAdapter injects skill content into prompt."""
|
|
94
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
95
|
+
os.makedirs(os.path.join(tmpdir, "reply_template"))
|
|
96
|
+
with open(os.path.join(tmpdir, "reply_template", "SKILL.md"), "w") as f:
|
|
97
|
+
f.write("# åå¤ęØ”ęæ\nč§č: 礼č²ćäøäøćē®ę“")
|
|
98
|
+
|
|
99
|
+
recipe = Recipe(
|
|
100
|
+
name="ęŗč½å®¢ę",
|
|
101
|
+
trigger_keywords=["åå¤"],
|
|
102
|
+
skills=["reply_template"]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
adapter = QianwenAdapter(skills_dir=tmpdir)
|
|
106
|
+
result = adapter.export(recipe)
|
|
107
|
+
prompt = result["system_prompt"]
|
|
108
|
+
|
|
109
|
+
assert "åå¤ęØ”ęæ" in prompt
|
|
110
|
+
assert "礼č²" in prompt
|
|
111
|
+
print("ā
test_qianwen_skills_injection passed")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_qianwen_validate():
|
|
115
|
+
"""Test: QianwenAdapter validates recipe constraints."""
|
|
116
|
+
adapter = QianwenAdapter()
|
|
117
|
+
|
|
118
|
+
# Valid recipe
|
|
119
|
+
good = Recipe(name="ę£åøøå", trigger_keywords=["test"], skills=[])
|
|
120
|
+
assert len(adapter.validate(good)) == 0
|
|
121
|
+
|
|
122
|
+
# Name too long (30 char limit for Qianwen)
|
|
123
|
+
long_name = Recipe(name="A" * 31, trigger_keywords=["test"], skills=[])
|
|
124
|
+
errors = adapter.validate(long_name)
|
|
125
|
+
assert len(errors) > 0
|
|
126
|
+
assert "30" in errors[0]
|
|
127
|
+
|
|
128
|
+
print("ā
test_qianwen_validate passed")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_adapter_missing_skill_warning():
|
|
132
|
+
"""Test: Adapter handles missing skill gracefully."""
|
|
133
|
+
recipe = Recipe(
|
|
134
|
+
name="ęµčÆ",
|
|
135
|
+
trigger_keywords=["test"],
|
|
136
|
+
skills=["nonexistent_skill"]
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
coze = CozeAdapter(skills_dir="/tmp")
|
|
140
|
+
result = coze.export(recipe)
|
|
141
|
+
assert "nonexistent_skill" in result["bot_info"]["prompt_info"]["prompt"]
|
|
142
|
+
assert "not found" in result["bot_info"]["prompt_info"]["prompt"]
|
|
143
|
+
|
|
144
|
+
qianwen = QianwenAdapter(skills_dir="/tmp")
|
|
145
|
+
result = qianwen.export(recipe)
|
|
146
|
+
assert "nonexistent_skill" in result["system_prompt"]
|
|
147
|
+
assert "ęŖę¾å°" in result["system_prompt"]
|
|
148
|
+
|
|
149
|
+
print("ā
test_adapter_missing_skill_warning passed")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
test_coze_export_basic()
|
|
154
|
+
test_coze_skills_injection()
|
|
155
|
+
test_coze_validate()
|
|
156
|
+
test_qianwen_export_basic()
|
|
157
|
+
test_qianwen_skills_injection()
|
|
158
|
+
test_qianwen_validate()
|
|
159
|
+
test_adapter_missing_skill_warning()
|
|
160
|
+
print("\nā
All 7 adapter tests passed")
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Tests for Agent Assembler SDK."""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
|
|
8
|
+
|
|
9
|
+
from agent_assembler import Assembler, Recipe
|
|
10
|
+
from agent_assembler.recipe import SkillRef
|
|
11
|
+
from agent_assembler.adapters.base import BaseAdapter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_basic_assembly():
|
|
15
|
+
"""Test: query matches recipe, skills loaded, prompt assembled."""
|
|
16
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
17
|
+
recipes_dir = os.path.join(tmpdir, "recipes")
|
|
18
|
+
skills_dir = os.path.join(tmpdir, "skills")
|
|
19
|
+
os.makedirs(recipes_dir)
|
|
20
|
+
os.makedirs(os.path.join(skills_dir, "skill_A"))
|
|
21
|
+
|
|
22
|
+
recipe = {
|
|
23
|
+
"name": "test_recipe",
|
|
24
|
+
"trigger_keywords": ["test", "demo"],
|
|
25
|
+
"skills": ["skill_A"]
|
|
26
|
+
}
|
|
27
|
+
with open(os.path.join(recipes_dir, "test.json"), "w") as f:
|
|
28
|
+
json.dump(recipe, f)
|
|
29
|
+
|
|
30
|
+
with open(os.path.join(skills_dir, "skill_A", "SKILL.md"), "w") as f:
|
|
31
|
+
f.write("# Skill A\nContent A")
|
|
32
|
+
|
|
33
|
+
assembler = Assembler(recipes_dir, skills_dir)
|
|
34
|
+
result = assembler.assemble("Please run a test demo")
|
|
35
|
+
|
|
36
|
+
assert result["status"] == "success", f"Status mismatch: {result['status']}"
|
|
37
|
+
assert result["recipe"] == "test_recipe"
|
|
38
|
+
assert "Content A" in result["system_prompt"]
|
|
39
|
+
print("ā
test_basic_assembly passed")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_no_match_fallback():
|
|
43
|
+
"""Test: query does not match any recipe ā fallback."""
|
|
44
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
45
|
+
recipes_dir = os.path.join(tmpdir, "recipes")
|
|
46
|
+
skills_dir = os.path.join(tmpdir, "skills")
|
|
47
|
+
os.makedirs(recipes_dir)
|
|
48
|
+
os.makedirs(skills_dir)
|
|
49
|
+
|
|
50
|
+
recipe = {
|
|
51
|
+
"name": "only_recipe",
|
|
52
|
+
"trigger_keywords": ["specific"],
|
|
53
|
+
"skills": []
|
|
54
|
+
}
|
|
55
|
+
with open(os.path.join(recipes_dir, "only.json"), "w") as f:
|
|
56
|
+
json.dump(recipe, f)
|
|
57
|
+
|
|
58
|
+
assembler = Assembler(recipes_dir, skills_dir)
|
|
59
|
+
result = assembler.assemble("unrelated query")
|
|
60
|
+
|
|
61
|
+
assert result["status"] == "fallback"
|
|
62
|
+
assert "No matching recipe found" in result["message"]
|
|
63
|
+
print("ā
test_no_match_fallback passed")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_missing_recipe_dir():
|
|
67
|
+
"""Test: non-existent recipes directory raises FileNotFoundError."""
|
|
68
|
+
try:
|
|
69
|
+
Assembler("/nonexistent/path/12345", "/tmp")
|
|
70
|
+
assert False, "Should have raised FileNotFoundError"
|
|
71
|
+
except FileNotFoundError:
|
|
72
|
+
print("ā
test_missing_recipe_dir passed")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_recipe_from_json():
|
|
76
|
+
"""Test: Recipe deserialization from JSON."""
|
|
77
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
78
|
+
json.dump({
|
|
79
|
+
"name": "my_recipe",
|
|
80
|
+
"trigger_keywords": ["alpha", "beta"],
|
|
81
|
+
"skills": ["s1", "s2"],
|
|
82
|
+
"notes": "Some notes",
|
|
83
|
+
"routing": "engineering-agent"
|
|
84
|
+
}, f)
|
|
85
|
+
f.flush()
|
|
86
|
+
|
|
87
|
+
recipe = Recipe.from_json(f.name)
|
|
88
|
+
assert recipe.name == "my_recipe"
|
|
89
|
+
assert len(recipe.trigger_keywords) == 2
|
|
90
|
+
assert len(recipe.skills) == 2
|
|
91
|
+
assert recipe.notes == "Some notes"
|
|
92
|
+
assert recipe.routing == "engineering-agent"
|
|
93
|
+
print("ā
test_recipe_from_json passed")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_skill_ref_load():
|
|
97
|
+
"""Test: SkillRef loads content from filesystem."""
|
|
98
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
99
|
+
os.makedirs(os.path.join(tmpdir, "my_skill"))
|
|
100
|
+
with open(os.path.join(tmpdir, "my_skill", "SKILL.md"), "w") as f:
|
|
101
|
+
f.write("# My Skill\nBody content")
|
|
102
|
+
|
|
103
|
+
ref = SkillRef(name="my_skill")
|
|
104
|
+
assert ref.load_content(tmpdir) is True
|
|
105
|
+
assert ref.loaded is True
|
|
106
|
+
assert "Body content" in ref.content
|
|
107
|
+
print("ā
test_skill_ref_load passed")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_skill_ref_missing():
|
|
111
|
+
"""Test: SkillRef returns False when file does not exist."""
|
|
112
|
+
ref = SkillRef(name="nonexistent_skill")
|
|
113
|
+
assert ref.load_content("/tmp") is False
|
|
114
|
+
assert ref.loaded is False
|
|
115
|
+
print("ā
test_skill_ref_missing passed")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_multiple_recipes_first_match():
|
|
119
|
+
"""Test: first matching recipe is returned (deterministic)."""
|
|
120
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
121
|
+
recipes_dir = os.path.join(tmpdir, "recipes")
|
|
122
|
+
skills_dir = os.path.join(tmpdir, "skills")
|
|
123
|
+
os.makedirs(recipes_dir)
|
|
124
|
+
os.makedirs(skills_dir)
|
|
125
|
+
|
|
126
|
+
# Write two recipes that both match "data"
|
|
127
|
+
for name, kw in [("data_cleaner", ["data", "clean"]), ("data_viz", ["data", "chart"])]:
|
|
128
|
+
with open(os.path.join(recipes_dir, f"{name}.json"), "w") as f:
|
|
129
|
+
json.dump({"name": name, "trigger_keywords": kw, "skills": []}, f)
|
|
130
|
+
|
|
131
|
+
assembler = Assembler(recipes_dir, skills_dir)
|
|
132
|
+
result = assembler.assemble("show me data")
|
|
133
|
+
|
|
134
|
+
assert result["status"] == "success"
|
|
135
|
+
# Should match the first one found (alphabetical walk order)
|
|
136
|
+
assert result["recipe"] in ("data_cleaner", "data_viz")
|
|
137
|
+
print("ā
test_multiple_recipes_first_match passed")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
test_basic_assembly()
|
|
142
|
+
test_no_match_fallback()
|
|
143
|
+
test_missing_recipe_dir()
|
|
144
|
+
test_recipe_from_json()
|
|
145
|
+
test_skill_ref_load()
|
|
146
|
+
test_skill_ref_missing()
|
|
147
|
+
test_multiple_recipes_first_match()
|
|
148
|
+
print("\nā
All 7 tests passed")
|