Compare commits

...
5 Commits
Author SHA1 Message Date
jaberjaber23 444d82e4d6 batch fixes 2026-03-02 23:49:44 +03:00
jaberjaber23 62e6e0f088 batch fixes 2026-03-02 23:12:19 +03:00
jaberjaber23 d3385f2cdc batch fixes 2026-03-02 20:52:57 +03:00
jaberjaber23 516f163dfb batch fixes 2026-03-02 18:37:56 +03:00
jaberjaber23 a54bb1cd4f batch fixes 2026-03-02 15:14:58 +03:00
28 changed files with 1081 additions and 248 deletions
Generated
+14 -14
View File
@@ -3866,7 +3866,7 @@ dependencies = [
[[package]]
name = "openfang-api"
version = "0.2.6"
version = "0.3.0"
dependencies = [
"async-trait",
"axum",
@@ -3902,7 +3902,7 @@ dependencies = [
[[package]]
name = "openfang-channels"
version = "0.2.6"
version = "0.3.0"
dependencies = [
"async-trait",
"axum",
@@ -3933,7 +3933,7 @@ dependencies = [
[[package]]
name = "openfang-cli"
version = "0.2.6"
version = "0.3.0"
dependencies = [
"clap",
"clap_complete",
@@ -3960,7 +3960,7 @@ dependencies = [
[[package]]
name = "openfang-desktop"
version = "0.2.6"
version = "0.3.0"
dependencies = [
"axum",
"open",
@@ -3986,7 +3986,7 @@ dependencies = [
[[package]]
name = "openfang-extensions"
version = "0.2.6"
version = "0.3.0"
dependencies = [
"aes-gcm",
"argon2",
@@ -4014,7 +4014,7 @@ dependencies = [
[[package]]
name = "openfang-hands"
version = "0.2.6"
version = "0.3.0"
dependencies = [
"chrono",
"dashmap",
@@ -4031,7 +4031,7 @@ dependencies = [
[[package]]
name = "openfang-kernel"
version = "0.2.6"
version = "0.3.0"
dependencies = [
"async-trait",
"chrono",
@@ -4067,7 +4067,7 @@ dependencies = [
[[package]]
name = "openfang-memory"
version = "0.2.6"
version = "0.3.0"
dependencies = [
"async-trait",
"chrono",
@@ -4086,7 +4086,7 @@ dependencies = [
[[package]]
name = "openfang-migrate"
version = "0.2.6"
version = "0.3.0"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -4105,7 +4105,7 @@ dependencies = [
[[package]]
name = "openfang-runtime"
version = "0.2.6"
version = "0.3.0"
dependencies = [
"anyhow",
"async-trait",
@@ -4137,7 +4137,7 @@ dependencies = [
[[package]]
name = "openfang-skills"
version = "0.2.6"
version = "0.3.0"
dependencies = [
"chrono",
"hex",
@@ -4159,7 +4159,7 @@ dependencies = [
[[package]]
name = "openfang-types"
version = "0.2.6"
version = "0.3.0"
dependencies = [
"async-trait",
"chrono",
@@ -4178,7 +4178,7 @@ dependencies = [
[[package]]
name = "openfang-wire"
version = "0.2.6"
version = "0.3.0"
dependencies = [
"async-trait",
"chrono",
@@ -8790,7 +8790,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xtask"
version = "0.2.6"
version = "0.3.0"
[[package]]
name = "yoke"
+1 -1
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.2.7"
version = "0.3.2"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
+4
View File
@@ -82,6 +82,10 @@ pub async fn auth(
// Public endpoints that don't require auth (dashboard needs these)
let path = request.uri().path();
if path == "/"
|| path == "/logo.png"
|| path == "/favicon.ico"
|| path == "/.well-known/agent.json"
|| path.starts_with("/a2a/")
|| path == "/api/health"
|| path == "/api/health/detail"
|| path == "/api/status"
+114 -31
View File
@@ -33,6 +33,9 @@ pub struct AppState {
pub channels_config: tokio::sync::RwLock<openfang_types::config::ChannelsConfig>,
/// Notify handle to trigger graceful HTTP server shutdown from the API.
pub shutdown_notify: Arc<tokio::sync::Notify>,
/// ClawHub response cache — prevents 429 rate limiting on rapid dashboard refreshes.
/// Maps cache key → (fetched_at, response_json) with 120s TTL.
pub clawhub_cache: DashMap<String, (Instant, serde_json::Value)>,
}
/// POST /api/agents — Spawn a new agent.
@@ -1120,7 +1123,7 @@ const CHANNEL_REGISTRY: &[ChannelMeta] = &[
ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("DISCORD_BOT_TOKEN"), required: true, placeholder: "MTIz...", advanced: false },
ChannelField { key: "allowed_guilds", label: "Allowed Guild IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "123456789, 987654321", advanced: true },
ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true },
ChannelField { key: "intents", label: "Intents Bitmask", field_type: FieldType::Number, env_var: None, required: false, placeholder: "33280", advanced: true },
ChannelField { key: "intents", label: "Intents Bitmask", field_type: FieldType::Number, env_var: None, required: false, placeholder: "37376", advanced: true },
],
setup_steps: &["Go to discord.com/developers/applications", "Create a bot and copy the token", "Paste it below"],
config_template: "[channels.discord]\nbot_token_env = \"DISCORD_BOT_TOKEN\"",
@@ -2782,6 +2785,14 @@ pub async fn clawhub_search(
.and_then(|v| v.parse().ok())
.unwrap_or(20);
// Check cache (120s TTL)
let cache_key = format!("search:{}:{}", query, limit);
if let Some(entry) = state.clawhub_cache.get(&cache_key) {
if entry.0.elapsed().as_secs() < 120 {
return (StatusCode::OK, Json(entry.1.clone()));
}
}
let cache_dir = state.kernel.config.home_dir.join(".cache").join("clawhub");
let client = openfang_skills::clawhub::ClawHubClient::new(cache_dir);
@@ -2801,20 +2812,26 @@ pub async fn clawhub_search(
})
})
.collect();
(
StatusCode::OK,
Json(serde_json::json!({
"items": items,
"next_cursor": null,
})),
)
let resp = serde_json::json!({
"items": items,
"next_cursor": null,
});
state.clawhub_cache.insert(cache_key, (Instant::now(), resp.clone()));
(StatusCode::OK, Json(resp))
}
Err(e) => {
tracing::warn!("ClawHub search failed: {e}");
let msg = format!("{e}");
tracing::warn!("ClawHub search failed: {msg}");
// Propagate 429 status instead of masking as 200
let status = if msg.contains("429") || msg.contains("rate limit") {
StatusCode::TOO_MANY_REQUESTS
} else {
StatusCode::OK
};
(
StatusCode::OK,
status,
Json(
serde_json::json!({"items": [], "next_cursor": null, "error": format!("{e}")}),
serde_json::json!({"items": [], "next_cursor": null, "error": msg}),
),
)
}
@@ -2846,6 +2863,14 @@ pub async fn clawhub_browse(
let cursor = params.get("cursor").map(|s| s.as_str());
// Check cache (120s TTL)
let cache_key = format!("browse:{:?}:{}:{}", sort, limit, cursor.unwrap_or(""));
if let Some(entry) = state.clawhub_cache.get(&cache_key) {
if entry.0.elapsed().as_secs() < 120 {
return (StatusCode::OK, Json(entry.1.clone()));
}
}
let cache_dir = state.kernel.config.home_dir.join(".cache").join("clawhub");
let client = openfang_skills::clawhub::ClawHubClient::new(cache_dir);
@@ -2856,20 +2881,25 @@ pub async fn clawhub_browse(
.iter()
.map(clawhub_browse_entry_to_json)
.collect();
(
StatusCode::OK,
Json(serde_json::json!({
"items": items,
"next_cursor": results.next_cursor,
})),
)
let resp = serde_json::json!({
"items": items,
"next_cursor": results.next_cursor,
});
state.clawhub_cache.insert(cache_key, (Instant::now(), resp.clone()));
(StatusCode::OK, Json(resp))
}
Err(e) => {
tracing::warn!("ClawHub browse failed: {e}");
let msg = format!("{e}");
tracing::warn!("ClawHub browse failed: {msg}");
let status = if msg.contains("429") || msg.contains("rate limit") {
StatusCode::TOO_MANY_REQUESTS
} else {
StatusCode::OK
};
(
StatusCode::OK,
status,
Json(
serde_json::json!({"items": [], "next_cursor": null, "error": format!("{e}")}),
serde_json::json!({"items": [], "next_cursor": null, "error": msg}),
),
)
}
@@ -2992,13 +3022,18 @@ pub async fn clawhub_install(
)
}
Err(e) => {
let status = if e.to_string().contains("SecurityBlocked") {
let msg = format!("{e}");
let status = if msg.contains("SecurityBlocked") {
StatusCode::FORBIDDEN
} else if msg.contains("429") || msg.contains("rate limit") {
StatusCode::TOO_MANY_REQUESTS
} else if msg.contains("Network error") || msg.contains("returned 4") || msg.contains("returned 5") {
StatusCode::BAD_GATEWAY
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
tracing::warn!("ClawHub install failed: {e}");
(status, Json(serde_json::json!({"error": format!("{e}")})))
tracing::warn!("ClawHub install failed: {msg}");
(status, Json(serde_json::json!({"error": msg})))
}
}
}
@@ -6957,6 +6992,29 @@ pub async fn create_schedule(
}
let agent_id_str = req["agent_id"].as_str().unwrap_or("").to_string();
if agent_id_str.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Missing required field: agent_id"})),
);
}
// Validate agent exists (UUID or name lookup)
let agent_exists = if let Ok(aid) = agent_id_str.parse::<AgentId>() {
state.kernel.registry.get(aid).is_some()
} else {
state
.kernel
.registry
.list()
.iter()
.any(|a| a.name == agent_id_str)
};
if !agent_exists {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": format!("Agent not found: {agent_id_str}")})),
);
}
let message = req["message"].as_str().unwrap_or("").to_string();
let enabled = req.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
@@ -7130,10 +7188,14 @@ pub async fn run_schedule(
.unwrap_or("Scheduled task triggered manually.");
let name = schedule["name"].as_str().unwrap_or("(unnamed)");
// Find the target agent
// Find the target agent — require explicit agent_id, no silent fallback
let target_agent = if !agent_id_str.is_empty() {
if let Ok(aid) = agent_id_str.parse::<AgentId>() {
Some(aid)
if state.kernel.registry.get(aid).is_some() {
Some(aid)
} else {
None
}
} else {
state
.kernel
@@ -7144,7 +7206,7 @@ pub async fn run_schedule(
.map(|a| a.id)
}
} else {
state.kernel.registry.list().first().map(|a| a.id)
None
};
let target_agent = match target_agent {
@@ -7185,7 +7247,8 @@ pub async fn run_schedule(
serde_json::Value::Array(schedules_updated),
);
match state.kernel.send_message(target_agent, &run_message).await {
let kernel_handle: Arc<dyn KernelHandle> = state.kernel.clone() as Arc<dyn KernelHandle>;
match state.kernel.send_message_with_handle(target_agent, &run_message, Some(kernel_handle)).await {
Ok(result) => (
StatusCode::OK,
Json(serde_json::json!({
@@ -7505,6 +7568,13 @@ pub async fn patch_agent_config(
}
}
// Persist updated manifest to database so changes survive restart
if let Some(entry) = state.kernel.registry.get(agent_id) {
if let Err(e) = state.kernel.memory.save_agent(&entry) {
tracing::warn!("Failed to persist agent config update: {e}");
}
}
(
StatusCode::OK,
Json(serde_json::json!({"status": "ok", "agent_id": id})),
@@ -8318,7 +8388,19 @@ pub async fn config_reload(State(state): State<Arc<AppState>>) -> impl IntoRespo
// ---------------------------------------------------------------------------
/// GET /api/config/schema — Return a simplified JSON description of the config structure.
pub async fn config_schema() -> impl IntoResponse {
pub async fn config_schema(
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
// Build provider/model options from model catalog for dropdowns
let catalog = state.kernel.model_catalog.read().unwrap_or_else(|e| e.into_inner());
let provider_options: Vec<String> = catalog.list_providers().iter().map(|p| p.id.clone()).collect();
let model_options: Vec<serde_json::Value> = catalog
.list_models()
.iter()
.map(|m| serde_json::json!({"id": m.id, "name": m.display_name, "provider": m.provider}))
.collect();
drop(catalog);
Json(serde_json::json!({
"sections": {
"api": {
@@ -8329,9 +8411,10 @@ pub async fn config_schema() -> impl IntoResponse {
}
},
"default_model": {
"hot_reloadable": true,
"fields": {
"provider": "string",
"model": "string",
"provider": { "type": "select", "options": provider_options },
"model": { "type": "select", "options": model_options },
"api_key_env": "string",
"base_url": "string"
}
+1
View File
@@ -49,6 +49,7 @@ pub async fn build_router(
bridge_manager: tokio::sync::Mutex::new(bridge),
channels_config: tokio::sync::RwLock::new(channels_config),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
});
// CORS: allow localhost origins by default. If API key is set, the API
+2 -57
View File
@@ -1349,63 +1349,8 @@
</defs>
<rect x="-2000" y="-2000" width="6000" height="6000" fill="url(#wf-grid)"/>
<!-- Connections -->
<template x-for="conn in connections" :key="conn.id">
<g>
<path :d="getConnectionPath(conn)" fill="none" stroke="var(--text-dim)" stroke-width="2"
style="cursor:pointer" @click.stop="selectedConnection = conn"
:stroke="selectedConnection && selectedConnection.id === conn.id ? 'var(--accent)' : 'var(--text-dim)'"
:stroke-width="selectedConnection && selectedConnection.id === conn.id ? 3 : 2"/>
<!-- Arrow at midpoint -->
</g>
</template>
<!-- Connection preview line -->
<template x-if="connecting && connectPreview">
<path :d="getPreviewPath()" fill="none" stroke="var(--accent)" stroke-width="2" stroke-dasharray="6,3"/>
</template>
<!-- Nodes -->
<template x-for="node in nodes" :key="node.id">
<g class="wf-node" :transform="'translate(' + node.x + ',' + node.y + ')'"
@mousedown="onNodeMouseDown(node, $event)" @dblclick="editNode(node)">
<!-- Node body -->
<rect x="0" y="0" :width="node.width" :height="node.height" rx="8" ry="8"
:fill="selectedNode && selectedNode.id === node.id ? 'var(--card-bg)' : 'var(--bg-secondary)'"
:stroke="selectedNode && selectedNode.id === node.id ? node.color : 'var(--border)'"
stroke-width="2" style="cursor:grab"/>
<!-- Color accent bar -->
<rect x="0" y="0" width="6" :height="node.height" rx="3" ry="0" :fill="node.color"/>
<!-- Icon -->
<circle cx="28" :cy="node.height/2" r="14" :fill="node.color" opacity="0.15"/>
<text x="28" :y="node.height/2 + 4" text-anchor="middle" :fill="node.color"
style="font-size:12px;font-weight:700;pointer-events:none" x-text="node.icon"></text>
<!-- Label -->
<text x="50" :y="node.height/2 - 4" fill="var(--text)" style="font-size:12px;font-weight:600;pointer-events:none"
x-text="node.label"></text>
<!-- Sub-label (config hint) -->
<text x="50" :y="node.height/2 + 12" fill="var(--text-dim)" style="font-size:10px;pointer-events:none"
x-text="node.type === 'agent' ? (node.config.agent_name || 'No agent') :
node.type === 'condition' ? (node.config.expression || 'No condition') :
node.type === 'loop' ? ('max ' + (node.config.max_iterations || 5) + ' iters') :
node.type === 'parallel' ? (node.config.fan_count + ' branches') :
node.type === 'collect' ? node.config.strategy : ''"></text>
<!-- Input ports -->
<template x-for="pi in Array.from({length: node.ports.in}, function(_,i){return i})" :key="'in-'+pi">
<circle class="wf-port wf-port-in" :cx="node.width / (node.ports.in + 1) * (pi + 1)" cy="0" r="6"
fill="var(--bg-secondary)" stroke="var(--text-dim)" stroke-width="2"
@mouseup.stop="endConnect(node.id, pi, $event)"/>
</template>
<!-- Output ports -->
<template x-for="po in Array.from({length: node.ports.out}, function(_,i){return i})" :key="'out-'+po">
<circle class="wf-port wf-port-out" :cx="node.width / (node.ports.out + 1) * (po + 1)" :cy="node.height" r="6"
fill="var(--bg-secondary)" :stroke="node.color" stroke-width="2"
@mousedown.stop="startConnect(node.id, po, $event)"/>
</template>
</g>
</template>
<!-- Manually rendered nodes & connections (Alpine x-for breaks inside SVG) -->
<g id="wf-render-group" x-effect="nodes.length; connections.length; selectedNode; selectedConnection; connecting; connectPreview; scheduleRender()"></g>
</g>
</svg>
+11 -1
View File
@@ -19,6 +19,8 @@ function skillsPage() {
installingSlug: null,
installResult: null,
_searchTimer: null,
_browseCache: {}, // { key: { ts, data } } client-side 60s cache
_searchCache: {},
// Skill detail modal
skillDetail: null,
@@ -146,9 +148,16 @@ function skillsPage() {
if (this._searchTimer) clearTimeout(this._searchTimer);
},
// ClawHub browse by sort
// ClawHub browse by sort (with 60s client-side cache)
async browseClawHub(sort) {
this.clawhubSort = sort || 'trending';
var ckey = 'browse:' + this.clawhubSort;
var cached = this._browseCache[ckey];
if (cached && (Date.now() - cached.ts) < 60000) {
this.clawhubBrowseResults = cached.data.items || [];
this.clawhubNextCursor = cached.data.next_cursor || null;
return;
}
this.clawhubLoading = true;
this.clawhubError = '';
this.clawhubNextCursor = null;
@@ -157,6 +166,7 @@ function skillsPage() {
this.clawhubBrowseResults = data.items || [];
this.clawhubNextCursor = data.next_cursor || null;
if (data.error) this.clawhubError = data.error;
this._browseCache[ckey] = { ts: Date.now(), data: data };
} catch(e) {
this.clawhubBrowseResults = [];
this.clawhubError = e.message || 'Browse failed';
@@ -37,6 +37,8 @@ function workflowBuilder() {
{ type: 'end', label: 'End', color: '#ef4444', icon: 'E', ports: { in: 1, out: 0 } }
],
_renderScheduled: false,
async init() {
var self = this;
// Load agents for the agent step dropdown
@@ -50,6 +52,157 @@ function workflowBuilder() {
self.addNode('start', 60, 200);
},
// ── SVG Manual Rendering ────────────────────────────
// Alpine.js x-for inside <svg> breaks because document.importNode
// doesn't handle SVG namespace correctly. We render nodes/connections
// manually via createElementNS and schedule re-renders reactively.
scheduleRender: function() {
if (this._renderScheduled) return;
this._renderScheduled = true;
var self = this;
requestAnimationFrame(function() {
self._renderScheduled = false;
self.renderCanvas();
});
},
renderCanvas: function() {
var container = document.getElementById('wf-render-group');
if (!container) return;
var SVG_NS = 'http://www.w3.org/2000/svg';
var self = this;
// Clear previous rendered content
while (container.firstChild) container.removeChild(container.firstChild);
// ── Connections ──
for (var ci = 0; ci < this.connections.length; ci++) {
var conn = this.connections[ci];
var d = this.getConnectionPath(conn);
if (!d) continue;
var path = document.createElementNS(SVG_NS, 'path');
path.setAttribute('d', d);
path.setAttribute('fill', 'none');
path.setAttribute('stroke', (this.selectedConnection && this.selectedConnection.id === conn.id) ? 'var(--accent)' : 'var(--text-dim)');
path.setAttribute('stroke-width', (this.selectedConnection && this.selectedConnection.id === conn.id) ? '3' : '2');
path.style.cursor = 'pointer';
(function(c) {
path.addEventListener('click', function(e) { e.stopPropagation(); self.selectedConnection = c; self.scheduleRender(); });
})(conn);
container.appendChild(path);
}
// ── Connection preview ──
if (this.connecting && this.connectPreview) {
var pd = this.getPreviewPath();
if (pd) {
var preview = document.createElementNS(SVG_NS, 'path');
preview.setAttribute('d', pd);
preview.setAttribute('fill', 'none');
preview.setAttribute('stroke', 'var(--accent)');
preview.setAttribute('stroke-width', '2');
preview.setAttribute('stroke-dasharray', '6,3');
container.appendChild(preview);
}
}
// ── Nodes ──
for (var ni = 0; ni < this.nodes.length; ni++) {
var node = this.nodes[ni];
var g = document.createElementNS(SVG_NS, 'g');
g.classList.add('wf-node');
g.setAttribute('transform', 'translate(' + node.x + ',' + node.y + ')');
(function(n) {
g.addEventListener('mousedown', function(e) { self.onNodeMouseDown(n, e); });
g.addEventListener('dblclick', function() { self.editNode(n); });
})(node);
// Node body rect
var rect = document.createElementNS(SVG_NS, 'rect');
rect.setAttribute('x', '0'); rect.setAttribute('y', '0');
rect.setAttribute('width', node.width); rect.setAttribute('height', node.height);
rect.setAttribute('rx', '8'); rect.setAttribute('ry', '8');
rect.setAttribute('fill', (self.selectedNode && self.selectedNode.id === node.id) ? 'var(--card-bg)' : 'var(--bg-secondary)');
rect.setAttribute('stroke', (self.selectedNode && self.selectedNode.id === node.id) ? node.color : 'var(--border)');
rect.setAttribute('stroke-width', '2');
rect.style.cursor = 'grab';
g.appendChild(rect);
// Color accent bar
var bar = document.createElementNS(SVG_NS, 'rect');
bar.setAttribute('x', '0'); bar.setAttribute('y', '0');
bar.setAttribute('width', '6'); bar.setAttribute('height', node.height);
bar.setAttribute('rx', '3'); bar.setAttribute('ry', '0');
bar.setAttribute('fill', node.color);
g.appendChild(bar);
// Icon circle + text
var circle = document.createElementNS(SVG_NS, 'circle');
circle.setAttribute('cx', '28'); circle.setAttribute('cy', node.height / 2);
circle.setAttribute('r', '14'); circle.setAttribute('fill', node.color);
circle.setAttribute('opacity', '0.15');
g.appendChild(circle);
var iconText = document.createElementNS(SVG_NS, 'text');
iconText.setAttribute('x', '28'); iconText.setAttribute('y', node.height / 2 + 4);
iconText.setAttribute('text-anchor', 'middle'); iconText.setAttribute('fill', node.color);
iconText.setAttribute('style', 'font-size:12px;font-weight:700;pointer-events:none');
iconText.textContent = node.icon;
g.appendChild(iconText);
// Label
var label = document.createElementNS(SVG_NS, 'text');
label.setAttribute('x', '50'); label.setAttribute('y', node.height / 2 - 4);
label.setAttribute('fill', 'var(--text)');
label.setAttribute('style', 'font-size:12px;font-weight:600;pointer-events:none');
label.textContent = node.label;
g.appendChild(label);
// Sub-label
var subLabel = document.createElementNS(SVG_NS, 'text');
subLabel.setAttribute('x', '50'); subLabel.setAttribute('y', node.height / 2 + 12);
subLabel.setAttribute('fill', 'var(--text-dim)');
subLabel.setAttribute('style', 'font-size:10px;pointer-events:none');
if (node.type === 'agent') subLabel.textContent = node.config.agent_name || 'No agent';
else if (node.type === 'condition') subLabel.textContent = node.config.expression || 'No condition';
else if (node.type === 'loop') subLabel.textContent = 'max ' + (node.config.max_iterations || 5) + ' iters';
else if (node.type === 'parallel') subLabel.textContent = (node.config.fan_count || 3) + ' branches';
else if (node.type === 'collect') subLabel.textContent = node.config.strategy || 'all';
g.appendChild(subLabel);
// Input ports
for (var pi = 0; pi < node.ports.in; pi++) {
var inp = document.createElementNS(SVG_NS, 'circle');
inp.classList.add('wf-port', 'wf-port-in');
inp.setAttribute('cx', node.width / (node.ports.in + 1) * (pi + 1));
inp.setAttribute('cy', '0'); inp.setAttribute('r', '6');
inp.setAttribute('fill', 'var(--bg-secondary)');
inp.setAttribute('stroke', 'var(--text-dim)'); inp.setAttribute('stroke-width', '2');
(function(nid, idx) {
inp.addEventListener('mouseup', function(e) { e.stopPropagation(); self.endConnect(nid, idx, e); });
})(node.id, pi);
g.appendChild(inp);
}
// Output ports
for (var po = 0; po < node.ports.out; po++) {
var outp = document.createElementNS(SVG_NS, 'circle');
outp.classList.add('wf-port', 'wf-port-out');
outp.setAttribute('cx', node.width / (node.ports.out + 1) * (po + 1));
outp.setAttribute('cy', node.height); outp.setAttribute('r', '6');
outp.setAttribute('fill', 'var(--bg-secondary)');
outp.setAttribute('stroke', node.color); outp.setAttribute('stroke-width', '2');
(function(nid, idx) {
outp.addEventListener('mousedown', function(e) { e.stopPropagation(); self.startConnect(nid, idx, e); });
})(node.id, po);
g.appendChild(outp);
}
container.appendChild(g);
}
},
// ── Node Management ──────────────────────────────────
addNode: function(type, x, y) {
@@ -83,6 +236,7 @@ function workflowBuilder() {
node.config = { strategy: 'all' };
}
this.nodes.push(node);
this.scheduleRender();
return node;
},
@@ -95,6 +249,7 @@ function workflowBuilder() {
this.selectedNode = null;
this.showNodeEditor = false;
}
this.scheduleRender();
},
duplicateNode: function(node) {
@@ -166,11 +321,13 @@ function workflowBuilder() {
}
this.connecting = null;
this.connectPreview = null;
this.scheduleRender();
},
deleteConnection: function(connId) {
this.connections = this.connections.filter(function(c) { return c.id !== connId; });
this.selectedConnection = null;
this.scheduleRender();
},
// ── Drag Handling ────────────────────────────────────
@@ -205,11 +362,13 @@ function workflowBuilder() {
node.x = Math.max(0, (e.clientX - rect.left) / this.zoom - this.canvasOffset.x - this.dragOffset.x);
node.y = Math.max(0, (e.clientY - rect.top) / this.zoom - this.canvasOffset.y - this.dragOffset.y);
}
this.scheduleRender();
} else if (this.connecting) {
this.connectPreview = {
x: (e.clientX - rect.left) / this.zoom - this.canvasOffset.x,
y: (e.clientY - rect.top) / this.zoom - this.canvasOffset.y
};
this.scheduleRender();
} else if (this.canvasDragging) {
this.canvasOffset = {
x: (e.clientX - this.canvasDragStart.x) / this.zoom,
@@ -223,6 +382,7 @@ function workflowBuilder() {
this.connecting = null;
this.connectPreview = null;
this.canvasDragging = false;
this.scheduleRender();
},
onCanvasWheel: function(e) {
@@ -386,7 +546,7 @@ function workflowBuilder() {
var rect = this._getCanvasRect();
var x = (e.clientX - rect.left) / this.zoom - this.canvasOffset.x;
var y = (e.clientY - rect.top) / this.zoom - this.canvasOffset.y;
this.addNode(type, x - 90, y - 35);
this.addNode(type, x - 90, y - 35); // addNode already calls scheduleRender
},
onCanvasDragOver: function(e) {
@@ -405,6 +565,7 @@ function workflowBuilder() {
this.nodes[i].y = y;
y += 120;
}
this.scheduleRender();
},
// ── Clear ────────────────────────────────────────────
@@ -414,7 +575,7 @@ function workflowBuilder() {
this.connections = [];
this.selectedNode = null;
this.nextId = 1;
this.addNode('start', 60, 200);
this.addNode('start', 60, 200); // addNode already calls scheduleRender
},
// ── Zoom controls ────────────────────────────────────
@@ -76,6 +76,7 @@ async fn start_test_server_with_provider(
bridge_manager: tokio::sync::Mutex::new(None),
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
});
let app = Router::new()
@@ -702,6 +703,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
bridge_manager: tokio::sync::Mutex::new(None),
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
});
let api_key_state = state.kernel.config.api_key.clone();
@@ -113,6 +113,7 @@ async fn test_full_daemon_lifecycle() {
bridge_manager: tokio::sync::Mutex::new(None),
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
});
let app = Router::new()
@@ -236,6 +237,7 @@ async fn test_server_immediate_responsiveness() {
bridge_manager: tokio::sync::Mutex::new(None),
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
});
let app = Router::new()
+1
View File
@@ -57,6 +57,7 @@ async fn start_test_server() -> TestServer {
bridge_manager: tokio::sync::Mutex::new(None),
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
});
let app = Router::new()
+6 -1
View File
@@ -373,10 +373,15 @@ async fn dispatch_message(
// Fetch per-channel overrides (if configured)
let overrides = handle.channel_overrides(ct_str).await;
let channel_default_format = match ct_str {
"telegram" => OutputFormat::TelegramHtml,
"slack" => OutputFormat::SlackMrkdwn,
_ => OutputFormat::Markdown,
};
let output_format = overrides
.as_ref()
.and_then(|o| o.output_format)
.unwrap_or(OutputFormat::Markdown);
.unwrap_or(channel_default_format);
let threading_enabled = overrides.as_ref().map(|o| o.threading).unwrap_or(false);
let thread_id = if threading_enabled {
message.thread_id.as_deref()
+7 -8
View File
@@ -38,7 +38,7 @@ pub struct DiscordAdapter {
/// SECURITY: Bot token is zeroized on drop to prevent memory disclosure.
token: Zeroizing<String>,
client: reqwest::Client,
allowed_guilds: Vec<u64>,
allowed_guilds: Vec<String>,
intents: u64,
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
@@ -51,7 +51,7 @@ pub struct DiscordAdapter {
}
impl DiscordAdapter {
pub fn new(token: String, allowed_guilds: Vec<u64>, intents: u64) -> Self {
pub fn new(token: String, allowed_guilds: Vec<String>, intents: u64) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
token: Zeroizing::new(token),
@@ -422,7 +422,7 @@ impl ChannelAdapter for DiscordAdapter {
async fn parse_discord_message(
d: &serde_json::Value,
bot_user_id: &Arc<RwLock<Option<String>>>,
allowed_guilds: &[u64],
allowed_guilds: &[String],
) -> Option<ChannelMessage> {
let author = d.get("author")?;
let author_id = author["id"].as_str()?;
@@ -442,8 +442,7 @@ async fn parse_discord_message(
// Filter by allowed guilds
if !allowed_guilds.is_empty() {
if let Some(guild_id) = d["guild_id"].as_str() {
let gid: u64 = guild_id.parse().unwrap_or(0);
if !allowed_guilds.contains(&gid) {
if !allowed_guilds.iter().any(|g| g == guild_id) {
return None;
}
}
@@ -587,11 +586,11 @@ mod tests {
});
// Not in allowed guilds
let msg = parse_discord_message(&d, &bot_id, &[111, 222]).await;
let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()]).await;
assert!(msg.is_none());
// In allowed guilds
let msg = parse_discord_message(&d, &bot_id, &[999]).await;
let msg = parse_discord_message(&d, &bot_id, &["999".into()]).await;
assert!(msg.is_some());
}
@@ -685,7 +684,7 @@ mod tests {
#[test]
fn test_discord_adapter_creation() {
let adapter = DiscordAdapter::new("test-token".to_string(), vec![123, 456], 33280);
let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], 37376);
assert_eq!(adapter.name(), "discord");
assert_eq!(adapter.channel_type(), ChannelType::Discord);
}
+133 -2
View File
@@ -85,6 +85,7 @@ impl TelegramAdapter {
let body = serde_json::json!({
"chat_id": chat_id,
"text": chunk,
"parse_mode": "HTML",
});
let resp = self.client.post(&url).json(&body).send().await?;
@@ -97,6 +98,103 @@ impl TelegramAdapter {
Ok(())
}
/// Call `sendPhoto` on the Telegram API.
async fn api_send_photo(
&self,
chat_id: i64,
photo_url: &str,
caption: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"https://api.telegram.org/bot{}/sendPhoto",
self.token.as_str()
);
let mut body = serde_json::json!({
"chat_id": chat_id,
"photo": photo_url,
});
if let Some(cap) = caption {
body["caption"] = serde_json::Value::String(cap.to_string());
body["parse_mode"] = serde_json::Value::String("HTML".to_string());
}
let resp = self.client.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Telegram sendPhoto failed: {body_text}");
}
Ok(())
}
/// Call `sendDocument` on the Telegram API.
async fn api_send_document(
&self,
chat_id: i64,
document_url: &str,
filename: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"https://api.telegram.org/bot{}/sendDocument",
self.token.as_str()
);
let body = serde_json::json!({
"chat_id": chat_id,
"document": document_url,
"caption": filename,
});
let resp = self.client.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Telegram sendDocument failed: {body_text}");
}
Ok(())
}
/// Call `sendVoice` on the Telegram API.
async fn api_send_voice(
&self,
chat_id: i64,
voice_url: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"https://api.telegram.org/bot{}/sendVoice",
self.token.as_str()
);
let body = serde_json::json!({
"chat_id": chat_id,
"voice": voice_url,
});
let resp = self.client.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Telegram sendVoice failed: {body_text}");
}
Ok(())
}
/// Call `sendLocation` on the Telegram API.
async fn api_send_location(
&self,
chat_id: i64,
lat: f64,
lon: f64,
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"https://api.telegram.org/bot{}/sendLocation",
self.token.as_str()
);
let body = serde_json::json!({
"chat_id": chat_id,
"latitude": lat,
"longitude": lon,
});
let resp = self.client.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Telegram sendLocation failed: {body_text}");
}
Ok(())
}
/// Call `sendChatAction` to show "typing..." indicator.
async fn api_send_typing(&self, chat_id: i64) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
@@ -130,6 +228,26 @@ impl ChannelAdapter for TelegramAdapter {
let bot_name = self.validate_token().await?;
info!("Telegram bot @{bot_name} connected");
// Clear any existing webhook to avoid 409 Conflict during getUpdates polling.
// This is necessary when the daemon restarts — the old polling session may
// still be active on Telegram's side for ~30s, causing 409 errors.
{
let delete_url = format!(
"https://api.telegram.org/bot{}/deleteWebhook",
self.token.as_str()
);
match self
.client
.post(&delete_url)
.json(&serde_json::json!({"drop_pending_updates": true}))
.send()
.await
{
Ok(_) => info!("Telegram: cleared webhook, polling mode active"),
Err(e) => tracing::warn!("Telegram: deleteWebhook failed (non-fatal): {e}"),
}
}
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
let token = self.token.clone();
@@ -285,10 +403,23 @@ impl ChannelAdapter for TelegramAdapter {
ChannelContent::Text(text) => {
self.api_send_message(chat_id, &text).await?;
}
_ => {
self.api_send_message(chat_id, "(Unsupported content type)")
ChannelContent::Image { url, caption } => {
self.api_send_photo(chat_id, &url, caption.as_deref())
.await?;
}
ChannelContent::File { url, filename } => {
self.api_send_document(chat_id, &url, &filename).await?;
}
ChannelContent::Voice { url, .. } => {
self.api_send_voice(chat_id, &url).await?;
}
ChannelContent::Location { lat, lon } => {
self.api_send_location(chat_id, lat, lon).await?;
}
ChannelContent::Command { name, args } => {
let text = format!("/{name} {}", args.join(" "));
self.api_send_message(chat_id, text.trim()).await?;
}
}
Ok(())
}
+162 -4
View File
@@ -405,6 +405,26 @@ enum HandCommands {
/// Hand ID.
id: String,
},
/// Check dependency status for a hand.
CheckDeps {
/// Hand ID.
id: String,
},
/// Install missing dependencies for a hand.
InstallDeps {
/// Hand ID.
id: String,
},
/// Pause a running hand instance.
Pause {
/// Instance ID (from `hand active`).
id: String,
},
/// Resume a paused hand instance.
Resume {
/// Instance ID (from `hand active`).
id: String,
},
}
#[derive(Subcommand)]
@@ -612,6 +632,9 @@ enum CronCommands {
spec: String,
/// Prompt to send when the job fires.
prompt: String,
/// Optional job name (auto-generated if omitted).
#[arg(long)]
name: Option<String>,
},
/// Delete a scheduled job.
Delete {
@@ -890,6 +913,10 @@ fn main() {
HandCommands::Activate { id } => cmd_hand_activate(&id),
HandCommands::Deactivate { id } => cmd_hand_deactivate(&id),
HandCommands::Info { id } => cmd_hand_info(&id),
HandCommands::CheckDeps { id } => cmd_hand_check_deps(&id),
HandCommands::InstallDeps { id } => cmd_hand_install_deps(&id),
HandCommands::Pause { id } => cmd_hand_pause(&id),
HandCommands::Resume { id } => cmd_hand_resume(&id),
},
Some(Commands::Config(sub)) => match sub {
ConfigCommands::Show => cmd_config_show(),
@@ -940,7 +967,8 @@ fn main() {
agent,
spec,
prompt,
} => cmd_cron_create(&agent, &spec, &prompt),
name,
} => cmd_cron_create(&agent, &spec, &prompt, name.as_deref()),
CronCommands::Delete { id } => cmd_cron_delete(&id),
CronCommands::Enable { id } => cmd_cron_toggle(&id, true),
CronCommands::Disable { id } => cmd_cron_toggle(&id, false),
@@ -3952,6 +3980,87 @@ fn cmd_hand_info(id: &str) {
);
}
fn cmd_hand_check_deps(id: &str) {
let base = require_daemon("hand check-deps");
let client = daemon_client();
let body = daemon_json(
client
.post(format!("{base}/api/hands/{id}/check-deps"))
.send(),
);
if body.get("error").is_some() {
ui::error(&format!(
"Failed: {}",
body["error"].as_str().unwrap_or("?")
));
} else {
println!(
"{}",
serde_json::to_string_pretty(&body).unwrap_or_default()
);
}
}
fn cmd_hand_install_deps(id: &str) {
let base = require_daemon("hand install-deps");
let client = daemon_client();
let body = daemon_json(
client
.post(format!("{base}/api/hands/{id}/install-deps"))
.send(),
);
if body.get("error").is_some() {
ui::error(&format!(
"Failed: {}",
body["error"].as_str().unwrap_or("?")
));
} else {
ui::success(&format!("Dependencies installed for hand '{id}'."));
if let Some(results) = body.get("results") {
println!(
"{}",
serde_json::to_string_pretty(results).unwrap_or_default()
);
}
}
}
fn cmd_hand_pause(id: &str) {
let base = require_daemon("hand pause");
let client = daemon_client();
let body = daemon_json(
client
.post(format!("{base}/api/hands/instances/{id}/pause"))
.send(),
);
if body.get("error").is_some() {
ui::error(&format!(
"Failed: {}",
body["error"].as_str().unwrap_or("?")
));
} else {
ui::success(&format!("Hand instance '{id}' paused."));
}
}
fn cmd_hand_resume(id: &str) {
let base = require_daemon("hand resume");
let client = daemon_client();
let body = daemon_json(
client
.post(format!("{base}/api/hands/instances/{id}/resume"))
.send(),
);
if body.get("error").is_some() {
ui::error(&format!(
"Failed: {}",
body["error"].as_str().unwrap_or("?")
));
} else {
ui::success(&format!("Hand instance '{id}' resumed."));
}
}
// ---------------------------------------------------------------------------
// Provider / API key helpers
// ---------------------------------------------------------------------------
@@ -4220,6 +4329,31 @@ fn cmd_config_set(key: &str, value: &str) {
}
let last_key = parts[parts.len() - 1];
// Validate: single-part keys must be known scalar fields, not sections.
// Writing a section name as a scalar silently breaks config deserialization.
if parts.len() == 1 {
let known_scalars = [
"home_dir",
"data_dir",
"log_level",
"api_listen",
"network_enabled",
"api_key",
"language",
"max_cron_jobs",
"usage_footer",
"workspaces_dir",
];
if !known_scalars.contains(&last_key) {
ui::error_with_fix(
&format!("'{last_key}' is a section, not a scalar"),
&format!("Use dotted notation: {last_key}.field_name"),
);
std::process::exit(1);
}
}
let tbl = current.as_table_mut().unwrap_or_else(|| {
ui::error(&format!("Parent of '{key}' is not a table"));
std::process::exit(1);
@@ -5124,16 +5258,40 @@ fn cmd_cron_list(json: bool) {
}
}
fn cmd_cron_create(agent: &str, spec: &str, prompt: &str) {
fn cmd_cron_create(agent: &str, spec: &str, prompt: &str, explicit_name: Option<&str>) {
let base = require_daemon("cron create");
let client = daemon_client();
// Use explicit name if provided, otherwise derive from agent + prompt
let name = if let Some(n) = explicit_name {
n.to_string()
} else {
let short_prompt: String = prompt
.split_whitespace()
.take(4)
.collect::<Vec<_>>()
.join("-")
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.take(64)
.collect();
format!("{}-{}", agent, if short_prompt.is_empty() { "job" } else { &short_prompt })
};
let body = daemon_json(
client
.post(format!("{base}/api/cron/jobs"))
.json(&serde_json::json!({
"agent_id": agent,
"cron_expr": spec,
"prompt": prompt,
"name": name,
"schedule": {
"kind": "cron",
"expr": spec
},
"action": {
"kind": "agent_turn",
"message": prompt
}
}))
.send(),
);
+8 -11
View File
@@ -5,7 +5,7 @@
//!
//! **No-op** (informational only): log_level, language, mode.
//!
//! **Restart required**: api_listen, api_key, network, memory, default_model.
//! **Restart required**: api_listen, api_key, network, memory.
use openfang_types::config::{KernelConfig, ReloadMode};
use tracing::{info, warn};
@@ -43,6 +43,8 @@ pub enum HotAction {
ReloadFallbackProviders,
/// Provider base URL overrides changed.
ReloadProviderUrls,
/// Default model changed — update in-place without restart.
UpdateDefaultModel,
}
// ---------------------------------------------------------------------------
@@ -161,11 +163,9 @@ pub fn build_reload_plan(old: &KernelConfig, new: &KernelConfig) -> ReloadPlan {
.push("memory config changed".to_string());
}
// Default model (driver needs recreation)
// Default model — hot-reloadable (just swap config fields, new agents pick it up)
if field_changed(&old.default_model, &new.default_model) {
plan.restart_required = true;
plan.restart_reasons
.push("default_model changed".to_string());
plan.hot_actions.push(HotAction::UpdateDefaultModel);
}
// Home/data directory changes
@@ -406,16 +406,13 @@ mod tests {
}
#[test]
fn test_default_model_requires_restart() {
fn test_default_model_hot_reloadable() {
let a = default_cfg();
let mut b = default_cfg();
b.default_model.model = "gpt-4".to_string();
let plan = build_reload_plan(&a, &b);
assert!(plan.restart_required);
assert!(plan
.restart_reasons
.iter()
.any(|r| r.contains("default_model")));
assert!(!plan.restart_required, "default_model should be hot-reloadable");
assert!(plan.hot_actions.contains(&HotAction::UpdateDefaultModel));
}
// -----------------------------------------------------------------------
+150 -34
View File
@@ -131,6 +131,8 @@ pub struct OpenFangKernel {
pub whatsapp_gateway_pid: Arc<std::sync::Mutex<Option<u32>>>,
/// Channel adapters registered at bridge startup (for proactive `channel_send` tool).
pub channel_adapters: dashmap::DashMap<String, Arc<dyn openfang_channels::types::ChannelAdapter>>,
/// Hot-reloadable default model override (set via config hot-reload, read at agent spawn).
pub default_model_override: std::sync::RwLock<Option<openfang_types::config::DefaultModelConfig>>,
/// Weak self-reference for trigger dispatch (set after Arc wrapping).
self_handle: OnceLock<Weak<OpenFangKernel>>,
}
@@ -530,7 +532,11 @@ impl OpenFangKernel {
let driver_config = DriverConfig {
provider: config.default_model.provider.clone(),
api_key: std::env::var(&config.default_model.api_key_env).ok(),
base_url: config.default_model.base_url.clone(),
base_url: config
.default_model
.base_url
.clone()
.or_else(|| config.provider_urls.get(&config.default_model.provider).cloned()),
};
let primary_driver = drivers::create_driver(&driver_config)
.map_err(|e| KernelError::BootFailed(format!("LLM driver init failed: {e}")))?;
@@ -546,7 +552,10 @@ impl OpenFangKernel {
} else {
std::env::var(&fb.api_key_env).ok()
},
base_url: fb.base_url.clone(),
base_url: fb
.base_url
.clone()
.or_else(|| config.provider_urls.get(&fb.provider).cloned()),
};
match drivers::create_driver(&fb_config) {
Ok(d) => {
@@ -883,6 +892,7 @@ impl OpenFangKernel {
booted_at: std::time::Instant::now(),
whatsapp_gateway_pid: Arc::new(std::sync::Mutex::new(None)),
channel_adapters: dashmap::DashMap::new(),
default_model_override: std::sync::RwLock::new(None),
self_handle: OnceLock::new(),
};
@@ -894,6 +904,61 @@ impl OpenFangKernel {
let agent_id = entry.id;
let name = entry.name.clone();
// Check if TOML on disk is newer/different — if so, update from file
let mut entry = entry;
let toml_path = kernel
.config
.home_dir
.join("agents")
.join(&name)
.join("agent.toml");
if toml_path.exists() {
match std::fs::read_to_string(&toml_path) {
Ok(toml_str) => {
match toml::from_str::<openfang_types::agent::AgentManifest>(
&toml_str,
) {
Ok(disk_manifest) => {
// Compare key fields to detect changes
let changed = disk_manifest.name != entry.manifest.name
|| disk_manifest.description != entry.manifest.description
|| disk_manifest.model.system_prompt != entry.manifest.model.system_prompt
|| disk_manifest.model.provider != entry.manifest.model.provider
|| disk_manifest.model.model != entry.manifest.model.model
|| disk_manifest.capabilities.tools != entry.manifest.capabilities.tools;
if changed {
info!(
agent = %name,
"Agent TOML on disk differs from DB, updating"
);
entry.manifest = disk_manifest;
// Persist the update back to DB
if let Err(e) = kernel.memory.save_agent(&entry) {
warn!(
agent = %name,
"Failed to persist TOML update: {e}"
);
}
}
}
Err(e) => {
warn!(
agent = %name,
path = %toml_path.display(),
"Invalid agent TOML on disk, using DB version: {e}"
);
}
}
}
Err(e) => {
warn!(
agent = %name,
"Failed to read agent TOML: {e}"
);
}
}
}
// Re-grant capabilities
let caps = manifest_to_capabilities(&entry.manifest);
kernel.capabilities.grant(agent_id, caps);
@@ -924,14 +989,18 @@ impl OpenFangKernel {
&& restored_entry.manifest.model.base_url.is_none()
{
let dm = &kernel.config.default_model;
if !dm.provider.is_empty() {
restored_entry.manifest.model.provider = dm.provider.clone();
}
if !dm.model.is_empty() {
restored_entry.manifest.model.model = dm.model.clone();
}
if dm.base_url.is_some() {
restored_entry.manifest.model.base_url = dm.base_url.clone();
let is_default_provider = restored_entry.manifest.model.provider.is_empty();
let is_default_model = restored_entry.manifest.model.model.is_empty();
if is_default_provider && is_default_model {
if !dm.provider.is_empty() {
restored_entry.manifest.model.provider = dm.provider.clone();
}
if !dm.model.is_empty() {
restored_entry.manifest.model.model = dm.model.clone();
}
if dm.base_url.is_some() {
restored_entry.manifest.model.base_url = dm.base_url.clone();
}
}
}
@@ -998,18 +1067,30 @@ impl OpenFangKernel {
}
info!(agent = %name, id = %agent_id, exec_mode = ?manifest.exec_policy.as_ref().map(|p| &p.mode), "Agent exec_policy resolved");
// Overlay kernel default_model onto agent if no custom key/url is set.
// This ensures agents respect the user's configured provider from `openfang init`.
// Overlay kernel default_model onto agent if agent didn't explicitly choose.
// Only override when the agent has empty (unset) provider/model fields.
// This preserves explicit model choices like provider="groq", model="llama-3.3-70b".
if manifest.model.api_key_env.is_none() && manifest.model.base_url.is_none() {
let dm = &self.config.default_model;
if !dm.provider.is_empty() {
manifest.model.provider = dm.provider.clone();
}
if !dm.model.is_empty() {
manifest.model.model = dm.model.clone();
}
if dm.base_url.is_some() {
manifest.model.base_url = dm.base_url.clone();
// Check hot-reloaded override first, fall back to boot-time config
let override_guard = self
.default_model_override
.read()
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
let dm = override_guard
.as_ref()
.unwrap_or(&self.config.default_model);
let is_default_provider = manifest.model.provider.is_empty();
let is_default_model = manifest.model.model.is_empty();
if is_default_provider && is_default_model {
if !dm.provider.is_empty() {
manifest.model.provider = dm.provider.clone();
}
if !dm.model.is_empty() {
manifest.model.model = dm.model.clone();
}
if dm.base_url.is_some() {
manifest.model.base_url = dm.base_url.clone();
}
}
}
@@ -1448,6 +1529,7 @@ impl OpenFangKernel {
None
},
peer_agents,
current_date: Some(chrono::Local::now().format("%A, %B %d, %Y (%Y-%m-%d %H:%M %Z)").to_string()),
};
manifest.model.system_prompt =
openfang_runtime::prompt_builder::build_system_prompt(&prompt_ctx);
@@ -1916,6 +1998,7 @@ impl OpenFangKernel {
None
},
peer_agents,
current_date: Some(chrono::Local::now().format("%A, %B %d, %Y (%Y-%m-%d %H:%M %Z)").to_string()),
};
manifest.model.system_prompt =
openfang_runtime::prompt_builder::build_system_prompt(&prompt_ctx);
@@ -2937,6 +3020,17 @@ impl OpenFangKernel {
.unwrap_or_else(|e| e.into_inner());
catalog.apply_url_overrides(&new_config.provider_urls);
}
HotAction::UpdateDefaultModel => {
info!(
"Hot-reload: updating default model to {}/{}",
new_config.default_model.provider, new_config.default_model.model
);
let mut guard = self
.default_model_override
.write()
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
*guard = Some(new_config.default_model.clone());
}
_ => {
// Other hot actions (channels, web, browser, extensions, etc.)
// are logged but not applied here — they require subsystem-specific
@@ -3091,18 +3185,30 @@ impl OpenFangKernel {
/// `Continuous`, `Periodic`, or `Proactive` schedules.
pub fn start_background_agents(self: &Arc<Self>) {
let agents = self.registry.list();
let mut started = 0u32;
let mut bg_agents: Vec<(openfang_types::agent::AgentId, String, ScheduleMode)> =
Vec::new();
for entry in &agents {
if matches!(entry.manifest.schedule, ScheduleMode::Reactive) {
continue;
}
self.start_background_for_agent(entry.id, &entry.name, &entry.manifest.schedule);
started += 1;
bg_agents.push((entry.id, entry.name.clone(), entry.manifest.schedule.clone()));
}
if started > 0 {
info!("Started {started} background agent loop(s)");
if !bg_agents.is_empty() {
let count = bg_agents.len();
let kernel = Arc::clone(self);
// Stagger agent startup to prevent rate-limit storm on shared providers.
// Each agent gets a 500ms delay before the next one starts.
tokio::spawn(async move {
for (i, (id, name, schedule)) in bg_agents.into_iter().enumerate() {
kernel.start_background_for_agent(id, &name, &schedule);
if i > 0 {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
}
info!("Started {count} background agent loop(s) (staggered)");
});
}
// Start heartbeat monitor for agent health checking
@@ -3293,9 +3399,10 @@ impl OpenFangKernel {
let timeout_s = timeout_secs.unwrap_or(120);
let timeout = std::time::Duration::from_secs(timeout_s);
let delivery = job.delivery.clone();
let kh: std::sync::Arc<dyn openfang_runtime::kernel_handle::KernelHandle> = kernel.clone();
match tokio::time::timeout(
timeout,
kernel.send_message(agent_id, message),
kernel.send_message_with_handle(agent_id, message, Some(kh)),
)
.await
{
@@ -3663,10 +3770,14 @@ impl OpenFangKernel {
let base_url = if has_custom_url {
manifest.model.base_url.clone()
} else if agent_provider == default_provider {
self.config.default_model.base_url.clone()
self.config
.default_model
.base_url
.clone()
.or_else(|| self.config.provider_urls.get(agent_provider.as_str()).cloned())
} else {
// Let create_driver() use the target provider's default base URL
None
// Check provider_urls before falling back to hardcoded defaults
self.config.provider_urls.get(agent_provider.as_str()).cloned()
};
let driver_config = DriverConfig {
@@ -3682,7 +3793,9 @@ impl OpenFangKernel {
// If fallback models are configured, wrap in FallbackDriver
if !manifest.fallback_models.is_empty() {
let mut chain = vec![primary.clone()];
// Primary driver uses the agent's own model name (already set in request)
let mut chain: Vec<(std::sync::Arc<dyn openfang_runtime::llm_driver::LlmDriver>, String)> =
vec![(primary.clone(), String::new())];
for fb in &manifest.fallback_models {
let config = DriverConfig {
provider: fb.provider.clone(),
@@ -3690,10 +3803,13 @@ impl OpenFangKernel {
.api_key_env
.as_ref()
.and_then(|env| std::env::var(env).ok()),
base_url: fb.base_url.clone(),
base_url: fb
.base_url
.clone()
.or_else(|| self.config.provider_urls.get(&fb.provider).cloned()),
};
match drivers::create_driver(&config) {
Ok(d) => chain.push(d),
Ok(d) => chain.push((d, fb.model.clone())),
Err(e) => {
warn!("Fallback driver '{}' failed to init: {e}", fb.provider);
}
@@ -3701,7 +3817,7 @@ impl OpenFangKernel {
}
if chain.len() > 1 {
return Ok(Arc::new(
openfang_runtime::drivers::fallback::FallbackDriver::new(chain),
openfang_runtime::drivers::fallback::FallbackDriver::with_models(chain),
));
}
}
+37 -3
View File
@@ -145,7 +145,14 @@ pub fn apply_context_guard(
let mut compacted = 0;
for loc in &locations {
if loc.char_len > single_max {
// Bounds check: indices may be stale if messages were modified concurrently
if loc.msg_idx >= messages.len() {
continue;
}
if let MessageContent::Blocks(blocks) = &mut messages[loc.msg_idx].content {
if loc.block_idx >= blocks.len() {
continue;
}
if let ContentBlock::ToolResult { content, .. } = &mut blocks[loc.block_idx] {
let old_len = content.len();
*content = truncate_to(content, single_max);
@@ -167,7 +174,13 @@ pub fn apply_context_guard(
if loc.char_len <= compact_target {
continue;
}
if loc.msg_idx >= messages.len() {
continue;
}
if let MessageContent::Blocks(blocks) = &mut messages[loc.msg_idx].content {
if loc.block_idx >= blocks.len() {
continue;
}
if let ContentBlock::ToolResult { content, .. } = &mut blocks[loc.block_idx] {
if content.len() > compact_target {
let old_len = content.len();
@@ -188,11 +201,32 @@ fn truncate_to(content: &str, max_chars: usize) -> String {
if content.len() <= max_chars {
return content.to_string();
}
let keep = max_chars.saturating_sub(80);
let keep = max_chars.saturating_sub(80).min(content.len());
// Ensure keep is a valid char boundary
let keep = if content.is_char_boundary(keep) {
keep
} else {
content[..keep]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0)
};
let search_start = keep.saturating_sub(100);
// Ensure search_start is a valid char boundary
let search_start = if content.is_char_boundary(search_start) {
search_start
} else {
content[..search_start]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0)
};
// Try to break at newline
let break_point = content[keep.saturating_sub(100)..keep]
let break_point = content[search_start..keep]
.rfind('\n')
.map(|pos| keep.saturating_sub(100) + pos)
.map(|pos| search_start + pos)
.unwrap_or(keep);
format!(
"{}\n\n[COMPACTED: {} → {} chars by context guard]",
@@ -365,10 +365,10 @@ impl LlmDriver for AnthropicDriver {
let mut event_type = String::new();
let mut data = String::new();
for line in event_text.lines() {
if let Some(et) = line.strip_prefix("event: ") {
event_type = et.to_string();
} else if let Some(d) = line.strip_prefix("data: ") {
data = d.to_string();
if let Some(et) = line.strip_prefix("event:") {
event_type = et.trim_start().to_string();
} else if let Some(d) = line.strip_prefix("data:") {
data = d.trim_start().to_string();
}
}
@@ -12,15 +12,23 @@ use tracing::warn;
///
/// On failure (including rate-limit and overload), moves to the next driver.
/// Only returns an error when ALL drivers in the chain are exhausted.
/// Each driver is paired with the model name it should use.
pub struct FallbackDriver {
drivers: Vec<Arc<dyn LlmDriver>>,
drivers: Vec<(Arc<dyn LlmDriver>, String)>,
}
impl FallbackDriver {
/// Create a new fallback driver from an ordered chain of drivers.
/// Create a new fallback driver from an ordered chain of (driver, model_name) pairs.
///
/// The first driver is the primary; subsequent are fallbacks.
/// The first entry is the primary; subsequent are fallbacks.
pub fn new(drivers: Vec<Arc<dyn LlmDriver>>) -> Self {
Self {
drivers: drivers.into_iter().map(|d| (d, String::new())).collect(),
}
}
/// Create a new fallback driver with explicit model names for each driver.
pub fn with_models(drivers: Vec<(Arc<dyn LlmDriver>, String)>) -> Self {
Self { drivers }
}
}
@@ -30,12 +38,17 @@ impl LlmDriver for FallbackDriver {
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
let mut last_error = None;
for (i, driver) in self.drivers.iter().enumerate() {
match driver.complete(request.clone()).await {
for (i, (driver, model_name)) in self.drivers.iter().enumerate() {
let mut req = request.clone();
if !model_name.is_empty() {
req.model = model_name.clone();
}
match driver.complete(req).await {
Ok(response) => return Ok(response),
Err(e @ LlmError::RateLimited { .. }) | Err(e @ LlmError::Overloaded { .. }) => {
warn!(
driver_index = i,
model = %model_name,
error = %e,
"Driver rate-limited/overloaded, trying next fallback"
);
@@ -44,6 +57,7 @@ impl LlmDriver for FallbackDriver {
Err(e) => {
warn!(
driver_index = i,
model = %model_name,
error = %e,
"Fallback driver failed, trying next"
);
@@ -65,12 +79,17 @@ impl LlmDriver for FallbackDriver {
) -> Result<CompletionResponse, LlmError> {
let mut last_error = None;
for (i, driver) in self.drivers.iter().enumerate() {
match driver.stream(request.clone(), tx.clone()).await {
for (i, (driver, model_name)) in self.drivers.iter().enumerate() {
let mut req = request.clone();
if !model_name.is_empty() {
req.model = model_name.clone();
}
match driver.stream(req, tx.clone()).await {
Ok(response) => return Ok(response),
Err(e @ LlmError::RateLimited { .. }) | Err(e @ LlmError::Overloaded { .. }) => {
warn!(
driver_index = i,
model = %model_name,
error = %e,
"Driver rate-limited/overloaded (stream), trying next fallback"
);
@@ -79,6 +98,7 @@ impl LlmDriver for FallbackDriver {
Err(e) => {
warn!(
driver_index = i,
model = %model_name,
error = %e,
"Fallback driver (stream) failed, trying next"
);
@@ -525,10 +525,10 @@ impl LlmDriver for GeminiDriver {
let event_text = buffer[..pos].to_string();
buffer = buffer[pos + 2..].to_string();
// Extract the data line
// Extract the data line (handle both "data: " and "data:" formats)
let data = event_text
.lines()
.find_map(|line| line.strip_prefix("data: "))
.find_map(|line| line.strip_prefix("data:").map(|d| d.trim_start()))
.unwrap_or("");
if data.is_empty() {
+78 -20
View File
@@ -33,7 +33,12 @@ impl OpenAIDriver {
struct OaiRequest {
model: String,
messages: Vec<OaiMessage>,
max_tokens: u32,
/// Classic token limit field (used by most models).
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
/// New token limit field required by GPT-5 and o-series reasoning models.
#[serde(skip_serializing_if = "Option::is_none")]
max_completion_tokens: Option<u32>,
temperature: f32,
#[serde(skip_serializing_if = "Vec::is_empty")]
tools: Vec<OaiTool>,
@@ -43,6 +48,16 @@ struct OaiRequest {
stream: bool,
}
/// Returns true if a model uses `max_completion_tokens` instead of `max_tokens`.
fn uses_completion_tokens(model: &str) -> bool {
let m = model.to_lowercase();
m.starts_with("gpt-5")
|| m.starts_with("gpt5")
|| m.starts_with("o1")
|| m.starts_with("o3")
|| m.starts_with("o4")
}
#[derive(Debug, Serialize)]
struct OaiMessage {
role: String,
@@ -276,10 +291,16 @@ impl LlmDriver for OpenAIDriver {
Some(serde_json::json!("auto"))
};
let (mt, mct) = if uses_completion_tokens(&request.model) {
(None, Some(request.max_tokens))
} else {
(Some(request.max_tokens), None)
};
let mut oai_request = OaiRequest {
model: request.model.clone(),
messages: oai_messages,
max_tokens: request.max_tokens,
max_tokens: mt,
max_completion_tokens: mct,
temperature: request.temperature,
tools: oai_tools,
tool_choice,
@@ -339,16 +360,31 @@ impl LlmDriver for OpenAIDriver {
}
}
// GPT-5 / o-series: switch from max_tokens to max_completion_tokens
if status == 400
&& body.contains("max_tokens")
&& (body.contains("unsupported_parameter")
|| body.contains("max_completion_tokens"))
&& oai_request.max_tokens.is_some()
&& attempt < max_retries
{
let val = oai_request.max_tokens.unwrap();
warn!(model = %oai_request.model, "Switching to max_completion_tokens for this model");
oai_request.max_tokens = None;
oai_request.max_completion_tokens = Some(val);
continue;
}
// Auto-cap max_tokens when model rejects our value (e.g. Groq Maverick limit 8192)
if status == 400 && body.contains("max_tokens") && attempt < max_retries {
// Extract the limit from error: "must be less than or equal to `8192`"
let cap = extract_max_tokens_limit(&body).unwrap_or(oai_request.max_tokens / 2);
warn!(
old = oai_request.max_tokens,
new = cap,
"Auto-capping max_tokens to model limit"
);
oai_request.max_tokens = cap;
let current = oai_request.max_tokens.or(oai_request.max_completion_tokens).unwrap_or(4096);
let cap = extract_max_tokens_limit(&body).unwrap_or(current / 2);
warn!(old = current, new = cap, "Auto-capping max_tokens to model limit");
if oai_request.max_completion_tokens.is_some() {
oai_request.max_completion_tokens = Some(cap);
} else {
oai_request.max_tokens = Some(cap);
}
continue;
}
@@ -555,10 +591,16 @@ impl LlmDriver for OpenAIDriver {
Some(serde_json::json!("auto"))
};
let (mt, mct) = if uses_completion_tokens(&request.model) {
(None, Some(request.max_tokens))
} else {
(Some(request.max_tokens), None)
};
let mut oai_request = OaiRequest {
model: request.model.clone(),
messages: oai_messages,
max_tokens: request.max_tokens,
max_tokens: mt,
max_completion_tokens: mct,
temperature: request.temperature,
tools: oai_tools,
tool_choice,
@@ -620,15 +662,31 @@ impl LlmDriver for OpenAIDriver {
}
}
// GPT-5 / o-series: switch from max_tokens to max_completion_tokens
if status == 400
&& body.contains("max_tokens")
&& (body.contains("unsupported_parameter")
|| body.contains("max_completion_tokens"))
&& oai_request.max_tokens.is_some()
&& attempt < max_retries
{
let val = oai_request.max_tokens.unwrap();
warn!(model = %oai_request.model, "Switching to max_completion_tokens for this model (stream)");
oai_request.max_tokens = None;
oai_request.max_completion_tokens = Some(val);
continue;
}
// Auto-cap max_tokens when model rejects our value
if status == 400 && body.contains("max_tokens") && attempt < max_retries {
let cap = extract_max_tokens_limit(&body).unwrap_or(oai_request.max_tokens / 2);
warn!(
old = oai_request.max_tokens,
new = cap,
"Auto-capping max_tokens (stream)"
);
oai_request.max_tokens = cap;
let current = oai_request.max_tokens.or(oai_request.max_completion_tokens).unwrap_or(4096);
let cap = extract_max_tokens_limit(&body).unwrap_or(current / 2);
warn!(old = current, new = cap, "Auto-capping max_tokens (stream)");
if oai_request.max_completion_tokens.is_some() {
oai_request.max_completion_tokens = Some(cap);
} else {
oai_request.max_tokens = Some(cap);
}
continue;
}
@@ -660,8 +718,8 @@ impl LlmDriver for OpenAIDriver {
continue;
}
let data = match line.strip_prefix("data: ") {
Some(d) => d,
let data = match line.strip_prefix("data:") {
Some(d) => d.trim_start(),
None => continue,
};
+58 -3
View File
@@ -32,7 +32,7 @@ pub struct McpServerConfig {
}
fn default_timeout() -> u64 {
30
60
}
/// Transport type for MCP server connections.
@@ -393,7 +393,31 @@ impl McpConnection {
return Err("MCP command path contains '..': rejected".to_string());
}
let mut cmd = tokio::process::Command::new(command);
// On Windows, npm/npx install as .cmd batch wrappers. Detect and adapt.
let resolved_command: String = if cfg!(windows) {
// If the user already specified .cmd/.bat, use as-is
if command.ends_with(".cmd") || command.ends_with(".bat") {
command.to_string()
} else {
// Check if the .cmd variant exists on PATH
let cmd_variant = format!("{command}.cmd");
let has_cmd = std::env::var("PATH")
.unwrap_or_default()
.split(';')
.any(|dir| {
std::path::Path::new(dir).join(&cmd_variant).exists()
});
if has_cmd {
cmd_variant
} else {
command.to_string()
}
}
} else {
command.to_string()
};
let mut cmd = tokio::process::Command::new(&resolved_command);
cmd.args(args);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
@@ -410,10 +434,41 @@ impl McpConnection {
if let Ok(path) = std::env::var("PATH") {
cmd.env("PATH", path);
}
// On Windows, npm/node need APPDATA, USERPROFILE, LOCALAPPDATA, and SystemRoot
if cfg!(windows) {
for var in &[
"APPDATA",
"LOCALAPPDATA",
"USERPROFILE",
"SystemRoot",
"TEMP",
"TMP",
"HOME",
"HOMEDRIVE",
"HOMEPATH",
] {
if let Ok(val) = std::env::var(var) {
cmd.env(var, val);
}
}
}
let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to spawn MCP server '{command}': {e}"))?;
.map_err(|e| format!("Failed to spawn MCP server '{resolved_command}': {e}"))?;
// Log stderr in background for debugging MCP server issues
if let Some(stderr) = child.stderr.take() {
let cmd_name = resolved_command.clone();
tokio::spawn(async move {
use tokio::io::AsyncBufReadExt;
let reader = tokio::io::BufReader::new(stderr);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::debug!(mcp_server = %cmd_name, "stderr: {line}");
}
});
}
let stdin = child
.stdin
+3 -16
View File
@@ -647,8 +647,8 @@ fn builtin_aliases() -> HashMap<String, String> {
("gpt4-mini", "gpt-4o-mini"),
("gpt5", "gpt-5.2"),
("gpt5-mini", "gpt-5-mini"),
("flash", "gemini-3-flash"),
("gemini-flash", "gemini-3-flash"),
("flash", "gemini-2.5-flash"),
("gemini-flash", "gemini-2.5-flash"),
("gemini-pro", "gemini-3.1-pro"),
("deepseek", "deepseek-chat"),
("llama", "llama-3.3-70b-versatile"),
@@ -1052,20 +1052,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_streaming: true,
aliases: vec!["gemini-pro".into()],
},
ModelCatalogEntry {
id: "gemini-3-flash".into(),
display_name: "Gemini 3 Flash".into(),
provider: "gemini".into(),
tier: ModelTier::Smart,
context_window: 1_048_576,
max_output_tokens: 65_536,
input_cost_per_m: 0.50,
output_cost_per_m: 3.0,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["flash".into(), "gemini-flash".into()],
},
// gemini-3-flash removed: model doesn't exist. Use gemini-2.5-flash instead.
ModelCatalogEntry {
id: "gemini-3-deep-think".into(),
display_name: "Gemini 3 Deep Think".into(),
@@ -53,6 +53,8 @@ pub struct PromptContext {
pub heartbeat_md: Option<String>,
/// Peer agents visible to this agent: (name, state, model).
pub peer_agents: Vec<(String, String, String)>,
/// Current date/time string for temporal awareness.
pub current_date: Option<String>,
}
/// Build the complete system prompt from a `PromptContext`.
@@ -66,6 +68,11 @@ pub fn build_system_prompt(ctx: &PromptContext) -> String {
// Section 1 — Agent Identity (always present)
sections.push(build_identity_section(ctx));
// Section 1.5 — Current Date/Time (always present when set)
if let Some(ref date) = ctx.current_date {
sections.push(format!("## Current Date\nToday is {date}."));
}
// Section 2 — Tool Call Behavior (skip for subagents)
if !ctx.is_subagent {
sections.push(TOOL_CALL_BEHAVIOR.to_string());
@@ -208,6 +215,9 @@ const TOOL_CALL_BEHAVIOR: &str = "\
- Prefer action over narration. If you can answer by using a tool, do it.
- When executing multiple sequential tool calls, batch them don't output reasoning between each call.
- If a tool returns useful results, present the KEY information, not the raw output.
- When web_fetch or web_search returns content, you MUST include the relevant data in your response. \
Quote specific facts, numbers, or passages from the fetched content. Never say you fetched something \
without sharing what you found.
- Start with the answer, not meta-commentary about how you'll help.
- IMPORTANT: If your instructions or persona mention a shell command, script path, or code snippet, \
execute it via the appropriate tool call (shell_exec, file_write, etc.). Never output commands as \
+9 -3
View File
@@ -187,8 +187,11 @@ pub async fn execute_tool(
is_error: true,
};
}
let method = input["method"].as_str().unwrap_or("GET");
let headers = input.get("headers").and_then(|v| v.as_object());
let body = input["body"].as_str();
if let Some(ctx) = web_ctx {
ctx.fetch.fetch(url).await
ctx.fetch.fetch_with_options(url, method, headers, body).await
} else {
tool_web_fetch_legacy(input).await
}
@@ -533,11 +536,14 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
// --- Web tools ---
ToolDefinition {
name: "web_fetch".to_string(),
description: "Fetch a web page and extract its content as Markdown. Includes SSRF protection and result caching.".to_string(),
description: "Fetch a URL with SSRF protection. Supports GET/POST/PUT/PATCH/DELETE. For GET, HTML is converted to Markdown. For other methods, returns raw response body.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"url": { "type": "string", "description": "The URL to fetch (http/https only)" }
"url": { "type": "string", "description": "The URL to fetch (http/https only)" },
"method": { "type": "string", "enum": ["GET","POST","PUT","PATCH","DELETE"], "description": "HTTP method (default: GET)" },
"headers": { "type": "object", "description": "Custom HTTP headers as key-value pairs" },
"body": { "type": "string", "description": "Request body for POST/PUT/PATCH" }
},
"required": ["url"]
}),
+64 -20
View File
@@ -32,23 +32,62 @@ impl WebFetchEngine {
}
}
/// Fetch a URL with full security pipeline.
/// Fetch a URL with full security pipeline (GET only, for backwards compat).
pub async fn fetch(&self, url: &str) -> Result<String, String> {
self.fetch_with_options(url, "GET", None, None).await
}
/// Fetch a URL with configurable HTTP method, headers, and body.
pub async fn fetch_with_options(
&self,
url: &str,
method: &str,
headers: Option<&serde_json::Map<String, serde_json::Value>>,
body: Option<&str>,
) -> Result<String, String> {
let method_upper = method.to_uppercase();
// Step 1: SSRF protection — BEFORE any network I/O
check_ssrf(url)?;
// Step 2: Cache lookup
let cache_key = format!("fetch:{}", url);
if let Some(cached) = self.cache.get(&cache_key) {
debug!(url, "Fetch cache hit");
return Ok(cached);
// Step 2: Cache lookup (only for GET)
let cache_key = format!("fetch:{}:{}", method_upper, url);
if method_upper == "GET" {
if let Some(cached) = self.cache.get(&cache_key) {
debug!(url, "Fetch cache hit");
return Ok(cached);
}
}
// Step 3: HTTP GET
let resp = self
.client
.get(url)
.header("User-Agent", "Mozilla/5.0 (compatible; OpenFangAgent/0.1)")
// Step 3: Build request with configured method
let mut req = match method_upper.as_str() {
"POST" => self.client.post(url),
"PUT" => self.client.put(url),
"PATCH" => self.client.patch(url),
"DELETE" => self.client.delete(url),
_ => self.client.get(url),
};
req = req.header("User-Agent", "Mozilla/5.0 (compatible; OpenFangAgent/0.1)");
// Add custom headers
if let Some(hdrs) = headers {
for (k, v) in hdrs {
if let Some(val) = v.as_str() {
req = req.header(k.as_str(), val);
}
}
}
// Add body for non-GET methods
if let Some(b) = body {
// Auto-detect JSON body
if b.trim_start().starts_with('{') || b.trim_start().starts_with('[') {
req = req.header("Content-Type", "application/json");
}
req = req.body(b.to_string());
}
let resp = req
.send()
.await
.map_err(|e| format!("HTTP request failed: {e}"))?;
@@ -72,22 +111,25 @@ impl WebFetchEngine {
.unwrap_or("")
.to_string();
let body = resp
let resp_body = resp
.text()
.await
.map_err(|e| format!("Failed to read response body: {e}"))?;
// Step 4: Detect HTML and optionally convert to Markdown
let processed = if self.config.readability && is_html(&content_type, &body) {
let markdown = html_to_markdown(&body);
// Step 4: For GET requests, detect HTML and convert to Markdown.
// For non-GET (API calls), return raw body — don't mangle JSON/XML responses.
let processed = if method_upper == "GET"
&& self.config.readability
&& is_html(&content_type, &resp_body)
{
let markdown = html_to_markdown(&resp_body);
if markdown.trim().is_empty() {
// Fallback to raw text if extraction produced nothing
body
resp_body
} else {
markdown
}
} else {
body
resp_body
};
// Step 5: Truncate
@@ -107,8 +149,10 @@ impl WebFetchEngine {
wrap_external_content(url, &truncated)
);
// Step 7: Cache
self.cache.put(cache_key, result.clone());
// Step 7: Cache (only GET responses)
if method_upper == "GET" {
self.cache.put(cache_key, result.clone());
}
Ok(result)
}
+8 -4
View File
@@ -746,11 +746,14 @@ impl Default for CanvasConfig {
#[serde(rename_all = "lowercase")]
pub enum ExecSecurityMode {
/// Block all shell execution.
#[serde(alias = "none", alias = "disabled")]
Deny,
/// Only allow commands in safe_bins or allowed_commands.
#[default]
#[serde(alias = "restricted")]
Allowlist,
/// Allow all commands (unsafe, dev only).
#[serde(alias = "allow", alias = "all", alias = "unrestricted")]
Full,
}
@@ -1549,10 +1552,11 @@ pub struct DiscordConfig {
/// Env var name holding the bot token (NOT the token itself).
pub bot_token_env: String,
/// Guild (server) IDs allowed to interact (empty = allow all).
pub allowed_guilds: Vec<u64>,
/// Accepts strings for consistency with other channel configs.
pub allowed_guilds: Vec<String>,
/// Default agent name to route messages to.
pub default_agent: Option<String>,
/// Gateway intents bitmask (default: 33280 = GUILD_MESSAGES | MESSAGE_CONTENT).
/// Gateway intents bitmask (default: 37376 = GUILD_MESSAGES | DIRECT_MESSAGES | MESSAGE_CONTENT).
pub intents: u64,
/// Per-channel behavior overrides.
#[serde(default)]
@@ -1565,7 +1569,7 @@ impl Default for DiscordConfig {
bot_token_env: "DISCORD_BOT_TOKEN".to_string(),
allowed_guilds: vec![],
default_agent: None,
intents: 33280,
intents: 37376,
overrides: ChannelOverrides::default(),
}
}
@@ -3222,7 +3226,7 @@ mod tests {
let dc = DiscordConfig::default();
assert_eq!(dc.bot_token_env, "DISCORD_BOT_TOKEN");
assert!(dc.allowed_guilds.is_empty());
assert_eq!(dc.intents, 33280);
assert_eq!(dc.intents, 37376);
}
#[test]