0.4.0: ワーカーの廃止

This commit is contained in:
Keisuke Hirata 2025-10-24 09:53:12 +09:00
parent 02667f5396
commit cab8cd7f32
16 changed files with 409 additions and 1863 deletions

648
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,39 +4,37 @@
## 特徴
- 主要 LLM プロバイダーを単一のインターフェースで利用
- プロンプト/パーシャル読み込みを利用者実装の `ResourceLoader` へ委譲
- システムプロンプトの生成を外部関数として注入可能
- ツール連携とストリーミング応答、フックによるカスタマイズに対応
## 利用手順
`worker` のプロンプト/パーシャル解決は利用者側に委譲されています。以下の流れで組み込みます。
`worker` はシステムプロンプトの生成を外部関数に完全委任します。以下の手順で組み込みます。
1. `ResourceLoader` を実装して、テンプレートやパーシャルが参照する識別子から文字列を返す。
2. `Worker::builder()` にプロバイダー・モデル・ロールと合わせて `resource_loader` を渡し、`Worker` を生成。
3. セッションを初期化し、`process_task_with_history` などの API でイベントストリームを処理。
1. `fn(&PromptContext, &[Message]) -> Result<String, PromptError>` 形式の関数(またはクロージャ)でシステムプロンプトを返す。
2. `Worker::builder()` にプロバイダー・モデル・APIキーと合わせて `system_prompt(...)` を渡し、`Worker` を生成。
3. セッション初期化後、`process_task_with_history` などでイベントストリームを処理。
```rust
use futures_util::StreamExt;
use worker::{LlmProvider, PromptError, ResourceLoader, Role, Worker};
struct FsLoader;
impl ResourceLoader for FsLoader {
fn load(&self, id: &str) -> Result<String, PromptError> {
std::fs::read_to_string(id)
.map_err(|e| PromptError::FileNotFound(format!("{}: {}", id, e)))
}
}
use worker::{LlmProvider, PromptContext, PromptError, Worker};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let role = Role::new("assistant", "Helper", "You are a helpful assistant.");
let system_prompt = |ctx: &PromptContext, _messages: &[worker_types::Message]| {
Ok(format!(
"You are assisting the project: {}",
ctx.workspace
.project_name
.clone()
.unwrap_or_else(|| "unknown".to_string())
))
};
let mut worker = Worker::builder()
.provider(LlmProvider::Claude)
.model("claude-3-sonnet-20240229")
.resource_loader(FsLoader)
.role(role)
.system_prompt(system_prompt)
.build()?;
worker.initialize_session()?;

33
docs/patch_note/v0.4.0.md Normal file
View File

@ -0,0 +1,33 @@
# Release Notes - v0.4.0
**Release Date**: 2025-??-??
v0.4.0 は Worker が `Role` や YAML 設定を扱わず、システムプロンプト生成を完全に利用者へ委譲する大規模リファクタです。これにより、任意のテンプレートエンジンやデータソースを組み合わせてプロンプトを構築できます。
## Breaking Changes
- `Role` / `ConfigParser` / `ResourceLoader` を削除。`WorkerBuilder` はシステムプロンプト生成関数 (`system_prompt(...)`) の指定が必須になりました。
- `worker/src/config` の Role 関連コードとテストを削除。既存の YAML ベース設定は互換層なし。
## 新機能 / 仕様変更
- `PromptComposer``Arc<SystemPromptFn>` を受け取り、`PromptContext` と履歴メッセージからシステムプロンプト文字列を生成するシンプルなラッパーになりました。
- `WorkerBuilder``.system_prompt(...)` で登録した関数を保持し、メッセージ送信時に毎回システムプロンプトを再生成します。
- READMEサンプルコードを刷新し、システムプロンプト関数・マクロベースのツールフック登録手順のみを掲載。
- 新しい `docs/prompt-composer.md` を追加し、`PromptComposer` の利用例をサマリー形式で紹介。
## 不具合修正
- `PromptComposer` が内部でファイルアクセスを行う経路を排除し、生成関数の失敗時は直近のキャッシュを利用するようにしました。
- Worker から NIA 固有の設定コードを除去し、環境依存の副作用を縮小。
## 移行ガイド
1. 旧 `Role` / `ConfigParser` を利用していた場合、`PromptContext` と会話履歴を引数にシステムプロンプト文字列を返す関数を実装し、`.system_prompt(...)` に渡してください。
2. `Worker::load_config` やリソースパス解決に依存していたコードは削除してください。必要であればアプリケーション側でファイル読み込みを行い、生成関数内で利用してください。
3. ツール・フックは引き続き `#[worker::tool]` / `#[worker::hook]` マクロを推奨していますAPI に変更はありません)。
## 開発者向けメモ
- README を簡潔化し、RustDocs で確認できる内容の重複を削除しました。
- `worker/examples/` を更新し、システムプロンプト関数とマクロベースのツール登録のみを扱うよう整理しました。

View File

@ -1,106 +1,32 @@
# PromptComposer
テンプレートベースのプロンプト構築システム。Handlebarsテンプレートエンジンによる動的プロンプト生成。
## 基本使用方法
`PromptComposer` は、`PromptContext` と会話履歴からシステムプロンプト文字列を生成するクロージャをラップし、LLM へ送信するメッセージ列を構築します。
```rust
use std::sync::Arc;
use worker::prompt::{PromptComposer, PromptContext, PromptError, ResourceLoader};
use worker::prompt::{PromptComposer, PromptContext, PromptError, SystemPromptFn};
use worker_types::Message;
struct FsLoader;
impl ResourceLoader for FsLoader {
fn load(&self, identifier: &str) -> Result<String, PromptError> {
std::fs::read_to_string(identifier)
.map_err(|e| PromptError::FileNotFound(format!("{}: {}", identifier, e)))
}
fn build_context() -> PromptContext {
// WorkspaceDetector などからアプリ固有の情報を収集して埋め込む
todo!()
}
// 初期化
let loader = Arc::new(FsLoader);
let mut composer = PromptComposer::from_config_file("role.yaml", context, loader.clone())?;
composer.initialize_session(&messages)?;
// プロンプト構築
let messages = composer.compose(&user_messages)?;
```
## リソースローダー
`PromptComposer` はテンプレート内で参照されるパーシャルや `{{include_file}}` の解決をクレート利用者に委ねています。
`ResourceLoader` トレイトを実装して、任意のストレージや命名規則に基づいて文字列を返してください。
```rust
struct MyLoader;
impl ResourceLoader for MyLoader {
fn load(&self, identifier: &str) -> Result<String, PromptError> {
match identifier.strip_prefix("#workspace/") {
Some(rest) => {
let path = std::env::current_dir()?.join(".nia").join(rest);
std::fs::read_to_string(path).map_err(|e| PromptError::FileNotFound(e.to_string()))
}
None => std::fs::read_to_string(identifier)
.map_err(|e| PromptError::FileNotFound(e.to_string())),
}
}
fn generator(ctx: &PromptContext, messages: &[Message]) -> Result<String, PromptError> {
Ok(format!(
"Project {} has {} prior messages.",
ctx.workspace
.project_name
.clone()
.unwrap_or_else(|| \"unknown\".into()),
messages.len()
))
}
let context = build_context();
let composer = PromptComposer::new(context, Arc::new(generator));
let conversation = vec![Message::new(worker_types::Role::User, \"Hello\".into())];
let final_messages = composer.compose(&conversation)?;
```
## テンプレート構文
### 変数展開
```handlebars
{{workspace.project_name}} # プロジェクト名
{{workspace.project_type}} # プロジェクト種別
{{model.provider}}/{{model.model_name}} # モデル情報
{{tools_schema}} # ツールスキーマ
```
### 条件分岐
```handlebars
{{#if workspace.has_nia_md}}
Project info: {{workspace_content}}
{{/if}}
{{#eq workspace.project_type "Rust"}}
Focus on memory safety and performance.
{{/eq}}
```
### 繰り返し処理
```handlebars
{{#each tools}}
- **{{name}}**: {{description}}
{{/each}}
```
### パーシャルテンプレート
```handlebars
{{> header}}
{{> coding_guidelines}}
{{> footer}}
```
## カスタムヘルパー
### include_file
外部ファイルを読み込み:
```handlebars
{{include_file "~/.config/nia/templates/guidelines.md"}}
```
### workspace_content
ワークスペースのnia.md内容を取得
```handlebars
{{workspace_content}}
```
## 利用可能なコンテキスト変数
- `workspace`: プロジェクト情報root_path、project_type、git_info等
- `model`: LLMモデル情報provider、model_name、capabilities
- `session`: セッション情報conversation_id、message_count
- `user_input`: ユーザー入力内容
- `tools_schema`: ツール定義JSON
`compose_with_tools` を使うと、`tools_schema` をテンプレート変数として渡した上でシステムプロンプトを再生成できます。

View File

@ -1,6 +1,6 @@
[package]
name = "worker"
version = "0.3.0"
version = "0.4.0"
edition = "2024"
[dependencies]
@ -31,7 +31,6 @@ tracing = "0.1.40"
eventsource-stream = "0.2.3"
xdg = "3.0.0"
chrono = { version = "0.4", features = ["serde"] }
handlebars = "5.1.2"
regex = "1.10.2"
uuid = { version = "1.10", features = ["v4", "serde"] }
tokio-util = { version = "0.7", features = ["codec"] }

View File

@ -1,23 +1,19 @@
use std::collections::HashMap;
use worker::{LlmProvider, PromptError, ResourceLoader, Role, Worker};
struct FsLoader;
impl ResourceLoader for FsLoader {
fn load(&self, identifier: &str) -> Result<String, PromptError> {
std::fs::read_to_string(identifier)
.map_err(|e| PromptError::FileNotFound(format!("{}: {}", identifier, e)))
}
}
use worker::{LlmProvider, PromptContext, PromptError, Worker};
use worker_types::Message;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Example 1: Basic role
let role = Role::new(
"assistant",
"A helpful AI assistant",
"You are a helpful, harmless, and honest AI assistant.",
);
// Example 1: Basic system prompt generator
fn system_prompt(ctx: &PromptContext, _messages: &[Message]) -> Result<String, PromptError> {
Ok(format!(
"You are helping with the project: {}",
ctx.workspace
.project_name
.clone()
.unwrap_or_else(|| "unknown".to_string())
))
}
let mut api_keys = HashMap::new();
api_keys.insert("claude".to_string(), std::env::var("ANTHROPIC_API_KEY")?);
@ -26,27 +22,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.provider(LlmProvider::Claude)
.model("claude-3-sonnet-20240229")
.api_keys(api_keys)
.resource_loader(FsLoader)
.role(role)
.system_prompt(system_prompt)
.build()?;
println!("✅ Worker created with builder pattern");
println!(" Provider: {:?}", worker.get_provider_name());
println!(" Model: {}", worker.get_model_name());
// Example 2: Code reviewer role
let code_reviewer_role = Role::new(
"code-reviewer",
"An AI that reviews code for best practices",
"You are an expert code reviewer. Always provide constructive feedback.",
);
// Example 2: Different prompt generator
fn reviewer_prompt(_ctx: &PromptContext, _messages: &[Message]) -> Result<String, PromptError> {
Ok("You are an expert code reviewer. Always provide constructive feedback.".to_string())
}
let _worker2 = Worker::builder()
.provider(LlmProvider::Claude)
.model("claude-3-sonnet-20240229")
.api_key("claude", std::env::var("ANTHROPIC_API_KEY")?)
.resource_loader(FsLoader)
.role(code_reviewer_role)
.system_prompt(reviewer_prompt)
.build()?;
println!("✅ Worker created with custom role");

View File

@ -1,18 +1,10 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use worker::{
PromptError, ResourceLoader, Role, Worker,
PromptContext, PromptError, Worker,
plugin::{PluginRegistry, ProviderPlugin, example_provider::CustomProviderPlugin},
};
struct FsLoader;
impl ResourceLoader for FsLoader {
fn load(&self, identifier: &str) -> Result<String, PromptError> {
std::fs::read_to_string(identifier)
.map_err(|e| PromptError::FileNotFound(format!("{}: {}", identifier, e)))
}
}
use worker_types::Message;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
@ -58,18 +50,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
// Create a Worker instance using the plugin
let role = Role::new(
"assistant",
"A helpful AI assistant",
"You are a helpful, harmless, and honest AI assistant powered by a custom LLM provider.",
);
fn plugin_prompt(_ctx: &PromptContext, _messages: &[Message]) -> Result<String, PromptError> {
Ok("You are a helpful assistant powered by a custom provider.".to_string())
}
let worker = Worker::builder()
.plugin("custom-provider", plugin_registry.clone())
.model("custom-turbo")
.api_key("__plugin__", "custom-1234567890abcdefghijklmnop")
.resource_loader(FsLoader)
.role(role)
.system_prompt(plugin_prompt)
.build()?;
println!("\nWorker created successfully with custom provider plugin!");

View File

@ -1,18 +1,18 @@
use crate::Worker;
use crate::prompt::{ResourceLoader, Role};
use crate::prompt::{PromptComposer, PromptContext, PromptError, SystemPromptFn};
use crate::types::WorkerError;
use std::collections::HashMap;
use std::marker::PhantomData;
use std::sync::{Arc, Mutex};
use worker_types::LlmProvider;
use worker_types::{LlmProvider, Message};
// Type-state markers
pub struct NoProvider;
pub struct WithProvider;
pub struct NoModel;
pub struct WithModel;
pub struct NoRole;
pub struct WithRole;
pub struct NoSystemPrompt;
pub struct WithSystemPrompt;
/// WorkerBuilder with type-state pattern
///
@ -20,51 +20,40 @@ pub struct WithRole;
///
/// # Example
/// ```no_run
/// use worker::{Worker, LlmProvider, Role, ResourceLoader, PromptError};
/// use worker::{Worker, LlmProvider, PromptContext, PromptError};
///
/// struct FsLoader;
///
/// impl ResourceLoader for FsLoader {
/// fn load(&self, identifier: &str) -> Result<String, PromptError> {
/// std::fs::read_to_string(identifier)
/// .map_err(|e| PromptError::FileNotFound(format!("{}: {}", identifier, e)))
/// }
/// fn system_prompt(
/// _ctx: &PromptContext,
/// _messages: &[worker_types::Message],
/// ) -> Result<String, PromptError> {
/// Ok("You are a helpful assistant.".to_string())
/// }
///
/// let role = Role::new("assistant", "AI Assistant", "You are a helpful assistant.");
/// let worker = Worker::builder()
/// .provider(LlmProvider::Claude)
/// .model("claude-3-sonnet-20240229")
/// .api_key("claude", "sk-ant-...")
/// .resource_loader(FsLoader)
/// .role(role)
/// .system_prompt(system_prompt)
/// .build()?;
/// # Ok::<(), worker::WorkerError>(())
/// ```
pub struct WorkerBuilder<P, M, R> {
pub struct WorkerBuilder<P, M, S> {
provider: Option<LlmProvider>,
model_name: Option<String>,
api_keys: HashMap<String, String>,
// Role configuration (required)
role: Option<Role>,
resource_loader: Option<Arc<dyn ResourceLoader>>,
// Plugin configuration
system_prompt_fn: Option<Arc<SystemPromptFn>>,
plugin_id: Option<String>,
plugin_registry: Option<Arc<Mutex<crate::plugin::PluginRegistry>>>,
_phantom: PhantomData<(P, M, R)>,
_phantom: PhantomData<(P, M, S)>,
}
impl Default for WorkerBuilder<NoProvider, NoModel, NoRole> {
impl Default for WorkerBuilder<NoProvider, NoModel, NoSystemPrompt> {
fn default() -> Self {
Self {
provider: None,
model_name: None,
api_keys: HashMap::new(),
role: None,
resource_loader: None,
system_prompt_fn: None,
plugin_id: None,
plugin_registry: None,
_phantom: PhantomData,
@ -72,23 +61,21 @@ impl Default for WorkerBuilder<NoProvider, NoModel, NoRole> {
}
}
impl WorkerBuilder<NoProvider, NoModel, NoRole> {
/// Create a new WorkerBuilder
impl WorkerBuilder<NoProvider, NoModel, NoSystemPrompt> {
pub fn new() -> Self {
Self::default()
}
}
// Step 1: Set provider
impl<M, R> WorkerBuilder<NoProvider, M, R> {
pub fn provider(mut self, provider: LlmProvider) -> WorkerBuilder<WithProvider, M, R> {
impl<M, S> WorkerBuilder<NoProvider, M, S> {
pub fn provider(mut self, provider: LlmProvider) -> WorkerBuilder<WithProvider, M, S> {
self.provider = Some(provider);
WorkerBuilder {
provider: self.provider,
model_name: self.model_name,
api_keys: self.api_keys,
role: self.role,
resource_loader: self.resource_loader,
system_prompt_fn: self.system_prompt_fn,
plugin_id: self.plugin_id,
plugin_registry: self.plugin_registry,
_phantom: PhantomData,
@ -100,15 +87,14 @@ impl<M, R> WorkerBuilder<NoProvider, M, R> {
mut self,
plugin_id: impl Into<String>,
registry: Arc<Mutex<crate::plugin::PluginRegistry>>,
) -> WorkerBuilder<WithProvider, M, R> {
) -> WorkerBuilder<WithProvider, M, S> {
self.plugin_id = Some(plugin_id.into());
self.plugin_registry = Some(registry);
WorkerBuilder {
provider: None,
model_name: self.model_name,
api_keys: self.api_keys,
role: self.role,
resource_loader: self.resource_loader,
system_prompt_fn: self.system_prompt_fn,
plugin_id: self.plugin_id,
plugin_registry: self.plugin_registry,
_phantom: PhantomData,
@ -117,18 +103,17 @@ impl<M, R> WorkerBuilder<NoProvider, M, R> {
}
// Step 2: Set model
impl<R> WorkerBuilder<WithProvider, NoModel, R> {
impl<S> WorkerBuilder<WithProvider, NoModel, S> {
pub fn model(
mut self,
model_name: impl Into<String>,
) -> WorkerBuilder<WithProvider, WithModel, R> {
) -> WorkerBuilder<WithProvider, WithModel, S> {
self.model_name = Some(model_name.into());
WorkerBuilder {
provider: self.provider,
model_name: self.model_name,
api_keys: self.api_keys,
role: self.role,
resource_loader: self.resource_loader,
system_prompt_fn: self.system_prompt_fn,
plugin_id: self.plugin_id,
plugin_registry: self.plugin_registry,
_phantom: PhantomData,
@ -136,16 +121,21 @@ impl<R> WorkerBuilder<WithProvider, NoModel, R> {
}
}
// Step 3: Set role
impl WorkerBuilder<WithProvider, WithModel, NoRole> {
pub fn role(mut self, role: Role) -> WorkerBuilder<WithProvider, WithModel, WithRole> {
self.role = Some(role);
// Step 3: Register system prompt generator
impl WorkerBuilder<WithProvider, WithModel, NoSystemPrompt> {
pub fn system_prompt<F>(
mut self,
generator: F,
) -> WorkerBuilder<WithProvider, WithModel, WithSystemPrompt>
where
F: Fn(&PromptContext, &[Message]) -> Result<String, PromptError> + Send + Sync + 'static,
{
self.system_prompt_fn = Some(Arc::new(generator));
WorkerBuilder {
provider: self.provider,
model_name: self.model_name,
api_keys: self.api_keys,
role: self.role,
resource_loader: self.resource_loader,
system_prompt_fn: self.system_prompt_fn,
plugin_id: self.plugin_id,
plugin_registry: self.plugin_registry,
_phantom: PhantomData,
@ -153,45 +143,33 @@ impl WorkerBuilder<WithProvider, WithModel, NoRole> {
}
}
// Optional configurations (available at any stage)
impl<P, M, R> WorkerBuilder<P, M, R> {
/// Add API key for a provider
// Optional configurations
impl<P, M, S> WorkerBuilder<P, M, S> {
pub fn api_key(mut self, provider: impl Into<String>, key: impl Into<String>) -> Self {
self.api_keys.insert(provider.into(), key.into());
self
}
/// Set multiple API keys at once
pub fn api_keys(mut self, keys: HashMap<String, String>) -> Self {
self.api_keys = keys;
self
}
/// Provide a resource loader implementation for partial/include resolution
pub fn resource_loader<L>(mut self, loader: L) -> Self
where
L: ResourceLoader + 'static,
{
self.resource_loader = Some(Arc::new(loader));
self
}
}
// Build
impl WorkerBuilder<WithProvider, WithModel, WithRole> {
impl WorkerBuilder<WithProvider, WithModel, WithSystemPrompt> {
pub fn build(self) -> Result<Worker, WorkerError> {
use crate::{LlmProviderExt, PromptComposer, WorkspaceDetector, plugin};
use crate::{LlmProviderExt, WorkspaceDetector, plugin};
let resource_loader = self.resource_loader.clone().ok_or_else(|| {
let system_prompt_fn = self.system_prompt_fn.ok_or_else(|| {
WorkerError::config(
"Resource loader is required. Call resource_loader(...) before build.",
"System prompt generator is required. Call system_prompt(...) before build.",
)
})?;
let role = self.role.unwrap();
let model_name = self.model_name.unwrap();
// Plugin provider
// Plugin-backed provider
if let (Some(plugin_id), Some(plugin_registry)) = (self.plugin_id, self.plugin_registry) {
let api_key_opt = self
.api_keys
@ -215,7 +193,6 @@ impl WorkerBuilder<WithProvider, WithModel, WithRole> {
let provider_str = plugin_id.clone();
let api_key = api_key_opt.map(|s| s.to_string()).unwrap_or_default();
let workspace_context = WorkspaceDetector::detect_workspace().ok();
let prompt_context = Worker::create_prompt_context_static(
@ -225,23 +202,15 @@ impl WorkerBuilder<WithProvider, WithModel, WithRole> {
&[],
);
tracing::info!("Creating worker with plugin and role: {}", role.name);
let composer =
PromptComposer::from_config(role.clone(), prompt_context, resource_loader.clone())
.map_err(|e| WorkerError::config(e.to_string()))?;
drop(registry);
let mut worker = Worker {
llm_client: Box::new(llm_client),
composer,
resource_loader: resource_loader.clone(),
composer: PromptComposer::new(prompt_context, system_prompt_fn.clone()),
tools: Vec::new(),
api_key,
provider_str,
model_name,
role,
workspace_context,
message_history: Vec::new(),
hook_manager: worker_types::HookManager::new(),
@ -256,7 +225,7 @@ impl WorkerBuilder<WithProvider, WithModel, WithRole> {
return Ok(worker);
}
// Standard provider
// Built-in provider
let provider = self.provider.unwrap();
let provider_str = provider.as_str();
let api_key = self.api_keys.get(provider_str).cloned().unwrap_or_default();
@ -272,21 +241,13 @@ impl WorkerBuilder<WithProvider, WithModel, WithRole> {
&[],
);
tracing::info!("Creating worker with role: {}", role.name);
let composer =
PromptComposer::from_config(role.clone(), prompt_context, resource_loader.clone())
.map_err(|e| WorkerError::config(e.to_string()))?;
let mut worker = Worker {
llm_client: Box::new(llm_client),
composer,
resource_loader,
composer: PromptComposer::new(prompt_context, system_prompt_fn.clone()),
tools: Vec::new(),
api_key,
provider_str: provider_str.to_string(),
model_name,
role,
workspace_context,
message_history: Vec::new(),
hook_manager: worker_types::HookManager::new(),

View File

@ -1,5 +1,3 @@
mod parser;
mod url;
pub use parser::ConfigParser;
pub use url::UrlConfig;

View File

@ -1,56 +0,0 @@
use crate::prompt::{PromptError, Role};
use std::fs;
use std::path::Path;
/// 設定ファイルのパーサー
pub struct ConfigParser;
impl ConfigParser {
/// YAML設定ファイルを読み込んでパースする
pub fn parse_from_file<P: AsRef<Path>>(path: P) -> Result<Role, PromptError> {
let content = fs::read_to_string(path.as_ref()).map_err(|e| {
PromptError::FileNotFound(format!("{}: {}", path.as_ref().display(), e))
})?;
Self::parse_from_string(&content)
}
/// YAML文字列をパースしてRoleに変換する
pub fn parse_from_string(content: &str) -> Result<Role, PromptError> {
let config: Role = serde_yaml::from_str(content)?;
// 基本的なバリデーション
Self::validate_config(&config)?;
Ok(config)
}
/// 設定ファイルの基本的なバリデーション
fn validate_config(config: &Role) -> Result<(), PromptError> {
if config.name.is_empty() {
return Err(PromptError::VariableResolution(
"name field cannot be empty".to_string(),
));
}
if config.template.is_empty() {
return Err(PromptError::TemplateCompilation(
"template field cannot be empty".to_string(),
));
}
// パーシャルのパス検証
if let Some(partials) = &config.partials {
for (name, partial) in partials {
if partial.path.is_empty() {
return Err(PromptError::PartialLoading(format!(
"partial '{}' has empty path",
name
)));
}
}
}
Ok(())
}
}

View File

@ -1,7 +1,4 @@
use crate::prompt::{
ModelCapabilities, ModelContext, PromptComposer, PromptContext, SessionContext,
WorkspaceContext,
};
use crate::prompt::{ModelCapabilities, ModelContext, SessionContext, WorkspaceContext};
use crate::workspace::WorkspaceDetector;
use async_stream::stream;
use futures_util::{Stream, StreamExt};
@ -33,17 +30,12 @@ pub mod prompt;
pub mod types;
pub mod workspace;
pub use crate::prompt::{PromptError, ResourceLoader, Role};
pub use crate::prompt::{PromptComposer, PromptContext, PromptError, SystemPromptFn};
pub use crate::types::WorkerError;
pub use builder::WorkerBuilder;
pub use client::LlmClient;
pub use core::LlmClientTrait;
#[cfg(test)]
mod tests {
mod config_tests;
}
pub use schemars;
pub use serde_json;
@ -442,13 +434,11 @@ pub async fn supports_native_tools(
pub struct Worker {
pub(crate) llm_client: Box<dyn LlmClientTrait>,
pub(crate) composer: PromptComposer,
pub(crate) resource_loader: std::sync::Arc<dyn ResourceLoader>,
pub(crate) composer: crate::prompt::PromptComposer,
pub(crate) tools: Vec<Box<dyn Tool>>,
pub(crate) api_key: String,
pub(crate) provider_str: String,
pub(crate) model_name: String,
pub(crate) role: Role,
pub(crate) workspace_context: Option<WorkspaceContext>,
pub(crate) message_history: Vec<Message>,
pub(crate) hook_manager: crate::types::HookManager,
@ -461,29 +451,22 @@ impl Worker {
///
/// # Example
/// ```no_run
/// use worker::{Worker, LlmProvider, Role, PromptError, ResourceLoader};
/// use worker::{Worker, LlmProvider, PromptContext, PromptError};
///
/// struct FsLoader;
///
/// impl ResourceLoader for FsLoader {
/// fn load(&self, identifier: &str) -> Result<String, PromptError> {
/// std::fs::read_to_string(identifier)
/// .map_err(|e| PromptError::FileNotFound(format!("{}: {}", identifier, e)))
/// }
/// fn system_prompt(_ctx: &PromptContext, _messages: &[worker_types::Message]) -> Result<String, PromptError> {
/// Ok("You are a helpful assistant.".to_string())
/// }
///
/// let role = Role::new("assistant", "AI Assistant", "You are a helpful assistant.");
/// let worker = Worker::builder()
/// .provider(LlmProvider::Claude)
/// .model("claude-3-sonnet-20240229")
/// .api_key("claude", "sk-ant-...")
/// .resource_loader(FsLoader)
/// .role(role)
/// .system_prompt(system_prompt)
/// .build()?;
/// # Ok::<(), worker::WorkerError>(())
/// ```
pub fn builder()
-> builder::WorkerBuilder<builder::NoProvider, builder::NoModel, builder::NoRole> {
-> builder::WorkerBuilder<builder::NoProvider, builder::NoModel, builder::NoSystemPrompt> {
builder::WorkerBuilder::new()
}
@ -784,43 +767,13 @@ impl Worker {
)
}
/// 設定を読み込む
pub fn load_config<P: AsRef<std::path::Path>>(
&mut self,
config_path: P,
) -> Result<(), WorkerError> {
use crate::config::ConfigParser;
// 設定ファイルを読み込み
let role = ConfigParser::parse_from_file(config_path)
.map_err(|e| WorkerError::config(e.to_string()))?;
// プロンプトコンテキストを構築
let prompt_context = self.create_prompt_context()?;
// DynamicPromptComposerを作成
let composer =
PromptComposer::from_config(role.clone(), prompt_context, self.resource_loader.clone())
.map_err(|e| WorkerError::config(e.to_string()))?;
self.role = role;
self.composer = composer;
// 設定変更後にセッション再初期化
self.initialize_session()
.map_err(|e| WorkerError::config(e.to_string()))?;
tracing::info!("Dynamic configuration loaded successfully");
Ok(())
}
/// 静的プロンプトコンテキストを作成(構築時用)
fn create_prompt_context_static(
workspace_context: &Option<WorkspaceContext>,
provider: LlmProvider,
model_name: &str,
tools: &[String],
) -> PromptContext {
) -> crate::prompt::PromptContext {
let supports_native_tools = match provider {
LlmProvider::Claude => true,
LlmProvider::OpenAI => !model_name.contains("gpt-3.5-turbo-instruct"),
@ -853,7 +806,7 @@ impl Worker {
let workspace_context = workspace_context.clone().unwrap_or_default();
PromptContext {
crate::prompt::PromptContext {
workspace: workspace_context,
model: model_context,
session: session_context,
@ -862,7 +815,7 @@ impl Worker {
}
/// プロンプトコンテキストを作成
fn create_prompt_context(&self) -> Result<PromptContext, WorkerError> {
fn create_prompt_context(&self) -> Result<crate::prompt::PromptContext, WorkerError> {
let provider = LlmProvider::from_str(&self.provider_str).ok_or_else(|| {
WorkerError::config(format!("Unknown provider: {}", self.provider_str))
})?;
@ -902,7 +855,7 @@ impl Worker {
let workspace_context = self.workspace_context.clone().unwrap_or_default();
Ok(PromptContext {
Ok(crate::prompt::PromptContext {
workspace: workspace_context,
model: model_context,
session: session_context,
@ -999,13 +952,8 @@ impl Worker {
}
/// Get configuration information for task delegation
pub fn get_config(&self) -> (LlmProvider, &str, &str, &Role) {
(
self.llm_client.provider(),
&self.model_name,
&self.api_key,
&self.role,
)
pub fn get_config(&self) -> (LlmProvider, &str, &str) {
(self.llm_client.provider(), &self.model_name, &self.api_key)
}
/// Get tool names (used to filter out specific tools)

View File

@ -1,342 +1,83 @@
use super::types::*;
use handlebars::{Context, Handlebars, Helper, HelperDef, HelperResult, Output, RenderContext};
use std::path::Path;
use super::types::{PromptContext, PromptError};
use std::sync::Arc;
// Import Message and Role enum from worker_types
use worker_types::{Message, Role as MessageRole};
/// プロンプト構築システム
pub type SystemPromptFn =
dyn Fn(&PromptContext, &[Message]) -> Result<String, PromptError> + Send + Sync;
/// シンプルなシステムプロンプト生成ラッパー
#[derive(Clone)]
pub struct PromptComposer {
config: Role,
handlebars: Handlebars<'static>,
context: PromptContext,
system_prompt: Option<String>,
resource_loader: Arc<dyn ResourceLoader>,
generator: Arc<SystemPromptFn>,
cached_prompt: Arc<std::sync::Mutex<Option<String>>>,
}
impl PromptComposer {
/// 設定ファイルから新しいインスタンスを作成
pub fn from_config_file<P: AsRef<Path>>(
config_path: P,
context: PromptContext,
resource_loader: Arc<dyn ResourceLoader>,
) -> Result<Self, PromptError> {
let config = crate::config::ConfigParser::parse_from_file(config_path)?;
Self::from_config(config, context, resource_loader)
}
/// 設定オブジェクトから新しいインスタンスを作成
pub fn from_config(
config: Role,
context: PromptContext,
resource_loader: Arc<dyn ResourceLoader>,
) -> Result<Self, PromptError> {
let mut handlebars = Handlebars::new();
// カスタムヘルパー関数を登録
Self::register_custom_helpers(&mut handlebars, resource_loader.clone())?;
let mut composer = Self {
config,
handlebars,
pub fn new(context: PromptContext, generator: Arc<SystemPromptFn>) -> Self {
Self {
context,
system_prompt: None,
resource_loader,
};
// パーシャルテンプレートを読み込み・登録
composer.load_partials()?;
Ok(composer)
}
/// セッション開始時にシステムプロンプトを事前構築
pub fn initialize_session(&mut self, initial_messages: &[Message]) -> Result<(), PromptError> {
let system_prompt = self.compose_system_prompt(initial_messages)?;
self.system_prompt = Some(system_prompt);
Ok(())
}
/// メインのプロンプト構築メソッド
pub fn compose(&self, messages: &[Message]) -> Result<Vec<Message>, PromptError> {
if let Some(system_prompt) = &self.system_prompt {
// システムプロンプトが既に構築済みの場合、それを使用
let mut result_messages =
vec![Message::new(MessageRole::System, system_prompt.clone())];
// ユーザーメッセージを追加
for msg in messages {
if msg.role != MessageRole::System {
result_messages.push(msg.clone());
}
}
Ok(result_messages)
} else {
// フォールバック: 従来の動的構築
self.compose_with_context(messages, &self.context)
generator,
cached_prompt: Arc::new(std::sync::Mutex::new(None)),
}
}
/// ツール情報を含むセッション初期化
pub fn initialize_session_with_tools(
&mut self,
initial_messages: &[Message],
tools_schema: &serde_json::Value,
) -> Result<(), PromptError> {
// 一時的にコンテキストをコピーしてツールスキーマを追加
let mut temp_context = self.context.clone();
temp_context
.variables
.insert("tools_schema".to_string(), tools_schema.clone());
let system_prompt =
self.compose_system_prompt_with_context(initial_messages, &temp_context)?;
self.system_prompt = Some(system_prompt);
/// 初期化時にシステムプロンプトを先行生成してキャッシュ
pub fn initialize_session(&mut self, initial_messages: &[Message]) -> Result<(), PromptError> {
let prompt = (self.generator)(&self.context, initial_messages)?;
if let Ok(mut guard) = self.cached_prompt.lock() {
*guard = Some(prompt);
}
Ok(())
}
/// ツール情報を含むプロンプト構築(後方互換性のため保持)
pub fn compose(&self, messages: &[Message]) -> Result<Vec<Message>, PromptError> {
let system_prompt = self.generate_with_context(&self.context, messages)?;
Ok(self.build_message_list(messages, system_prompt))
}
pub fn compose_with_tools(
&self,
messages: &[Message],
tools_schema: &serde_json::Value,
) -> Result<Vec<Message>, PromptError> {
if let Some(system_prompt) = &self.system_prompt {
// システムプロンプトが既に構築済みの場合、それを使用
let mut result_messages =
vec![Message::new(MessageRole::System, system_prompt.clone())];
let mut context = self.context.clone();
context
.variables
.insert("tools_schema".to_string(), tools_schema.clone());
let system_prompt = self.generate_with_context(&context, messages)?;
Ok(self.build_message_list(messages, system_prompt))
}
// ユーザーメッセージを追加
for msg in messages {
if msg.role != MessageRole::System {
result_messages.push(msg.clone());
fn generate_with_context(
&self,
context: &PromptContext,
messages: &[Message],
) -> Result<String, PromptError> {
match (self.generator)(context, messages) {
Ok(prompt) => {
if let Ok(mut guard) = self.cached_prompt.lock() {
*guard = Some(prompt.clone());
}
Ok(prompt)
}
Ok(result_messages)
} else {
// フォールバック: 従来の動的構築
let mut temp_context = self.context.clone();
temp_context
.variables
.insert("tools_schema".to_string(), tools_schema.clone());
self.compose_with_context(messages, &temp_context)
Err(err) => match self.cached_prompt.lock() {
Ok(cache) => cache.as_ref().cloned().ok_or(err),
Err(_) => Err(err),
},
}
}
/// システムプロンプトのみを構築(セッション初期化用)
fn compose_system_prompt(&self, messages: &[Message]) -> Result<String, PromptError> {
self.compose_system_prompt_with_context(messages, &self.context)
}
fn build_message_list(&self, messages: &[Message], system_prompt: String) -> Vec<Message> {
let mut result = Vec::with_capacity(messages.len() + 1);
result.push(Message::new(MessageRole::System, system_prompt));
/// コンテキストを指定してシステムプロンプトを構築
fn compose_system_prompt_with_context(
&self,
messages: &[Message],
context: &PromptContext,
) -> Result<String, PromptError> {
// コンテキスト変数を準備
let mut template_data = self.prepare_template_data_with_context(messages, context)?;
// 条件評価と変数の動的設定
self.apply_conditions(&mut template_data)?;
// メインテンプレートを実行
let system_prompt = self
.handlebars
.render_template(&self.config.template, &template_data)
.map_err(PromptError::Handlebars)?;
Ok(system_prompt)
}
/// コンテキストを指定してプロンプトを構築(後方互換性のため保持)
fn compose_with_context(
&self,
messages: &[Message],
context: &PromptContext,
) -> Result<Vec<Message>, PromptError> {
let system_prompt = self.compose_system_prompt_with_context(messages, context)?;
// システムメッセージとユーザーメッセージを結合
let mut result_messages = vec![Message::new(MessageRole::System, system_prompt)];
// ユーザーメッセージを追加
for msg in messages {
if msg.role != MessageRole::System {
result_messages.push(msg.clone());
result.push(msg.clone());
}
}
Ok(result_messages)
}
/// カスタムヘルパー関数を登録
fn register_custom_helpers(
handlebars: &mut Handlebars<'static>,
resource_loader: Arc<dyn ResourceLoader>,
) -> Result<(), PromptError> {
handlebars.register_helper(
"include_file",
Box::new(IncludeFileHelper {
loader: resource_loader.clone(),
}),
);
handlebars.register_helper("workspace_content", Box::new(workspace_content_helper));
Ok(())
}
/// パーシャルテンプレートを読み込み・登録
fn load_partials(&mut self) -> Result<(), PromptError> {
if let Some(partials) = &self.config.partials {
for (name, partial_config) in partials {
let content = self.load_partial_content(partial_config)?;
self.handlebars
.register_partial(name, content)
.map_err(|e| PromptError::PartialLoading(e.to_string()))?;
}
}
Ok(())
}
/// パーシャルの内容を読み込み(フォールバック対応)
fn load_partial_content(&self, partial_config: &PartialConfig) -> Result<String, PromptError> {
match self.resource_loader.load(&partial_config.path) {
Ok(content) => Ok(content),
Err(primary_err) => {
if let Some(fallback) = &partial_config.fallback {
match self.resource_loader.load(fallback) {
Ok(content) => Ok(content),
Err(fallback_err) => Err(PromptError::PartialLoading(format!(
"Could not load partial '{}' (fallback: {:?}): primary error={}, fallback error={}",
partial_config.path, partial_config.fallback, primary_err, fallback_err
))),
}
} else {
Err(primary_err)
}
}
}
}
/// コンテキストを指定してテンプレート用のデータを準備
fn prepare_template_data_with_context(
&self,
messages: &[Message],
context: &PromptContext,
) -> Result<serde_json::Value, PromptError> {
let user_input = messages
.iter()
.filter(|m| m.role == MessageRole::User)
.map(|m| m.content.as_str())
.collect::<Vec<_>>()
.join("\n\n");
let mut data = serde_json::json!({
"workspace": context.workspace,
"model": context.model,
"session": context.session,
"user_input": user_input,
"tools": context.variables.get("tools_schema").unwrap_or(&serde_json::Value::Null),
"tools_schema": context.variables.get("tools_schema").unwrap_or(&serde_json::Value::Null),
});
// 設定ファイルの変数を追加
if let Some(variables) = &self.config.variables {
for (key, value_template) in variables {
// 変数値もHandlebarsテンプレートとして処理
let resolved_value = self
.handlebars
.render_template(value_template, &data)
.map_err(PromptError::Handlebars)?;
data[key] = serde_json::Value::String(resolved_value);
}
}
// コンテキストの追加変数をマージ
for (key, value) in &context.variables {
data[key] = value.clone();
}
Ok(data)
}
/// 条件評価と動的変数設定
fn apply_conditions(&self, data: &mut serde_json::Value) -> Result<(), PromptError> {
if let Some(conditions) = &self.config.conditions {
for (_condition_name, condition_config) in conditions {
// 条件式を評価
let condition_result = self
.handlebars
.render_template(&condition_config.when, data)
.map_err(PromptError::Handlebars)?;
// 条件が真の場合、変数を適用
if condition_result.trim() == "true" {
if let Some(variables) = &condition_config.variables {
for (key, value_template) in variables {
let resolved_value = self
.handlebars
.render_template(value_template, data)
.map_err(PromptError::Handlebars)?;
data[key] = serde_json::Value::String(resolved_value);
}
}
}
}
}
Ok(())
result
}
}
// カスタムヘルパー関数の実装
struct IncludeFileHelper {
loader: Arc<dyn ResourceLoader>,
}
impl HelperDef for IncludeFileHelper {
fn call<'reg: 'rc, 'rc>(
&self,
h: &Helper<'rc>,
_handlebars: &Handlebars<'reg>,
_context: &Context,
_rc: &mut RenderContext<'reg, 'rc>,
out: &mut dyn Output,
) -> HelperResult {
let file_path = h.param(0).and_then(|v| v.value().as_str()).unwrap_or("");
match self.loader.load(file_path) {
Ok(content) => {
out.write(&content)?;
}
Err(_) => {
// ファイルが見つからない場合は空文字を出力
out.write("")?;
}
}
Ok(())
}
}
fn workspace_content_helper(
_h: &Helper,
_hbs: &Handlebars,
ctx: &Context,
_rc: &mut RenderContext,
out: &mut dyn Output,
) -> HelperResult {
if let Some(workspace) = ctx.data().get("workspace") {
if let Some(content) = workspace.get("nia_md_content") {
if let Some(content_str) = content.as_str() {
out.write(content_str)?;
}
}
}
Ok(())
}

View File

@ -1,8 +1,8 @@
mod composer;
mod types;
pub use composer::PromptComposer;
pub use composer::{PromptComposer, SystemPromptFn};
pub use types::{
ConditionConfig, GitInfo, ModelCapabilities, ModelContext, PartialConfig, ProjectType,
PromptContext, PromptError, ResourceLoader, Role, SessionContext, SystemInfo, WorkspaceContext,
GitInfo, ModelCapabilities, ModelContext, ProjectType, PromptContext, PromptError,
SessionContext, SystemInfo, WorkspaceContext,
};

View File

@ -2,37 +2,6 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
/// Role configuration - defines the system instructions for the LLM
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Role {
pub name: String,
pub description: String,
pub version: Option<String>,
pub template: String,
pub partials: Option<HashMap<String, PartialConfig>>,
pub variables: Option<HashMap<String, String>>,
pub conditions: Option<HashMap<String, ConditionConfig>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PartialConfig {
pub path: String,
pub fallback: Option<String>,
pub description: Option<String>,
}
/// External resource loader used to resolve template includes/partials
pub trait ResourceLoader: Send + Sync {
fn load(&self, identifier: &str) -> Result<String, PromptError>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConditionConfig {
pub when: String,
pub variables: Option<HashMap<String, String>>,
pub template_override: Option<String>,
}
/// システム情報
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemInfo {
@ -116,35 +85,17 @@ pub struct PromptContext {
pub variables: HashMap<String, serde_json::Value>,
}
/// プロンプト構築エラー
/// システムプロンプト生成時のエラー
#[derive(Debug, thiserror::Error)]
pub enum PromptError {
#[error("Template compilation error: {0}")]
TemplateCompilation(String),
#[error("Variable resolution error: {0}")]
VariableResolution(String),
#[error("Partial loading error: {0}")]
PartialLoading(String),
#[error("File not found: {0}")]
FileNotFound(String),
#[error("System prompt generation error: {0}")]
Generation(String),
#[error("Workspace detection error: {0}")]
WorkspaceDetection(String),
#[error("Git information error: {0}")]
GitInfo(String),
#[error("Handlebars error: {0}")]
Handlebars(#[from] handlebars::RenderError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("YAML parsing error: {0}")]
YamlParsing(#[from] serde_yaml::Error),
}
impl SystemInfo {
@ -356,22 +307,3 @@ impl Default for SessionContext {
}
}
}
impl Role {
/// Create a new Role with name, description, and template
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
template: impl Into<String>,
) -> Self {
Self {
name: name.into(),
description: description.into(),
version: Some("1.0.0".to_string()),
template: template.into(),
partials: None,
variables: None,
conditions: None,
}
}
}

View File

@ -1,334 +0,0 @@
use crate::config::ConfigParser;
use crate::prompt::{
ModelCapabilities, ModelContext, PromptComposer, PromptContext, PromptError, ResourceLoader,
SessionContext, SystemInfo, WorkspaceContext,
};
use crate::types::LlmProvider;
use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::NamedTempFile;
#[test]
fn test_parse_basic_config() {
let yaml_content = r##"
name: "Test Assistant"
description: "A test configuration"
version: "1.0"
template: |
# Test Role
{{>role_header}}
{{#if workspace.has_nia_md}}
# Project Context
{{workspace.nia_md_content}}
{{/if}}
partials:
role_header:
path: "#nia/prompts/headers/role.md"
description: "Basic role definition"
variables:
max_context_length: "{{model.context_length}}"
project_name: "{{workspace.project_name}}"
"##;
let config =
ConfigParser::parse_from_string(yaml_content).expect("Failed to parse basic config");
assert_eq!(config.name, "Test Assistant");
assert_eq!(config.description, "A test configuration");
assert_eq!(config.version, Some("1.0".to_string()));
assert!(!config.template.is_empty());
// パーシャルのテスト
let partials = config.partials.expect("Partials should be present");
assert!(partials.contains_key("role_header"));
let role_header = &partials["role_header"];
assert_eq!(role_header.path, "#nia/prompts/headers/role.md");
assert_eq!(
role_header.description,
Some("Basic role definition".to_string())
);
// 変数のテスト
let variables = config.variables.expect("Variables should be present");
assert!(variables.contains_key("max_context_length"));
assert!(variables.contains_key("project_name"));
}
#[test]
fn test_parse_minimal_config() {
let yaml_content = r##"
name: "Minimal Assistant"
description: "A minimal configuration"
template: "Hello {{user_input}}"
"##;
let config =
ConfigParser::parse_from_string(yaml_content).expect("Failed to parse minimal config");
assert_eq!(config.name, "Minimal Assistant");
assert_eq!(config.description, "A minimal configuration");
assert_eq!(config.template, "Hello {{user_input}}");
assert!(config.partials.is_none());
assert!(config.variables.is_none());
assert!(config.conditions.is_none());
}
#[test]
fn test_parse_with_conditions() {
let yaml_content = r##"
name: "Conditional Assistant"
description: "Configuration with conditions"
template: "Base template"
conditions:
native_tools_enabled:
when: "{{model.supports_native_tools}}"
variables:
tool_format: "native"
include_tool_schemas: false
xml_tools_enabled:
when: "{{not model.supports_native_tools}}"
variables:
tool_format: "xml"
include_tool_schemas: true
"##;
let config = ConfigParser::parse_from_string(yaml_content)
.expect("Failed to parse config with conditions");
let conditions = config.conditions.expect("Conditions should be present");
assert!(conditions.contains_key("native_tools_enabled"));
assert!(conditions.contains_key("xml_tools_enabled"));
let native_condition = &conditions["native_tools_enabled"];
assert_eq!(native_condition.when, "{{model.supports_native_tools}}");
let variables = native_condition
.variables
.as_ref()
.expect("Variables should be present");
assert_eq!(variables.get("tool_format"), Some(&"native".to_string()));
assert_eq!(
variables.get("include_tool_schemas"),
Some(&"false".to_string())
);
}
#[test]
fn test_validation_errors() {
// 空の名前
let invalid_yaml = r##"
name: ""
description: "Test"
template: "Test template"
"##;
let result = ConfigParser::parse_from_string(invalid_yaml);
assert!(result.is_err());
// 空のテンプレート
let invalid_yaml = r##"
name: "Test"
description: "Test"
template: ""
"##;
let result = ConfigParser::parse_from_string(invalid_yaml);
assert!(result.is_err());
// 空のパーシャルパス
let invalid_yaml = r##"
name: "Test"
description: "Test"
template: "Test template"
partials:
empty_path:
path: ""
"##;
let result = ConfigParser::parse_from_string(invalid_yaml);
assert!(result.is_err());
}
#[test]
fn test_parse_from_file() {
let yaml_content = r##"
name: "File Test Assistant"
description: "Testing file parsing"
template: "File content {{user_input}}"
"##;
let mut temp_file = NamedTempFile::new().expect("Failed to create temp file");
temp_file
.write_all(yaml_content.as_bytes())
.expect("Failed to write to temp file");
let config =
ConfigParser::parse_from_file(temp_file.path()).expect("Failed to parse config from file");
assert_eq!(config.name, "File Test Assistant");
assert_eq!(config.description, "Testing file parsing");
assert_eq!(config.template, "File content {{user_input}}");
}
struct InMemoryLoader {
data: HashMap<String, String>,
}
impl InMemoryLoader {
fn new(data: HashMap<String, String>) -> Self {
Self { data }
}
}
impl ResourceLoader for InMemoryLoader {
fn load(&self, identifier: &str) -> Result<String, PromptError> {
self.data
.get(identifier)
.cloned()
.ok_or_else(|| PromptError::FileNotFound(format!("not found: {}", identifier)))
}
}
fn build_prompt_context() -> PromptContext {
let workspace = WorkspaceContext {
root_path: PathBuf::from("."),
nia_md_content: None,
project_type: None,
git_info: None,
has_nia_md: false,
project_name: None,
system_info: SystemInfo::collect(),
};
let capabilities = ModelCapabilities {
supports_tools: false,
supports_function_calling: false,
supports_vision: false,
supports_multimodal: None,
context_length: None,
capabilities: vec![],
needs_verification: None,
};
let model_context = ModelContext {
provider: LlmProvider::Claude,
model_name: "test-model".to_string(),
capabilities,
supports_native_tools: false,
};
let session_context = SessionContext {
conversation_id: None,
message_count: 0,
active_tools: vec![],
user_preferences: None,
};
PromptContext {
workspace,
model: model_context,
session: session_context,
variables: HashMap::new(),
}
}
#[test]
fn test_prompt_composer_uses_resource_loader() {
let yaml_content = r##"
name: "Loader Test"
description: "Ensure resource loader is used"
template: |
{{>header}}
{{include_file "include.md"}}
partials:
header:
path: "missing.md"
fallback: "fallback.md"
"##;
let role =
ConfigParser::parse_from_string(yaml_content).expect("Failed to parse loader test config");
let loader = Arc::new(InMemoryLoader::new(HashMap::from([
("fallback.md".to_string(), "Fallback Partial".to_string()),
("include.md".to_string(), "Included Content".to_string()),
])));
let prompt_context = build_prompt_context();
let composer = PromptComposer::from_config(role, prompt_context, loader)
.expect("Composer should use provided loader");
let messages = composer
.compose(&[])
.expect("Composer should build system prompt");
assert!(!messages.is_empty());
let system_message = &messages[0];
assert!(system_message.content.contains("Fallback Partial"));
assert!(system_message.content.contains("Included Content"));
}
#[test]
fn test_complex_template_syntax() {
let yaml_content = r##"
name: "Complex Template Assistant"
description: "Testing complex Handlebars syntax"
template: |
# Dynamic Role
{{>role_header}}
{{#if workspace.has_nia_md}}
# Project: {{workspace.project_name}}
{{workspace.nia_md_content}}
{{/if}}
{{#if_native_tools model.supports_native_tools}}
Native tools are supported.
{{else}}
Using XML-based tool calls.
Available tools:
```json
{{tools_schema}}
```
{{/if_native_tools}}
{{#model_specific model.provider}}
{{#case "Claude"}}
Claude-specific instructions here.
{{/case}}
{{#case "Gemini"}}
Gemini-specific instructions here.
{{/case}}
{{#default}}
Generic model instructions.
{{/default}}
{{/model_specific}}
partials:
role_header:
path: "#nia/prompts/headers/role.md"
"##;
let config =
ConfigParser::parse_from_string(yaml_content).expect("Failed to parse complex template");
assert!(!config.template.is_empty());
assert!(config.template.contains("{{>role_header}}"));
assert!(config.template.contains("{{#if workspace.has_nia_md}}"));
assert!(
config
.template
.contains("{{#if_native_tools model.supports_native_tools}}")
);
assert!(
config
.template
.contains("{{#model_specific model.provider}}")
);
}

View File

@ -1,333 +0,0 @@
use crate::types::{LlmProvider, Message, Role};
use crate::workspace_detector::WorkspaceDetector;
use std::collections::HashMap;
use std::fs;
use tempfile::TempDir;
#[test]
#[ignore] // Temporarily disabled due to missing dependencies
fn test_full_dynamic_prompt_composition() {
// テスト用の一時ディレクトリを作成
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let temp_path = temp_dir.path();
// テスト用のNIA.mdファイルを作成
let nia_md_content = r#"# Test Project
This is a test project for dynamic prompt composition.
## Features
- Dynamic prompt generation
- Workspace detection
- Model-specific optimizations
"#;
fs::write(temp_path.join("NIA.md"), nia_md_content).expect("Failed to write NIA.md");
// テスト用のCargoファイルを作成Rustプロジェクトとして認識させる
let cargo_toml = r#"[package]
name = "test-project"
version = "0.1.0"
edition = "2021"
"#;
fs::write(temp_path.join("Cargo.toml"), cargo_toml).expect("Failed to write Cargo.toml");
// ワークスペースコンテキストを取得
let workspace = WorkspaceDetector::detect_workspace_from_path(temp_path)
.expect("Failed to detect workspace");
assert!(workspace.has_nia_md);
assert_eq!(workspace.project_type, Some(ProjectType::Rust));
assert!(workspace.nia_md_content.is_some());
assert_eq!(workspace.project_name, Some("test-project".to_string()));
// モデルコンテキストを作成
let model_context = ModelContext {
provider: LlmProvider::Claude,
model_name: "claude-3-sonnet".to_string(),
capabilities: ModelCapabilities {
supports_tools: true,
supports_function_calling: true,
..Default::default()
},
supports_native_tools: true,
};
// セッションコンテキストを作成
let session_context = SessionContext {
conversation_id: Some("test-conv".to_string()),
message_count: 1,
active_tools: vec!["file_read".to_string(), "file_write".to_string()],
user_preferences: None,
};
// 全体的なプロンプトコンテキストを作成
let prompt_context = PromptContext {
workspace,
model: model_context,
session: session_context,
variables: HashMap::new(),
};
// 動的設定を作成
let config = create_test_dynamic_config();
// DynamicPromptComposerを作成
let mut composer = DynamicPromptComposer::from_config(config, prompt_context)
.expect("Failed to create composer");
// テストメッセージ
let messages = vec![Message {
role: Role::User,
content: "Please help me understand the project structure".to_string(),
}];
// プロンプトを構築
let result = composer
.compose(&messages)
.expect("Failed to compose prompt");
assert!(!result.is_empty());
assert_eq!(result[0].role, Role::System);
// 生成されたプロンプトにワークスペース情報が含まれていることを確認
let system_prompt = &result[0].content;
assert!(system_prompt.contains("Test Project"));
assert!(system_prompt.contains("dynamic prompt generation"));
assert!(system_prompt.contains("test-project"));
}
#[test]
#[ignore] // Temporarily disabled due to missing dependencies
fn test_native_tools_vs_xml_tools() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let workspace = WorkspaceDetector::detect_workspace_from_path(temp_dir.path())
.expect("Failed to detect workspace");
// ネイティブツールサポートありのモデル
let native_model = ModelContext {
provider: LlmProvider::Claude,
model_name: "claude-3-sonnet".to_string(),
capabilities: ModelCapabilities {
supports_tools: true,
supports_function_calling: true,
..Default::default()
},
supports_native_tools: true,
};
// XMLツールのみのモデル
let xml_model = ModelContext {
provider: LlmProvider::Ollama,
model_name: "llama3".to_string(),
capabilities: ModelCapabilities {
supports_tools: false,
supports_function_calling: false,
..Default::default()
},
supports_native_tools: false,
};
let session = SessionContext::default();
// 両方のモデルでプロンプトを生成
let native_context = PromptContext {
workspace: workspace.clone(),
model: native_model,
session: session.clone(),
variables: HashMap::new(),
};
let xml_context = PromptContext {
workspace: workspace.clone(),
model: xml_model,
session: session.clone(),
variables: HashMap::new(),
};
let config = create_test_dynamic_config();
let mut native_composer = DynamicPromptComposer::from_config(config.clone(), native_context)
.expect("Failed to create native composer");
let mut xml_composer = DynamicPromptComposer::from_config(config, xml_context)
.expect("Failed to create xml composer");
let messages = vec![Message {
role: Role::User,
content: "Test message".to_string(),
}];
let native_result = native_composer
.compose(&messages)
.expect("Failed to compose native prompt");
let xml_result = xml_composer
.compose(&messages)
.expect("Failed to compose xml prompt");
// 両方のプロンプトが生成されることを確認
assert!(!native_result.is_empty());
assert!(!xml_result.is_empty());
// ネイティブツール用プロンプトとXMLツール用プロンプトが異なることを確認
assert_ne!(native_result[0].content, xml_result[0].content);
}
#[test]
#[ignore] // Temporarily disabled due to missing dependencies
fn test_workspace_detection_without_nia_md() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
// .nia ディレクトリのみ作成NIA.mdなし
fs::create_dir(temp_dir.path().join(".nia")).expect("Failed to create .nia dir");
let workspace = WorkspaceDetector::detect_workspace_from_path(temp_dir.path())
.expect("Failed to detect workspace");
assert!(!workspace.has_nia_md);
assert!(workspace.nia_md_content.is_none());
assert_eq!(workspace.project_type, Some(ProjectType::Unknown));
}
#[test]
#[ignore] // Temporarily disabled due to missing dependencies
fn test_project_type_detection() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let temp_path = temp_dir.path();
// TypeScriptプロジェクト
fs::write(temp_path.join("package.json"), r#"{"name": "test"}"#)
.expect("Failed to write package.json");
fs::write(temp_path.join("tsconfig.json"), "{}").expect("Failed to write tsconfig.json");
let workspace = WorkspaceDetector::detect_workspace_from_path(temp_path)
.expect("Failed to detect workspace");
assert_eq!(workspace.project_type, Some(ProjectType::TypeScript));
}
#[test]
#[ignore] // Temporarily disabled due to missing dependencies
fn test_tools_schema_integration() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let workspace = WorkspaceDetector::detect_workspace_from_path(temp_dir.path())
.expect("Failed to detect workspace");
let model_context = ModelContext {
provider: LlmProvider::Gemini,
model_name: "gemini-1.5-flash".to_string(),
capabilities: ModelCapabilities {
supports_tools: false,
supports_function_calling: false,
..Default::default()
},
supports_native_tools: false,
};
let session_context = SessionContext::default();
let prompt_context = PromptContext {
workspace,
model: model_context,
session: session_context,
variables: HashMap::new(),
};
let config = create_test_dynamic_config();
let mut composer = DynamicPromptComposer::from_config(config, prompt_context)
.expect("Failed to create composer");
// ツールスキーマを作成
let tools_schema = serde_json::json!([
{
"name": "file_read",
"description": "Read a file",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"}
}
}
}
]);
let messages = vec![Message {
role: Role::User,
content: "Read a file for me".to_string(),
}];
// ツール情報付きでプロンプトを構築
let result = composer
.compose_with_tools(&messages, &tools_schema)
.expect("Failed to compose with tools");
assert!(!result.is_empty());
// XMLツールモデルなので、ツール情報がプロンプトに含まれるはず
let system_prompt = &result[0].content;
assert!(system_prompt.contains("file_read"));
}
// テスト用の動的設定を作成
fn create_test_dynamic_config() -> DynamicRoleConfig {
let mut variables = HashMap::new();
variables.insert(
"project_name".to_string(),
"{{workspace.project_name}}".to_string(),
);
variables.insert("model_name".to_string(), "{{model.model_name}}".to_string());
let mut conditions = HashMap::new();
// ネイティブツール条件
let mut native_vars = HashMap::new();
native_vars.insert("tool_format".to_string(), "native".to_string());
conditions.insert(
"native_tools".to_string(),
ConditionConfig {
when: "{{model.supports_native_tools}}".to_string(),
variables: Some(native_vars),
template_override: None,
},
);
// XMLツール条件
let mut xml_vars = HashMap::new();
xml_vars.insert("tool_format".to_string(), "xml".to_string());
conditions.insert(
"xml_tools".to_string(),
ConditionConfig {
when: "{{not model.supports_native_tools}}".to_string(),
variables: Some(xml_vars),
template_override: None,
},
);
DynamicRoleConfig {
name: "Test Assistant".to_string(),
description: "A test configuration".to_string(),
version: Some("1.0".to_string()),
template: r#"# Test Role
{{#if workspace.has_nia_md}}
# Project Context
Project: {{workspace.project_name}}
{{workspace.nia_md_content}}
{{/if}}
{{#if model.supports_native_tools}}
Native tools are supported for {{model.model_name}}.
{{else}}
Using XML-based tool calls for {{model.model_name}}.
{{#if tools_schema}}
Available tools: {{tools_schema}}
{{/if}}
{{/if}}
User request: {{user_input}}
"#
.to_string(),
partials: None, // パーシャルを使わない
variables: Some(variables),
conditions: Some(conditions),
}
}