fix community PRs (inspired by #438 @pandego, #433 @ozekimasaki, #417 @f-liva, #392 @cryptonahue, #410 @hobostay, #413 @castorinop, #275 @woodcoal, #464 @citadelgrad, #419 @shipdocs, #480 @skeltavik, #439 @modship)

This commit is contained in:
jaberjaber23
2026-03-11 03:25:16 +03:00
parent 24f5717ae9
commit 98f8d1ca79
20 changed files with 551 additions and 78 deletions
Generated
+14 -14
View File
@@ -3792,7 +3792,7 @@ dependencies = [
[[package]]
name = "openfang-api"
version = "0.3.44"
version = "0.3.46"
dependencies = [
"async-trait",
"axum",
@@ -3829,7 +3829,7 @@ dependencies = [
[[package]]
name = "openfang-channels"
version = "0.3.44"
version = "0.3.46"
dependencies = [
"async-trait",
"axum",
@@ -3861,7 +3861,7 @@ dependencies = [
[[package]]
name = "openfang-cli"
version = "0.3.44"
version = "0.3.46"
dependencies = [
"clap",
"clap_complete",
@@ -3888,7 +3888,7 @@ dependencies = [
[[package]]
name = "openfang-desktop"
version = "0.3.44"
version = "0.3.46"
dependencies = [
"axum",
"open",
@@ -3914,7 +3914,7 @@ dependencies = [
[[package]]
name = "openfang-extensions"
version = "0.3.44"
version = "0.3.46"
dependencies = [
"aes-gcm",
"argon2",
@@ -3942,7 +3942,7 @@ dependencies = [
[[package]]
name = "openfang-hands"
version = "0.3.44"
version = "0.3.46"
dependencies = [
"chrono",
"dashmap",
@@ -3959,7 +3959,7 @@ dependencies = [
[[package]]
name = "openfang-kernel"
version = "0.3.44"
version = "0.3.46"
dependencies = [
"async-trait",
"chrono",
@@ -3996,7 +3996,7 @@ dependencies = [
[[package]]
name = "openfang-memory"
version = "0.3.44"
version = "0.3.46"
dependencies = [
"async-trait",
"chrono",
@@ -4015,7 +4015,7 @@ dependencies = [
[[package]]
name = "openfang-migrate"
version = "0.3.44"
version = "0.3.46"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -4034,7 +4034,7 @@ dependencies = [
[[package]]
name = "openfang-runtime"
version = "0.3.44"
version = "0.3.46"
dependencies = [
"anyhow",
"async-trait",
@@ -4068,7 +4068,7 @@ dependencies = [
[[package]]
name = "openfang-skills"
version = "0.3.44"
version = "0.3.46"
dependencies = [
"chrono",
"hex",
@@ -4091,7 +4091,7 @@ dependencies = [
[[package]]
name = "openfang-types"
version = "0.3.44"
version = "0.3.46"
dependencies = [
"async-trait",
"chrono",
@@ -4110,7 +4110,7 @@ dependencies = [
[[package]]
name = "openfang-wire"
version = "0.3.44"
version = "0.3.46"
dependencies = [
"async-trait",
"chrono",
@@ -8773,7 +8773,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xtask"
version = "0.3.44"
version = "0.3.46"
[[package]]
name = "yoke"
+1 -1
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.3.45"
version = "0.3.46"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
+20 -9
View File
@@ -56,6 +56,8 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use tracing::{error, info, warn};
use openfang_runtime::str_utils::safe_truncate_str;
/// Wraps `OpenFangKernel` to implement `ChannelBridgeHandle`.
pub struct KernelBridgeAdapter {
kernel: Arc<OpenFangKernel>,
@@ -378,7 +380,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.map(|e| e.name.clone())
.unwrap_or_else(|| t.agent_id.to_string());
let status = if t.enabled { "on" } else { "off" };
let id_short = &t.id.0.to_string()[..8];
let id_str = t.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
msg.push_str(&format!(
" [{}] {} -> {} ({:?}) fires:{} [{}]\n",
id_short,
@@ -417,7 +420,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.kernel
.triggers
.register(agent.id, pattern, prompt.to_string(), 0);
let id_short = &trigger_id.0.to_string()[..8];
let id_str = trigger_id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Trigger created [{id_short}] for agent '{agent_name}'.")
}
@@ -432,7 +436,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
1 => {
let t = matched[0];
if self.kernel.triggers.remove(t.id) {
format!("Trigger [{}] removed.", &t.id.0.to_string()[..8])
let id_str = t.id.0.to_string();
format!("Trigger [{}] removed.", safe_truncate_str(&id_str, 8))
} else {
"Failed to remove trigger.".to_string()
}
@@ -455,7 +460,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.map(|e| e.name.clone())
.unwrap_or_else(|| job.agent_id.to_string());
let status = if job.enabled { "on" } else { "off" };
let id_short = &job.id.0.to_string()[..8];
let id_str = job.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
let sched = match &job.schedule {
openfang_types::scheduler::CronSchedule::Cron { expr, .. } => expr.clone(),
openfang_types::scheduler::CronSchedule::Every { every_secs } => {
@@ -515,7 +521,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
match self.kernel.cron_scheduler.add_job(job, false) {
Ok(id) => {
let id_short = &id.0.to_string()[..8];
let id_str = id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Job [{id_short}] created: '{cron_expr}' -> {agent_name}: \"{message}\"")
}
Err(e) => format!("Failed to create job: {e}"),
@@ -537,7 +544,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
let j = matched[0];
match self.kernel.cron_scheduler.remove_job(j.id) {
Ok(_) => {
format!("Job [{}] '{}' removed.", &j.id.0.to_string()[..8], j.name)
let id_str = j.id.0.to_string();
format!("Job [{}] '{}' removed.", safe_truncate_str(&id_str, 8), j.name)
}
Err(e) => format!("Failed to remove job: {e}"),
}
@@ -569,7 +577,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
};
match self.kernel.send_message(j.agent_id, &message).await {
Ok(result) => {
let id_short = &j.id.0.to_string()[..8];
let id_str = j.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Job [{id_short}] ran:\n{}", result.response)
}
Err(e) => format!("Failed to run job: {e}"),
@@ -589,7 +598,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
}
let mut msg = format!("Pending approvals ({}):\n", pending.len());
for req in &pending {
let id_short = &req.id.to_string()[..8];
let id_str = req.id.to_string();
let id_short = safe_truncate_str(&id_str, 8);
let age_secs = (chrono::Utc::now() - req.requested_at).num_seconds();
let age = if age_secs >= 60 {
format!("{}m", age_secs / 60)
@@ -630,10 +640,11 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
) {
Ok(_) => {
let verb = if approve { "Approved" } else { "Rejected" };
let id_str = req.id.to_string();
format!(
"{} [{}] {}{}",
verb,
&req.id.to_string()[..8],
safe_truncate_str(&id_str, 8),
req.tool_name,
req.agent_id
)
+6 -6
View File
@@ -736,7 +736,7 @@
<span class="text-xs" style="color:var(--danger)" x-text="formatRecordingTime()"></span>
</div>
<textarea id="msg-input" rows="1" :placeholder="recording ? 'Recording... release to send' : 'Message OpenFang... (/ for commands)'"
@keydown.enter.prevent="if(!$event.isComposing && !$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
@keydown.enter.prevent="if(!$event.isComposing && $event.keyCode !== 229 && !$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
@keydown.escape="showSlashMenu = false; showModelPicker = false"
@keydown.arrow-up.prevent="if(showModelPicker){modelPickerIdx = Math.max(0, modelPickerIdx - 1)}else if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
@keydown.arrow-down.prevent="if(showModelPicker){modelPickerIdx = Math.min(filteredModelPicker.length - 1, modelPickerIdx + 1)}else if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
@@ -769,7 +769,7 @@
<div class="model-switcher-dropdown" x-show="showModelSwitcher" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform translate-y-1" x-transition:enter-end="opacity-100 transform translate-y-0" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
<div class="model-switcher-search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:0.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="model-switcher-search" type="text" x-model="modelSwitcherFilter" placeholder="Search models..." @keydown.escape.stop="showModelSwitcher = false" @keydown.arrow-down.prevent="modelSwitcherIdx = Math.min(modelSwitcherIdx + 1, filteredSwitcherModels.length - 1)" @keydown.arrow-up.prevent="modelSwitcherIdx = Math.max(modelSwitcherIdx - 1, 0)" @keydown.enter.prevent="filteredSwitcherModels[modelSwitcherIdx] && switchModel(filteredSwitcherModels[modelSwitcherIdx])">
<input id="model-switcher-search" type="text" x-model="modelSwitcherFilter" placeholder="Search models..." @keydown.escape.stop="showModelSwitcher = false" @keydown.arrow-down.prevent="modelSwitcherIdx = Math.min(modelSwitcherIdx + 1, filteredSwitcherModels.length - 1)" @keydown.arrow-up.prevent="modelSwitcherIdx = Math.max(modelSwitcherIdx - 1, 0)" @keydown.enter.prevent="!$event.isComposing && $event.keyCode !== 229 && filteredSwitcherModels[modelSwitcherIdx] && switchModel(filteredSwitcherModels[modelSwitcherIdx])">
<select x-model="modelSwitcherProviderFilter" style="background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text-dim);font-size:11px;padding:2px 6px;cursor:pointer;font-family:var(--font-mono);flex-shrink:0">
<option value="">All</option>
<template x-for="pn in switcherProviders" :key="pn">
@@ -1091,7 +1091,7 @@
<div x-show="spawnStep === 1">
<div class="form-group">
<label>Agent Name</label>
<input class="form-input" x-model="spawnForm.name" placeholder="my-agent" @keydown.enter="nextStep()">
<input class="form-input" x-model="spawnForm.name" placeholder="my-agent" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) nextStep()">
</div>
<div class="form-group">
<label>Emoji</label>
@@ -2172,7 +2172,7 @@
<!-- Search bar with live search and clear button -->
<div class="search-input mb-4" style="position:relative">
<span style="color:var(--text-muted)"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg></span>
<input placeholder="Search ClawHub skills... (type to search)" x-model="clawhubSearch" @input="onSearchInput()" @keydown.enter="searchClawHub()" @keydown.escape="clearSearch()" x-ref="clawhubSearchInput">
<input placeholder="Search ClawHub skills... (type to search)" x-model="clawhubSearch" @input="onSearchInput()" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) searchClawHub()" @keydown.escape="clearSearch()" x-ref="clawhubSearchInput">
<button x-show="clawhubSearch" @click="clearSearch()" class="search-clear-btn" title="Clear search (Esc)">&times;</button>
</div>
@@ -4576,7 +4576,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<div class="card" style="border-left:3px solid var(--accent)">
<div class="form-group" style="margin-bottom:8px">
<label>Agent Name</label>
<input class="form-input" type="text" x-model="agentName" placeholder="my-assistant" style="max-width:320px" @keydown.enter="createAgent()">
<input class="form-input" type="text" x-model="agentName" placeholder="my-assistant" style="max-width:320px" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) createAgent()">
</div>
<div class="text-xs text-dim" x-text="'Will use ' + templates[selectedTemplate].provider + ' / ' + templates[selectedTemplate].model + ' with ' + profileInfo(templates[selectedTemplate].profile).label + ' profile'"></div>
<div class="mt-2">
@@ -4622,7 +4622,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- Input -->
<div style="display:flex;gap:8px;margin-top:12px">
<input class="form-input" type="text" x-model="tryItInput" placeholder="Type a message..."
@keydown.enter="sendTryItMessage(tryItInput)" :disabled="tryItSending" style="flex:1">
@keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) sendTryItMessage(tryItInput)" :disabled="tryItSending" style="flex:1">
<button class="btn btn-primary btn-sm" @click="sendTryItMessage(tryItInput)" :disabled="tryItSending || !tryItInput.trim()">Send</button>
</div>
</div>
+20 -5
View File
@@ -1,6 +1,21 @@
// OpenFang Agents Page — Multi-step spawn wizard, detail view with tabs, file editor, personality presets
'use strict';
/** Escape a string for use inside TOML triple-quoted strings ("""\n...\n""").
* Backslashes are escaped, and runs of 3+ consecutive double-quotes are
* broken up so the TOML parser never sees an unintended closing delimiter.
*/
function tomlMultilineEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
}
/** Escape a string for use inside a TOML basic (single-line) string ("...").
* Backslashes, double-quotes, and common control chars are escaped.
*/
function tomlBasicEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
}
function agentsPage() {
return {
tab: 'agents',
@@ -411,7 +426,7 @@ function agentsPage() {
var f = this.spawnForm;
var si = this.spawnIdentity;
var lines = [
'name = "' + f.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"',
'name = "' + tomlBasicEscape(f.name) + '"',
'module = "builtin:chat"'
];
if (f.profile && f.profile !== 'custom') {
@@ -420,7 +435,7 @@ function agentsPage() {
lines.push('', '[model]');
lines.push('provider = "' + f.provider + '"');
lines.push('model = "' + f.model + '"');
lines.push('system_prompt = """\n' + f.systemPrompt.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"') + '\n"""');
lines.push('system_prompt = """\n' + tomlMultilineEscape(f.systemPrompt) + '\n"""');
if (f.profile === 'custom') {
lines.push('', '[capabilities]');
if (f.caps.memory_read) lines.push('memory_read = ["*"]');
@@ -698,12 +713,12 @@ function agentsPage() {
},
async spawnBuiltin(t) {
var toml = 'name = "' + t.name + '"\n';
toml += 'description = "' + t.description.replace(/"/g, '\\"') + '"\n';
var toml = 'name = "' + tomlBasicEscape(t.name) + '"\n';
toml += 'description = "' + tomlBasicEscape(t.description) + '"\n';
toml += 'module = "builtin:chat"\n';
toml += 'profile = "' + t.profile + '"\n\n';
toml += '[model]\nprovider = "' + t.provider + '"\nmodel = "' + t.model + '"\n';
toml += 'system_prompt = """\n' + t.system_prompt + '\n"""\n';
toml += 'system_prompt = """\n' + tomlMultilineEscape(t.system_prompt) + '\n"""\n';
try {
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: toml });
+13 -3
View File
@@ -1,6 +1,16 @@
// OpenFang Setup Wizard — First-run guided setup (Provider + Agent + Channel)
'use strict';
/** Escape a string for use inside TOML triple-quoted strings ("""\n...\n"""). */
function wizardTomlMultilineEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
}
/** Escape a string for use inside a TOML basic (single-line) string ("..."). */
function wizardTomlBasicEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
}
function wizardPage() {
return {
step: 1,
@@ -462,12 +472,12 @@ function wizardPage() {
}
var toml = '[agent]\n';
toml += 'name = "' + name.replace(/"/g, '\\"') + '"\n';
toml += 'description = "' + tpl.description.replace(/"/g, '\\"') + '"\n';
toml += 'name = "' + wizardTomlBasicEscape(name) + '"\n';
toml += 'description = "' + wizardTomlBasicEscape(tpl.description) + '"\n';
toml += 'profile = "' + tpl.profile + '"\n\n';
toml += '[model]\nprovider = "' + provider + '"\n';
toml += 'model = "' + model + '"\n';
toml += 'system_prompt = """\n' + tpl.system_prompt + '\n"""\n';
toml += 'system_prompt = """\n' + wizardTomlMultilineEscape(tpl.system_prompt) + '\n"""\n';
this.creatingAgent = true;
try {
+26 -7
View File
@@ -6,6 +6,7 @@
use openfang_api::server::build_router;
use openfang_kernel::OpenFangKernel;
use std::net::{SocketAddr, TcpListener};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::watch;
use tracing::{error, info};
@@ -20,24 +21,40 @@ pub struct ServerHandle {
shutdown_tx: watch::Sender<bool>,
/// Join handle for the background server thread.
server_thread: Option<std::thread::JoinHandle<()>>,
/// Track whether shutdown has already been initiated to prevent double shutdown.
shutdown_initiated: Arc<AtomicBool>,
}
impl ServerHandle {
/// Signal the server to shut down and wait for the background thread.
pub fn shutdown(mut self) {
let _ = self.shutdown_tx.send(true);
if let Some(handle) = self.server_thread.take() {
let _ = handle.join();
// Only proceed if shutdown hasn't been initiated yet
if self
.shutdown_initiated
.compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
.is_ok()
{
let _ = self.shutdown_tx.send(true);
if let Some(handle) = self.server_thread.take() {
let _ = handle.join();
}
self.kernel.shutdown();
info!("OpenFang embedded server stopped");
}
self.kernel.shutdown();
info!("OpenFang embedded server stopped");
}
}
impl Drop for ServerHandle {
fn drop(&mut self) {
let _ = self.shutdown_tx.send(true);
// Best-effort: don't block in drop, the thread will exit on its own.
// Only send shutdown signal if it hasn't been initiated yet
if self
.shutdown_initiated
.compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
.is_ok()
{
let _ = self.shutdown_tx.send(true);
// Best-effort: don't block in drop, the thread will exit on its own.
}
}
}
@@ -61,6 +78,7 @@ pub fn start_server() -> Result<ServerHandle, Box<dyn std::error::Error>> {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let kernel_clone = kernel.clone();
let shutdown_initiated = Arc::new(AtomicBool::new(false));
let server_thread = std::thread::Builder::new()
.name("openfang-server".into())
@@ -83,6 +101,7 @@ pub fn start_server() -> Result<ServerHandle, Box<dyn std::error::Error>> {
kernel,
shutdown_tx,
server_thread: Some(server_thread),
shutdown_initiated,
})
}
+30 -7
View File
@@ -806,12 +806,20 @@ impl OpenFangKernel {
use openfang_runtime::embedding::create_embedding_driver;
let configured_model = &config.memory.embedding_model;
if let Some(ref provider) = config.memory.embedding_provider {
// Explicit config takes priority — use the configured embedding model
// Explicit config takes priority — use the configured embedding model.
// If the user left embedding_model at the default ("all-MiniLM-L6-v2"),
// pick a sensible default for the chosen provider so we don't send a
// local model name to a cloud API.
let model = if configured_model == "all-MiniLM-L6-v2" {
default_embedding_model_for_provider(provider)
} else {
configured_model.as_str()
};
let api_key_env = config.memory.embedding_api_key_env.as_deref().unwrap_or("");
let custom_url = config.provider_urls.get(provider.as_str()).map(|s| s.as_str());
match create_embedding_driver(provider, configured_model, api_key_env, custom_url) {
match create_embedding_driver(provider, model, api_key_env, custom_url) {
Ok(d) => {
info!(provider = %provider, model = %configured_model, "Embedding driver configured from memory config");
info!(provider = %provider, model = %model, "Embedding driver configured from memory config");
Some(Arc::from(d))
}
Err(e) => {
@@ -821,14 +829,14 @@ impl OpenFangKernel {
}
} else if std::env::var("OPENAI_API_KEY").is_ok() {
let model = if configured_model == "all-MiniLM-L6-v2" {
"text-embedding-3-small"
default_embedding_model_for_provider("openai")
} else {
configured_model.as_str()
};
let openai_url = config.provider_urls.get("openai").map(|s| s.as_str());
match create_embedding_driver("openai", model, "OPENAI_API_KEY", openai_url) {
Ok(d) => {
info!("Embedding driver auto-detected: OpenAI");
info!(model = %model, "Embedding driver auto-detected: OpenAI");
Some(Arc::from(d))
}
Err(e) => {
@@ -839,14 +847,14 @@ impl OpenFangKernel {
} else {
// Try Ollama (local, no key needed)
let model = if configured_model == "all-MiniLM-L6-v2" {
"nomic-embed-text"
default_embedding_model_for_provider("ollama")
} else {
configured_model.as_str()
};
let ollama_url = config.provider_urls.get("ollama").map(|s| s.as_str());
match create_embedding_driver("ollama", model, "", ollama_url) {
Ok(d) => {
info!("Embedding driver auto-detected: Ollama (local)");
info!(model = %model, "Embedding driver auto-detected: Ollama (local)");
Some(Arc::from(d))
}
Err(e) => {
@@ -4919,6 +4927,21 @@ fn apply_budget_defaults(
}
}
/// Pick a sensible default embedding model for a given provider when the user
/// configured an explicit `embedding_provider` but left `embedding_model` at the
/// default value (which is a local model name that cloud APIs wouldn't recognise).
fn default_embedding_model_for_provider(provider: &str) -> &'static str {
match provider {
"openai" => "text-embedding-3-small",
"mistral" => "mistral-embed",
"cohere" => "embed-english-v3.0",
// Local providers use nomic-embed-text as a good default
"ollama" | "vllm" | "lmstudio" => "nomic-embed-text",
// Other OpenAI-compatible APIs typically support the OpenAI model names
_ => "text-embedding-3-small",
}
}
/// Infer provider from a model name when catalog lookup fails.
///
/// Uses well-known model name prefixes to map to the correct provider.
+99 -2
View File
@@ -917,7 +917,11 @@ async fn call_with_retry(
Err(e) => {
// Use classifier for smarter error handling
let raw_error = e.to_string();
let classified = llm_errors::classify_error(&raw_error, None);
let status = match &e {
LlmError::Api { status, .. } => Some(*status),
_ => None,
};
let classified = llm_errors::classify_error(&raw_error, status);
warn!(
category = ?classified.category,
retryable = classified.is_retryable,
@@ -1027,7 +1031,11 @@ async fn stream_with_retry(
}
Err(e) => {
let raw_error = e.to_string();
let classified = llm_errors::classify_error(&raw_error, None);
let status = match &e {
LlmError::Api { status, .. } => Some(*status),
_ => None,
};
let classified = llm_errors::classify_error(&raw_error, status);
warn!(
category = ?classified.category,
retryable = classified.is_retryable,
@@ -1813,6 +1821,7 @@ pub async fn run_agent_loop_streaming(
/// 6. `[TOOL_CALL]...[/TOOL_CALL]` blocks (JSON or arrow syntax) — issue #354
/// 7. `<tool_call>{"name":"tool","arguments":{...}}</tool_call>` — Qwen3, issue #332
/// 8. Bare JSON `{"name":"tool","arguments":{...}}` objects (last resort, only if no tags found)
/// 9. `<function name="tool" parameters="{...}" />` — XML attribute style (Groq/Llama)
///
/// Validates tool names against available tools and returns synthetic `ToolCall` entries.
fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Vec<ToolCall> {
@@ -2148,6 +2157,53 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve
}
}
// Pattern 9: <function name="tool" parameters="{...}" /> — XML attribute style
// Groq/Llama sometimes emit self-closing XML with name/parameters attributes.
// The parameters value is HTML-entity-escaped JSON (&quot; etc.).
{
use regex_lite::Regex;
// Match both self-closing <function ... /> and <function ...></function>
let re = Regex::new(
r#"<function\s+name="([^"]+)"\s+parameters="([^"]*)"[^/]*/?>"#
).unwrap();
for caps in re.captures_iter(text) {
let tool_name = caps.get(1).unwrap().as_str();
let raw_params = caps.get(2).unwrap().as_str();
if !tool_names.contains(&tool_name) {
warn!(tool = tool_name, "XML-attribute tool call for unknown tool — skipping");
continue;
}
// Unescape HTML entities (&quot; &amp; &lt; &gt; &apos;)
let unescaped = raw_params
.replace("&quot;", "\"")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&apos;", "'");
let input: serde_json::Value = match serde_json::from_str(&unescaped) {
Ok(v) => v,
Err(e) => {
warn!(tool = tool_name, error = %e, "Failed to parse XML-attribute tool call params — skipping");
continue;
}
};
if calls.iter().any(|c| c.name == tool_name && c.input == input) {
continue;
}
info!(tool = tool_name, "Recovered XML-attribute tool call → synthetic ToolUse");
calls.push(ToolCall {
id: format!("recovered_{}", uuid::Uuid::new_v4()),
name: tool_name.to_string(),
input,
});
}
}
// Pattern 8: Bare JSON tool call objects in text (common Ollama fallback)
// Matches: {"name":"tool_name","arguments":{"key":"value"}} not already inside tags
// Only try this if no calls were found by tag-based patterns, to avoid false positives.
@@ -3571,6 +3627,47 @@ mod tests {
assert_eq!(calls[0].input["command"], "ls");
}
// --- Pattern 9: XML-attribute style <function name="..." parameters="..." /> ---
#[test]
fn test_recover_xml_attribute_basic() {
let tools = vec![ToolDefinition {
name: "web_search".into(),
description: "Search".into(),
input_schema: serde_json::json!({}),
}];
let text = r#"<function name="web_search" parameters="{&quot;query&quot;: &quot;best crypto 2024&quot;}" />"#;
let calls = recover_text_tool_calls(text, &tools);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "web_search");
assert_eq!(calls[0].input["query"], "best crypto 2024");
}
#[test]
fn test_recover_xml_attribute_unknown_tool() {
let tools = vec![ToolDefinition {
name: "web_search".into(),
description: "Search".into(),
input_schema: serde_json::json!({}),
}];
let text = r#"<function name="unknown_tool" parameters="{&quot;x&quot;: 1}" />"#;
let calls = recover_text_tool_calls(text, &tools);
assert!(calls.is_empty());
}
#[test]
fn test_recover_xml_attribute_non_selfclosing() {
let tools = vec![ToolDefinition {
name: "shell_exec".into(),
description: "Execute".into(),
input_schema: serde_json::json!({}),
}];
let text = r#"<function name="shell_exec" parameters="{&quot;command&quot;: &quot;ls&quot;}"></function>"#;
let calls = recover_text_tool_calls(text, &tools);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "shell_exec");
}
// --- Helper function tests ---
#[test]
@@ -27,7 +27,10 @@ impl AnthropicDriver {
Self {
api_key: Zeroizing::new(api_key),
base_url,
client: reqwest::Client::new(),
client: reqwest::Client::builder()
.user_agent(crate::USER_AGENT)
.build()
.unwrap_or_default(),
}
}
}
@@ -32,7 +32,10 @@ impl GeminiDriver {
Self {
api_key: Zeroizing::new(api_key),
base_url,
client: reqwest::Client::new(),
client: reqwest::Client::builder()
.user_agent(crate::USER_AGENT)
.build()
.unwrap_or_default(),
}
}
}
+42 -8
View File
@@ -15,11 +15,12 @@ use crate::llm_driver::{DriverConfig, LlmDriver, LlmError};
use openfang_types::model_catalog::{
AI21_BASE_URL, ANTHROPIC_BASE_URL, CEREBRAS_BASE_URL, CHUTES_BASE_URL, COHERE_BASE_URL,
DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL,
LEMONADE_BASE_URL, LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL,
OLLAMA_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL,
QIANFAN_BASE_URL, QWEN_BASE_URL, REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL,
VENICE_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL,
XAI_BASE_URL, ZAI_BASE_URL, ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL, MINIMAX_BASE_URL,
MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL,
OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL,
VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
};
use std::sync::Arc;
@@ -149,11 +150,16 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
api_key_env: "",
key_required: false,
}),
"moonshot" | "kimi" => Some(ProviderDefaults {
"moonshot" | "kimi" | "kimi2" => Some(ProviderDefaults {
base_url: MOONSHOT_BASE_URL,
api_key_env: "MOONSHOT_API_KEY",
key_required: true,
}),
"kimi_coding" => Some(ProviderDefaults {
base_url: KIMI_CODING_BASE_URL,
api_key_env: "KIMI_API_KEY",
key_required: true,
}),
"qwen" | "dashscope" | "model_studio" => Some(ProviderDefaults {
base_url: QWEN_BASE_URL,
api_key_env: "DASHSCOPE_API_KEY",
@@ -174,7 +180,7 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
api_key_env: "ZHIPU_API_KEY",
key_required: true,
}),
"zai" => Some(ProviderDefaults {
"zai" | "z.ai" => Some(ProviderDefaults {
base_url: ZAI_BASE_URL,
api_key_env: "ZHIPU_API_KEY",
key_required: true,
@@ -323,6 +329,22 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
)));
}
// Kimi for Code — Anthropic-compatible endpoint
if provider == "kimi_coding" {
let api_key = config
.api_key
.clone()
.or_else(|| std::env::var("KIMI_API_KEY").ok())
.ok_or_else(|| {
LlmError::MissingApiKey("Set KIMI_API_KEY environment variable".to_string())
})?;
let base_url = config
.base_url
.clone()
.unwrap_or_else(|| KIMI_CODING_BASE_URL.to_string());
return Ok(Arc::new(anthropic::AnthropicDriver::new(api_key, base_url)));
}
// All other providers use OpenAI-compatible format
if let Some(defaults) = provider_defaults(provider) {
let api_key = config
@@ -453,6 +475,8 @@ pub fn known_providers() -> &'static [&'static str] {
"minimax",
"zhipu",
"zhipu_coding",
"zai",
"kimi_coding",
"qianfan",
"volcengine",
"chutes",
@@ -551,12 +575,14 @@ mod tests {
assert!(providers.contains(&"minimax"));
assert!(providers.contains(&"zhipu"));
assert!(providers.contains(&"zhipu_coding"));
assert!(providers.contains(&"zai"));
assert!(providers.contains(&"kimi_coding"));
assert!(providers.contains(&"qianfan"));
assert!(providers.contains(&"volcengine"));
assert!(providers.contains(&"chutes"));
assert!(providers.contains(&"codex"));
assert!(providers.contains(&"claude-code"));
assert_eq!(providers.len(), 32);
assert_eq!(providers.len(), 34);
}
#[test]
@@ -642,6 +668,14 @@ mod tests {
std::env::remove_var("NVIDIA_API_KEY");
}
#[test]
fn test_provider_defaults_kimi_coding() {
let d = provider_defaults("kimi_coding").unwrap();
assert_eq!(d.base_url, "https://api.kimi.com/coding");
assert_eq!(d.api_key_env, "KIMI_API_KEY");
assert!(d.key_required);
}
#[test]
fn test_custom_provider_explicit_key_with_url() {
// When api_key is explicitly passed, it should be used regardless of env var.
+71 -3
View File
@@ -26,11 +26,19 @@ impl OpenAIDriver {
Self {
api_key: Zeroizing::new(api_key),
base_url,
client: reqwest::Client::new(),
client: reqwest::Client::builder()
.user_agent(crate::USER_AGENT)
.build()
.unwrap_or_default(),
extra_headers: Vec::new(),
}
}
/// True if this provider is Moonshot/Kimi and requires reasoning_content on assistant messages with tool_calls.
fn kimi_needs_reasoning_content(&self, model: &str) -> bool {
self.base_url.contains("moonshot") || model.to_lowercase().contains("kimi")
}
/// Create a driver with additional HTTP headers (e.g. for Copilot IDE auth).
pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
self.extra_headers = headers;
@@ -59,6 +67,9 @@ struct OaiRequest {
/// Request usage stats in streaming responses (OpenAI extension, supported by Groq et al).
#[serde(skip_serializing_if = "Option::is_none")]
stream_options: Option<serde_json::Value>,
/// Moonshot Kimi K2.5: disable thinking so multi-turn with tool_calls works without preserving reasoning_content.
#[serde(skip_serializing_if = "Option::is_none")]
thinking: Option<serde_json::Value>,
}
/// Returns true if a model uses `max_completion_tokens` instead of `max_tokens`.
@@ -89,6 +100,12 @@ fn rejects_temperature(model: &str) -> bool {
|| m.contains("-reasoning")
}
/// Returns true if a model only accepts temperature = 1 (e.g. Moonshot Kimi K2/K2.5).
fn temperature_must_be_one(model: &str) -> bool {
let m = model.to_lowercase();
m.starts_with("kimi-k2") || m == "kimi-k2.5" || m == "kimi-k2.5-0711"
}
#[derive(Debug, Serialize)]
struct OaiMessage {
role: String,
@@ -98,6 +115,9 @@ struct OaiMessage {
tool_calls: Option<Vec<OaiToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
/// Moonshot Kimi: sent as empty string on assistant messages with tool_calls when using Kimi (thinking is disabled for multi-turn compatibility).
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_content: Option<String>,
}
/// Content can be a plain string or an array of content parts (for images).
@@ -190,6 +210,7 @@ impl LlmDriver for OpenAIDriver {
content: Some(OaiMessageContent::Text(system.clone())),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
}
@@ -203,6 +224,7 @@ impl LlmDriver for OpenAIDriver {
content: Some(OaiMessageContent::Text(text.clone())),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
}
}
@@ -212,6 +234,7 @@ impl LlmDriver for OpenAIDriver {
content: Some(OaiMessageContent::Text(text.clone())),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
}
(Role::Assistant, MessageContent::Text(text)) => {
@@ -220,6 +243,7 @@ impl LlmDriver for OpenAIDriver {
content: Some(OaiMessageContent::Text(text.clone())),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
}
(Role::User, MessageContent::Blocks(blocks)) => {
@@ -241,6 +265,7 @@ impl LlmDriver for OpenAIDriver {
)),
tool_calls: None,
tool_call_id: Some(tool_use_id.clone()),
reasoning_content: None,
});
}
ContentBlock::Text { text } => {
@@ -263,6 +288,7 @@ impl LlmDriver for OpenAIDriver {
content: Some(OaiMessageContent::Parts(parts)),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
}
}
@@ -308,6 +334,11 @@ impl LlmDriver for OpenAIDriver {
Some(tool_calls)
},
tool_call_id: None,
reasoning_content: if has_tool_calls && self.kimi_needs_reasoning_content(&request.model) {
Some(String::new())
} else {
None
},
});
}
_ => {}
@@ -346,11 +377,25 @@ impl LlmDriver for OpenAIDriver {
messages: oai_messages,
max_tokens: mt,
max_completion_tokens: mct,
temperature: if rejects_temperature(&request.model) { None } else { Some(request.temperature) },
temperature: if self.kimi_needs_reasoning_content(&request.model) {
// Kimi with thinking disabled uses fixed 0.6 for multi-turn compatibility.
Some(0.6)
} else if temperature_must_be_one(&request.model) {
Some(1.0)
} else if rejects_temperature(&request.model) {
None
} else {
Some(request.temperature)
},
tools: oai_tools,
tool_choice,
stream: false,
stream_options: None,
thinking: if self.kimi_needs_reasoning_content(&request.model) {
Some(serde_json::json!({"type": "disabled"}))
} else {
None
},
};
let max_retries = 3;
@@ -615,6 +660,7 @@ impl LlmDriver for OpenAIDriver {
content: Some(OaiMessageContent::Text(system.clone())),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
}
@@ -627,6 +673,7 @@ impl LlmDriver for OpenAIDriver {
content: Some(OaiMessageContent::Text(text.clone())),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
}
}
@@ -636,6 +683,7 @@ impl LlmDriver for OpenAIDriver {
content: Some(OaiMessageContent::Text(text.clone())),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
}
(Role::Assistant, MessageContent::Text(text)) => {
@@ -644,6 +692,7 @@ impl LlmDriver for OpenAIDriver {
content: Some(OaiMessageContent::Text(text.clone())),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
}
(Role::User, MessageContent::Blocks(blocks)) => {
@@ -661,6 +710,7 @@ impl LlmDriver for OpenAIDriver {
)),
tool_calls: None,
tool_call_id: Some(tool_use_id.clone()),
reasoning_content: None,
});
}
}
@@ -703,6 +753,11 @@ impl LlmDriver for OpenAIDriver {
Some(tool_calls_out)
},
tool_call_id: None,
reasoning_content: if has_tool_calls && self.kimi_needs_reasoning_content(&request.model) {
Some(String::new())
} else {
None
},
});
}
_ => {}
@@ -741,11 +796,24 @@ impl LlmDriver for OpenAIDriver {
messages: oai_messages,
max_tokens: mt,
max_completion_tokens: mct,
temperature: if rejects_temperature(&request.model) { None } else { Some(request.temperature) },
temperature: if self.kimi_needs_reasoning_content(&request.model) {
Some(0.6)
} else if temperature_must_be_one(&request.model) {
Some(1.0)
} else if rejects_temperature(&request.model) {
None
} else {
Some(request.temperature)
},
tools: oai_tools,
tool_choice,
stream: true,
stream_options: Some(serde_json::json!({"include_usage": true})),
thinking: if self.kimi_needs_reasoning_content(&request.model) {
Some(serde_json::json!({"type": "disabled"}))
} else {
None
},
};
// Retry loop for the initial HTTP request
+4
View File
@@ -3,6 +3,10 @@
//! Manages the agent execution loop, LLM driver abstraction,
//! tool execution, and WASM sandboxing for untrusted skill/plugin code.
/// Default User-Agent header sent with all outgoing HTTP requests.
/// Some LLM providers (e.g. Moonshot, Qwen) reject requests without one.
pub const USER_AGENT: &str = "openfang/0.3.45";
pub mod a2a;
pub mod agent_loop;
pub mod apply_patch;
@@ -281,6 +281,7 @@ pub fn classify_error(message: &str, status: Option<u16>) -> ClassifiedError {
return build(LlmErrorCategory::Auth);
}
}
404 => return build(LlmErrorCategory::ModelNotFound),
_ => {}
}
}
+62 -5
View File
@@ -7,10 +7,10 @@ use openfang_types::model_catalog::{
AuthStatus, ModelCatalogEntry, ModelTier, ProviderInfo, AI21_BASE_URL, ANTHROPIC_BASE_URL,
BEDROCK_BASE_URL, CEREBRAS_BASE_URL, CHUTES_BASE_URL, COHERE_BASE_URL, DEEPSEEK_BASE_URL,
FIREWORKS_BASE_URL, GEMINI_BASE_URL, GITHUB_COPILOT_BASE_URL, GROQ_BASE_URL,
HUGGINGFACE_BASE_URL, LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL,
MOONSHOT_BASE_URL, OLLAMA_BASE_URL, LEMONADE_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL,
PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL, REPLICATE_BASE_URL,
SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL,
HUGGINGFACE_BASE_URL, KIMI_CODING_BASE_URL, LEMONADE_BASE_URL, LMSTUDIO_BASE_URL,
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL,
OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL,
VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
};
@@ -688,6 +688,15 @@ fn builtin_providers() -> Vec<ProviderInfo> {
auth_status: AuthStatus::Missing,
model_count: 0,
},
ProviderInfo {
id: "kimi_coding".into(),
display_name: "Kimi for Code".into(),
api_key_env: "KIMI_API_KEY".into(),
base_url: KIMI_CODING_BASE_URL.into(),
key_required: true,
auth_status: AuthStatus::Missing,
model_count: 0,
},
ProviderInfo {
id: "qianfan".into(),
display_name: "Baidu Qianfan".into(),
@@ -2991,6 +3000,37 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec!["codegeex".into()],
},
// ══════════════════════════════════════════════════════════════
// Z.AI Coding / GLM Coding Models (2)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "glm-5-coding".into(),
display_name: "GLM-5 Coding".into(),
provider: "zai_coding".into(),
tier: ModelTier::Frontier,
context_window: 131_072,
max_output_tokens: 16_384,
input_cost_per_m: 2.00,
output_cost_per_m: 8.00,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec!["glm-5-code".into(), "glm-coding".into()],
},
ModelCatalogEntry {
id: "glm-4.7-coding".into(),
display_name: "GLM-4.7 Coding".into(),
provider: "zai_coding".into(),
tier: ModelTier::Smart,
context_window: 131_072,
max_output_tokens: 16_384,
input_cost_per_m: 1.50,
output_cost_per_m: 5.00,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec!["glm-4.7-code".into()],
},
// ══════════════════════════════════════════════════════════════
// Moonshot / Kimi (5)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
@@ -3061,6 +3101,23 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["kimi-k2.5-0711".into()],
},
// ══════════════════════════════════════════════════════════════
// Kimi for Code (1)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "kimi-for-coding".into(),
display_name: "Kimi For Coding".into(),
provider: "kimi_coding".into(),
tier: ModelTier::Frontier,
context_window: 262_144,
max_output_tokens: 32_768,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
@@ -3492,7 +3549,7 @@ mod tests {
#[test]
fn test_catalog_has_providers() {
let catalog = ModelCatalog::new();
assert_eq!(catalog.list_providers().len(), 37);
assert_eq!(catalog.list_providers().len(), 38);
}
#[test]
+2 -1
View File
@@ -23,6 +23,7 @@ impl WebFetchEngine {
/// Create a new fetch engine from config with a shared cache.
pub fn new(config: WebFetchConfig, cache: Arc<WebCache>) -> Self {
let client = reqwest::Client::builder()
.user_agent(crate::USER_AGENT)
.timeout(std::time::Duration::from_secs(config.timeout_secs))
.gzip(true)
.deflate(true)
@@ -71,7 +72,7 @@ impl WebFetchEngine {
"DELETE" => self.client.delete(url),
_ => self.client.get(url),
};
req = req.header("User-Agent", "Mozilla/5.0 (compatible; OpenFangAgent/0.1)");
req = req.header("User-Agent", format!("Mozilla/5.0 (compatible; {})", crate::USER_AGENT));
// Add custom headers
if let Some(hdrs) = headers {
+128 -1
View File
@@ -265,7 +265,7 @@ impl Default for ResourceQuota {
max_memory_bytes: 256 * 1024 * 1024, // 256 MB
max_cpu_time_ms: 30_000, // 30 seconds
max_tool_calls_per_minute: 60,
max_llm_tokens_per_hour: 1_000_000,
max_llm_tokens_per_hour: 0, // unlimited by default
max_network_bytes_per_hour: 100 * 1024 * 1024, // 100 MB
max_cost_per_hour_usd: 0.0, // unlimited by default
max_cost_per_day_usd: 0.0, // unlimited
@@ -1169,4 +1169,131 @@ provider = "openai"
assert_eq!(cfg.model, "gpt-4o");
assert_eq!(cfg.provider, "openai");
}
// ----- Multi-line system_prompt TOML tests (wizard generateToml output) -----
#[test]
fn test_manifest_multiline_system_prompt_toml() {
// This is the exact TOML format the dashboard wizard generateToml() now produces
let toml_str = r#"
name = "brand-guardian"
module = "builtin:chat"
[model]
provider = "google"
model = "gemini-3-flash-preview"
system_prompt = """
You are Brand Guardian, an expert brand strategist.
Your Core Mission:
- Develop brand strategy including purpose, vision, mission, values
- Design complete visual identity systems
- Establish brand voice and messaging architecture
Critical Rules:
- Establish comprehensive brand foundation before tactical implementation
- Ensure all brand elements work as a cohesive system
"""
"#;
let manifest: AgentManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.name, "brand-guardian");
assert_eq!(manifest.model.provider, "google");
assert_eq!(manifest.model.model, "gemini-3-flash-preview");
assert!(manifest.model.system_prompt.contains("Brand Guardian"));
assert!(manifest.model.system_prompt.contains("Critical Rules:"));
// Verify newlines are preserved
assert!(manifest.model.system_prompt.contains('\n'));
}
#[test]
fn test_manifest_multiline_system_prompt_with_quotes() {
// System prompt containing double quotes (common in persona prompts)
let toml_str = r#"
name = "test-agent"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
system_prompt = """
You are a "helpful" assistant.
When users say "hello", respond warmly.
"""
"#;
let manifest: AgentManifest = toml::from_str(toml_str).unwrap();
assert!(manifest.model.system_prompt.contains("\"helpful\""));
assert!(manifest.model.system_prompt.contains("\"hello\""));
}
#[test]
fn test_manifest_multiline_system_prompt_with_code_blocks() {
// System prompt containing markdown-style code blocks
let toml_str = r#"
name = "coder"
[model]
provider = "deepseek"
model = "deepseek-chat"
system_prompt = """
You are a coding assistant.
Example output format:
```python
def hello():
print("world")
```
Always use proper indentation.
"""
"#;
let manifest: AgentManifest = toml::from_str(toml_str).unwrap();
assert!(manifest.model.system_prompt.contains("```python"));
assert!(manifest.model.system_prompt.contains("def hello()"));
}
#[test]
fn test_manifest_single_line_system_prompt_still_works() {
// Ensure the old single-line format still parses fine
let toml_str = r#"
name = "simple"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
system_prompt = "You are a helpful assistant."
"#;
let manifest: AgentManifest = toml::from_str(toml_str).unwrap();
assert_eq!(
manifest.model.system_prompt,
"You are a helpful assistant."
);
}
#[test]
fn test_manifest_wizard_custom_profile_with_capabilities() {
// Full wizard output when profile=custom with capabilities block
let toml_str = r#"
name = "brand-guardian"
module = "builtin:chat"
[model]
provider = "google"
model = "gemini-3-flash-preview"
system_prompt = """
You are Brand Guardian.
Protect brand consistency across all touchpoints.
"""
[capabilities]
memory_read = ["*"]
memory_write = ["self.*"]
"#;
let manifest: AgentManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.name, "brand-guardian");
assert!(manifest.model.system_prompt.contains("Brand Guardian"));
assert_eq!(manifest.capabilities.memory_read, vec!["*".to_string()]);
assert_eq!(
manifest.capabilities.memory_write,
vec!["self.*".to_string()]
);
}
}
+2 -3
View File
@@ -1108,9 +1108,8 @@ pub struct BudgetConfig {
pub max_monthly_usd: f64,
/// Alert threshold as a fraction (0.0 - 1.0). Trigger warnings at this % of any limit.
pub alert_threshold: f64,
/// Default per-agent hourly token limit override. When set (> 0), agents that
/// still have the built-in default (1,000,000) or a lower per-agent value will
/// be overridden to this value. Set to 0 to keep each agent's own limit.
/// Default per-agent hourly token limit override. When set (> 0), all agents
/// will be overridden to this value. Set to 0 to keep each agent's own limit.
/// Use this to globally raise or lower the token budget for all agents.
pub default_max_llm_tokens_per_hour: u64,
}
+2 -1
View File
@@ -42,7 +42,8 @@ pub const ZHIPU_CODING_BASE_URL: &str = "https://open.bigmodel.cn/api/coding/paa
/// Z.AI domain aliases (same API, different domain).
pub const ZAI_BASE_URL: &str = "https://api.z.ai/api/paas/v4";
pub const ZAI_CODING_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
pub const MOONSHOT_BASE_URL: &str = "https://api.moonshot.cn/v1";
pub const MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1";
pub const KIMI_CODING_BASE_URL: &str = "https://api.kimi.com/coding";
pub const QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com/v2";
pub const VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/v3";
pub const VOLCENGINE_CODING_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/coding/v3";