kowalski-core 2.3.1

Core library of Kowalski, a Rust multi-agent framework: agents with MCP tools and memory, and hordes (step pipelines with triggers) over Ollama or OpenAI-compatible models
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

/// Single source of truth for how kowalski processes find the HTTP API server.
/// The server binds here by default (`kowalski --bind`); the CLI targets
/// `http://<DEFAULT_API_BIND>` unless overridden; `ui/vite.config.ts` proxies to the
/// same address (keep it in sync — TypeScript cannot import this constant).
pub const DEFAULT_API_BIND: &str = "127.0.0.1:3456";

/// Env var carrying the server base URL (e.g. `http://127.0.0.1:3456`). Read by the CLI
/// (`--api` flag wins over it); exported by the server to every worker it spawns.
pub const API_URL_ENV: &str = "KOWALSKI_API";

/// Env var carrying the API bearer token. Read by the CLI on every `/api/*` call;
/// exported by the server to every worker it spawns; overrides the server's token file.
pub const API_TOKEN_ENV: &str = "KOWALSKI_API_TOKEN";

/// Env var overriding the horde-run store database URL (e.g. `sqlite:/path/runs.sqlite`).
/// Without it the store creates `runs.sqlite` under the server state dir — zero config.
pub const RUN_DB_ENV: &str = "KOWALSKI_RUN_DB";

/// Env var overriding the `kowalski-cli` binary used to spawn process-isolated
/// horde steps (`agent-app exec-step`). Without it the server looks for a
/// sibling `kowalski-cli` next to its own executable, then falls back to
/// `cargo run -p kowalski-cli` (dev tree).
pub const CLI_BIN_ENV: &str = "KOWALSKI_CLI_BIN";

/// Env var naming the config file explicitly.
pub const CONFIG_ENV: &str = "KOWALSKI_CONFIG";

/// Single source of truth for which config file the server and the CLI read when `-c` is not
/// given: [`CONFIG_ENV`], else `./config.toml` when it exists, else the per-user file that
/// `install.sh` writes (`$XDG_CONFIG_HOME/kowalski/config.toml`, default
/// `~/.config/kowalski/config.toml`) when it exists, else `./config.toml` (missing: defaults).
pub fn default_config_path() -> std::path::PathBuf {
    use std::path::PathBuf;
    if let Ok(p) = std::env::var(CONFIG_ENV)
        && !p.trim().is_empty()
    {
        return PathBuf::from(p.trim());
    }
    let local = PathBuf::from("config.toml");
    if local.is_file() {
        return local;
    }
    if let Some(user) = user_config_path()
        && user.is_file()
    {
        return user;
    }
    local
}

/// The per-user config file location (`$XDG_CONFIG_HOME/kowalski/config.toml`, default
/// `~/.config/kowalski/config.toml`), whether or not it exists.
pub fn user_config_path() -> Option<std::path::PathBuf> {
    use std::path::PathBuf;
    let base = std::env::var("XDG_CONFIG_HOME")
        .ok()
        .filter(|v| !v.trim().is_empty())
        .map(PathBuf::from)
        .or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".config")))?;
    Some(base.join("kowalski").join("config.toml"))
}

/// Env var overriding horde discovery roots: a `:`-separated list of directories
/// scanned for horde definitions. Its first entry is also the user hordes root
/// (where imported bundles land).
pub const HORDES_DIR_ENV: &str = "KOWALSKI_HORDES_DIR";

/// Single source of truth for horde discovery roots, in priority order:
/// [`HORDES_DIR_ENV`] entries, `<config-dir>/hordes`, `<config-dir>/../examples`,
/// and `<cwd>/examples`. Used by the server catalog and by CLI horde-id resolution.
pub fn default_horde_roots(config_dir: Option<&std::path::Path>) -> Vec<std::path::PathBuf> {
    use std::path::PathBuf;
    let mut roots = Vec::new();
    if let Ok(env) = std::env::var(HORDES_DIR_ENV) {
        for piece in env.split(':') {
            if !piece.trim().is_empty() {
                roots.push(PathBuf::from(piece.trim()));
            }
        }
    }
    if let Some(c) = config_dir {
        roots.push(c.join("hordes"));
        if let Some(parent) = c.parent() {
            roots.push(parent.join("examples"));
        }
    }
    if let Ok(cwd) = std::env::current_dir() {
        roots.push(cwd.join("examples"));
    }
    let mut seen = std::collections::HashSet::new();
    roots.retain(|p| seen.insert(p.clone()));
    roots
}

/// Single source of truth for the user hordes root — where imported bundles land:
/// the first [`HORDES_DIR_ENV`] entry when set, else `<config-dir>/hordes`.
/// Never an `examples/` directory.
pub fn user_hordes_root(config_dir: Option<&std::path::Path>) -> Option<std::path::PathBuf> {
    if let Ok(env) = std::env::var(HORDES_DIR_ENV) {
        for piece in env.split(':') {
            if !piece.trim().is_empty() {
                return Some(std::path::PathBuf::from(piece.trim()));
            }
        }
    }
    config_dir.map(|c| c.join("hordes"))
}

/// Single source of truth for the Ollama base URL a config resolves to:
/// `http://<ollama.host>:<ollama.port>`.
pub fn ollama_base_url(config: &Config) -> String {
    format!("http://{}:{}", config.ollama.host, config.ollama.port)
}

/// Single source of truth for the model a deployment chats with:
/// `llm.model` when the `openai` provider declares one, else `ollama.model`.
pub fn default_model(config: &Config) -> String {
    if config.llm.provider == "openai"
        && let Some(ref model) = config.llm.model
    {
        return model.clone();
    }
    config.ollama.model.clone()
}

/// Core configuration for the Kowalski system
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
    /// Ollama configuration
    pub ollama: OllamaConfig,
    /// Chat configuration
    pub chat: ChatConfig,
    /// Memory configuration
    pub memory: MemoryConfig,
    /// Maximum number of memories to retrieve from working memory
    pub working_memory_retrieval_limit: usize,
    /// Maximum number of memories to retrieve from episodic memory
    pub episodic_memory_retrieval_limit: usize,
    /// Maximum number of memories to retrieve from semantic memory
    pub semantic_memory_retrieval_limit: usize,
    /// LLM configuration (new)
    #[serde(default)]
    pub llm: LLMConfig,
    /// MCP configuration
    #[serde(default)]
    pub mcp: McpConfig,
    /// Additional configurations from other agents
    #[serde(flatten)]
    pub additional: HashMap<String, serde_json::Value>,
}

/// Configuration for generic LLM settings
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LLMConfig {
    /// The provider to use: `ollama` (local) or `openai` (Chat Completions API — OpenAI or compatible).
    pub provider: String,
    /// API key for `openai` provider (OpenAI, Groq, etc.). When the TOML omits it, the
    /// `OPENAI_API_KEY` environment variable is used, so keys stay out of config files; many
    /// servers need none, use `""` in TOML for those.
    #[serde(default = "openai_key_from_env")]
    pub openai_api_key: Option<String>,
    /// Base URL for OpenAI-compatible Chat Completions (e.g. `https://api.openai.com/v1`, or
    /// `http://127.0.0.1:1234/v1` for LM Studio). If unset, the official OpenAI API base is used.
    #[serde(default)]
    pub openai_api_base: Option<String>,
    /// Model name for the LLM provider. When using `openai` provider, this is the model ID to use.
    /// For Ollama, this can be left empty to use `ollama.model` as fallback.
    #[serde(default)]
    pub model: Option<String>,
    /// Provider to use for embeddings: `llm` (same as chat) or `ollama` (use Ollama's embeddings API).
    /// Set to `"ollama"` if your OpenAI-compatible API doesn't support embeddings.
    #[serde(default = "default_embeddings_provider")]
    pub embeddings_provider: String,
    /// Opt in to **native tool calling**: tool declarations are sent on the wire and the
    /// model returns structured tool calls instead of JSON-in-text. Requires a tool-capable
    /// model (both Ollama and OpenAI-compatible providers support it). Default `false` —
    /// agents fall back to text-based tool prompting.
    #[serde(default)]
    pub native_tools: bool,
    /// Which tool loop the agent runs (see [`ToolCallingMode`]). Default `auto`: native
    /// when [`Self::native_tools`] declares the model capable, else the text-based ReAct
    /// loop. `native` / `react` force one path regardless of the capability flag.
    #[serde(default)]
    pub tool_calling: ToolCallingMode,
    /// Opt in to **constrained structured output**: a JSON Schema travels on the request
    /// (Ollama `format`; OpenAI-compatible strict `response_format: json_schema`) and the
    /// model's reply is forced to conform. Requires backend support. Default `false` —
    /// callers fall back to prompt-based JSON extraction.
    #[serde(default)]
    pub structured_output: bool,
}

/// Single source of truth for the agent tool-loop policy (`[llm] tool_calling`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ToolCallingMode {
    /// Native structured tool calls when the provider reports the model capable
    /// (`[llm] native_tools`), otherwise the ReAct text loop.
    #[default]
    Auto,
    /// Always the native path, even without the capability flag (trust the operator).
    Native,
    /// Always the ReAct text loop, even for tool-capable models.
    React,
}

fn openai_key_from_env() -> Option<String> {
    std::env::var("OPENAI_API_KEY").ok().filter(|k| !k.is_empty())
}

fn default_embeddings_provider() -> String {
    "llm".to_string()
}

impl Default for LLMConfig {
    fn default() -> Self {
        Self {
            provider: "ollama".to_string(),
            openai_api_key: openai_key_from_env(),
            openai_api_base: None,
            model: None,
            embeddings_provider: "llm".to_string(),
            native_tools: false,
            tool_calling: ToolCallingMode::default(),
            structured_output: false,
        }
    }
}

/// Configuration for Ollama integration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct OllamaConfig {
    /// The host where Ollama is running
    pub host: String,
    /// The port where Ollama is running
    pub port: u16,
    /// The model to use
    pub model: String,
    /// Additional Ollama-specific settings
    #[serde(flatten)]
    pub additional: HashMap<String, serde_json::Value>,
}

impl Default for OllamaConfig {
    fn default() -> Self {
        Self {
            host: "localhost".to_string(),
            port: 11434,
            model: "llama3.2".to_string(), //llama3.2 //deepseek-r1:1.5b
            additional: HashMap::new(),
        }
    }
}

/// Configuration for chat functionality
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ChatConfig {
    /// Maximum number of messages to keep in history
    pub max_history: usize,
    /// Whether to enable streaming responses (`stream` is accepted as a TOML field alias)
    #[serde(alias = "stream")]
    pub enable_streaming: bool,
    /// Temperature for response generation (0.0 to 1.0)
    pub temperature: f32,
    /// Maximum number of tokens in generated responses
    pub max_tokens: u32,
    /// Additional chat-specific settings
    #[serde(flatten)]
    pub additional: HashMap<String, serde_json::Value>,
}

impl Default for ChatConfig {
    fn default() -> Self {
        Self {
            max_history: 100,
            enable_streaming: true,
            temperature: 0.7,
            max_tokens: 2048,
            additional: HashMap::new(),
        }
    }
}

fn default_embedding_vector_dimensions() -> usize {
    768
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct MemoryConfig {
    /// **Default Tier-2 episodic store:** embedded **SQLite** file under this path (`episodic.sqlite` in the directory, or a path ending in `.sqlite`/`.db`). Used when [`Self::database_url`] is unset or does not request PostgreSQL.
    pub episodic_path: String,
    /// Optional: set to **`postgres://…`** / **`postgresql://…`** to use PostgreSQL for Tier 2 (`episodic_kv`) and Tier 3 semantic SQL (**requires** `kowalski-core` **`--features postgres`**). If omitted, Tier 2 stays on **SQLite** ([`Self::episodic_path`]) — the default.
    #[serde(default)]
    pub database_url: Option<String>,
    /// Embedding width for **PostgreSQL** `semantic_memory.embedding` (`vector(N)`). Must match your embedder (e.g. **768** for Ollama `nomic-embed-text`) and the dimension in `migrations/postgres/003_semantic_memory.sql` (this crate).
    #[serde(default = "default_embedding_vector_dimensions")]
    pub embedding_vector_dimensions: usize,
    #[serde(flatten)]
    pub additional: HashMap<String, serde_json::Value>,
}

impl Default for MemoryConfig {
    fn default() -> Self {
        Self {
            episodic_path: "../target/episodic_db".to_string(), //just for testing!
            database_url: None,
            embedding_vector_dimensions: default_embedding_vector_dimensions(),
            additional: HashMap::new(),
        }
    }
}

/// Returns true when [`MemoryConfig::database_url`] points at PostgreSQL (episodic + semantic SQL backends).
pub fn memory_uses_postgres(memory: &MemoryConfig) -> bool {
    memory
        .database_url
        .as_ref()
        .is_some_and(|u| u.starts_with("postgres://") || u.starts_with("postgresql://"))
}

#[cfg(test)]
mod postgres_flag_tests {
    use super::{MemoryConfig, memory_uses_postgres};

    #[test]
    fn memory_uses_postgres_detects_url() {
        let mut m = MemoryConfig::default();
        assert!(!memory_uses_postgres(&m));
        m.database_url = Some("postgres://localhost/db".to_string());
        assert!(memory_uses_postgres(&m));
        m.database_url = Some("postgresql://localhost/db".to_string());
        assert!(memory_uses_postgres(&m));
    }
}

/// Build-time `postgres` feature was not enabled while config requests a PostgreSQL URL.
pub fn postgres_feature_required_error() -> crate::error::KowalskiError {
    crate::error::KowalskiError::Configuration(
        "PostgreSQL support requires building with `--features postgres` (e.g. `cargo build -p kowalski-core --features postgres` or `cargo build -p kowalski-cli --features postgres`).".to_string(),
    )
}

/// Trait for extending configuration with additional settings
pub trait ConfigExt {
    /// Get a reference to the core configuration
    fn core(&self) -> &Config;

    /// Get a mutable reference to the core configuration
    fn core_mut(&mut self) -> &mut Config;

    /// Get additional configuration value by key
    fn get_additional<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<T> {
        self.core()
            .additional
            .get(key)
            .and_then(|v| serde_json::from_value(v.clone()).ok())
    }

    /// Set additional configuration value
    fn set_additional<T: serde::Serialize>(&mut self, key: &str, value: T) {
        if let Ok(json) = serde_json::to_value(value) {
            self.core_mut().additional.insert(key.to_string(), json);
        }
    }
}

impl Default for Config {
    fn default() -> Self {
        Self {
            ollama: OllamaConfig::default(),
            llm: LLMConfig::default(),
            mcp: McpConfig::default(),
            chat: ChatConfig::default(),
            memory: MemoryConfig::default(),
            working_memory_retrieval_limit: 3,
            episodic_memory_retrieval_limit: 3,
            semantic_memory_retrieval_limit: 3,
            additional: HashMap::new(),
        }
    }
}

/// Configuration for MCP servers
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct McpConfig {
    #[serde(default)]
    pub servers: Vec<McpServerConfig>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerConfig {
    pub name: String,
    /// Base URL for HTTP/SSE; ignored for `stdio` (use `command`).
    #[serde(default)]
    pub url: String,
    /// Preferred transport, defaults to SSE as per spec
    #[serde(default)]
    pub transport: McpTransport,
    /// Optional static headers (e.g., auth tokens)
    #[serde(default)]
    pub headers: HashMap<String, String>,
    /// argv for [`McpTransport::Stdio`] (program + args).
    #[serde(default)]
    pub command: Vec<String>,
    /// OAuth sign-in for HTTP servers that require it (written by the setup screen): the
    /// bearer token comes from this token file and is refreshed before it expires.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub oauth: Option<McpOAuthConfig>,
}

/// Where an OAuth-connected MCP server's tokens live.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct McpOAuthConfig {
    /// JSON token file (owner-only permissions), see `mcp::oauth::TokenSet`.
    pub token_file: std::path::PathBuf,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum McpTransport {
    #[default]
    Sse,
    Http,
    /// Subprocess MCP (newline-delimited JSON-RPC on stdin/stdout).
    Stdio,
}