Compare commits

...
10 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
jaberjaber23 6f3c4e7778 batch fixes 2026-03-02 05:08:42 +03:00
jaberjaber23 73ad49a3a1 batch fixes 2026-03-02 03:31:59 +03:00
jaberjaber23 7ec0e024c7 v0.2.5 release 2026-03-01 22:27:42 +03:00
jaberjaber23 7c81c187c4 batch fixes 2026-03-01 20:38:12 +03:00
jaberjaber23 7ae80b1b9f batch fixes 2026-03-01 04:16:17 +03:00
63 changed files with 6375 additions and 800 deletions
Generated
+15 -14
View File
@@ -3866,7 +3866,7 @@ dependencies = [
[[package]]
name = "openfang-api"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"async-trait",
"axum",
@@ -3902,7 +3902,7 @@ dependencies = [
[[package]]
name = "openfang-channels"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"async-trait",
"axum",
@@ -3933,7 +3933,7 @@ dependencies = [
[[package]]
name = "openfang-cli"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"clap",
"clap_complete",
@@ -3960,7 +3960,7 @@ dependencies = [
[[package]]
name = "openfang-desktop"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"axum",
"open",
@@ -3986,7 +3986,7 @@ dependencies = [
[[package]]
name = "openfang-extensions"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"aes-gcm",
"argon2",
@@ -4014,7 +4014,7 @@ dependencies = [
[[package]]
name = "openfang-hands"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"chrono",
"dashmap",
@@ -4031,7 +4031,7 @@ dependencies = [
[[package]]
name = "openfang-kernel"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"async-trait",
"chrono",
@@ -4067,7 +4067,7 @@ dependencies = [
[[package]]
name = "openfang-memory"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"async-trait",
"chrono",
@@ -4086,7 +4086,7 @@ dependencies = [
[[package]]
name = "openfang-migrate"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -4105,7 +4105,7 @@ dependencies = [
[[package]]
name = "openfang-runtime"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"anyhow",
"async-trait",
@@ -4128,6 +4128,7 @@ dependencies = [
"tokio",
"tokio-stream",
"tokio-test",
"tokio-tungstenite 0.24.0",
"tracing",
"uuid",
"wasmtime",
@@ -4136,7 +4137,7 @@ dependencies = [
[[package]]
name = "openfang-skills"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"chrono",
"hex",
@@ -4158,7 +4159,7 @@ dependencies = [
[[package]]
name = "openfang-types"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"async-trait",
"chrono",
@@ -4177,7 +4178,7 @@ dependencies = [
[[package]]
name = "openfang-wire"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"async-trait",
"chrono",
@@ -8789,7 +8790,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xtask"
version = "0.2.1"
version = "0.3.0"
[[package]]
name = "yoke"
+1 -1
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.2.2"
version = "0.3.2"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
+18 -2
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"
@@ -103,26 +107,38 @@ pub async fn auth(
|| path == "/api/approvals"
|| path.starts_with("/api/approvals/")
|| path == "/api/channels"
|| path == "/api/hands"
|| path == "/api/hands/active"
|| path.starts_with("/api/hands/")
|| path == "/api/skills"
|| path == "/api/sessions"
|| path == "/api/integrations"
|| path == "/api/integrations/available"
|| path == "/api/integrations/health"
|| path == "/api/workflows"
|| path == "/api/logs/stream"
|| path.starts_with("/api/cron/")
|| path.starts_with("/api/providers/github-copilot/oauth/")
{
return next.run(request).await;
}
// Check Authorization: Bearer <token> header
// Check Authorization: Bearer <token> header, then fallback to X-API-Key
let bearer_token = request
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
let api_token = bearer_token.or_else(|| {
request
.headers()
.get("x-api-key")
.and_then(|v| v.to_str().ok())
});
// SECURITY: Use constant-time comparison to prevent timing attacks.
let header_auth = bearer_token.map(|token| {
let header_auth = api_token.map(|token| {
use subtle::ConstantTimeEq;
if token.len() != api_key.len() {
return false;
File diff suppressed because it is too large Load Diff
+45 -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
@@ -156,6 +157,10 @@ pub async fn build_router(
"/api/agents/{id}/session/reset",
axum::routing::post(routes::reset_session),
)
.route(
"/api/agents/{id}/history",
axum::routing::delete(routes::clear_agent_history),
)
.route(
"/api/agents/{id}/session/compact",
axum::routing::post(routes::compact_session),
@@ -168,6 +173,10 @@ pub async fn build_router(
"/api/agents/{id}/model",
axum::routing::put(routes::set_model),
)
.route(
"/api/agents/{id}/tools",
axum::routing::get(routes::get_agent_tools).put(routes::set_agent_tools),
)
.route(
"/api/agents/{id}/skills",
axum::routing::get(routes::get_agent_skills).put(routes::set_agent_skills),
@@ -335,6 +344,11 @@ pub async fn build_router(
"/api/hands/{hand_id}/install-deps",
axum::routing::post(routes::install_hand_deps),
)
.route(
"/api/hands/{hand_id}/settings",
axum::routing::get(routes::get_hand_settings)
.put(routes::update_hand_settings),
)
.route(
"/api/hands/instances/{id}/pause",
axum::routing::post(routes::pause_hand),
@@ -377,6 +391,27 @@ pub async fn build_router(
"/api/network/status",
axum::routing::get(routes::network_status),
)
// Agent communication (Comms) endpoints
.route(
"/api/comms/topology",
axum::routing::get(routes::comms_topology),
)
.route(
"/api/comms/events",
axum::routing::get(routes::comms_events),
)
.route(
"/api/comms/events/stream",
axum::routing::get(routes::comms_events_stream),
)
.route(
"/api/comms/send",
axum::routing::post(routes::comms_send),
)
.route(
"/api/comms/task",
axum::routing::post(routes::comms_task),
)
// Tools endpoint
.route("/api/tools", axum::routing::get(routes::list_tools))
// Config endpoints
@@ -421,7 +456,8 @@ pub async fn build_router(
)
.route(
"/api/budget/agents/{id}",
axum::routing::get(routes::agent_budget_status),
axum::routing::get(routes::agent_budget_status)
.put(routes::update_agent_budget),
)
// Session endpoints
.route("/api/sessions", axum::routing::get(routes::list_sessions))
@@ -450,6 +486,14 @@ pub async fn build_router(
"/api/models/aliases",
axum::routing::get(routes::list_aliases),
)
.route(
"/api/models/custom",
axum::routing::post(routes::add_custom_model),
)
.route(
"/api/models/custom/{*id}",
axum::routing::delete(routes::remove_custom_model),
)
.route("/api/models/{*id}", axum::routing::get(routes::get_model))
.route("/api/providers", axum::routing::get(routes::list_providers))
// Copilot OAuth (must be before parametric {name} routes)
+8 -2
View File
@@ -102,9 +102,15 @@ impl StreamChunker {
}
}
// Priority 4: Forced break at max_chunk_chars
// Priority 4: Forced break at max_chunk_chars (char-boundary safe)
if self.buffer.len() >= self.max_chunk_chars {
let break_at = self.max_chunk_chars;
let mut break_at = self.max_chunk_chars;
while break_at > 0 && !self.buffer.is_char_boundary(break_at) {
break_at -= 1;
}
if break_at == 0 {
break_at = self.buffer.len();
}
let chunk = self.buffer[..break_at].to_string();
self.buffer = self.buffer[break_at..].to_string();
return Some(chunk);
+2
View File
@@ -123,6 +123,8 @@ const WEBCHAT_HTML: &str = concat!(
include_str!("../static/js/pages/wizard.js"),
"\n",
include_str!("../static/js/pages/approvals.js"),
"\n",
include_str!("../static/js/pages/comms.js"),
"\n</script>\n",
// Alpine.js MUST be last — it processes x-data and fires alpine:init
"<script>\n",
+13 -1
View File
@@ -3072,4 +3072,16 @@ mark.search-highlight {
max-height: 400px;
overflow-y: auto;
}
.flex-col { flex-direction: column; }
/* Comms page */
.comms-topo-tree { padding: 4px 0 4px 8px; }
.comms-topo-child { padding: 0 0 0 20px; display: flex; align-items: center; gap: 4px; }
.comms-topo-branch { color: var(--text-dim); font-family: var(--font-mono); white-space: pre; }
.comms-topo-node { display: flex; align-items: center; gap: 4px; padding: 2px 0; }
.comms-event-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 12px; border-bottom: 1px solid var(--border);
font-size: 12px; transition: background var(--transition-fast);
}
.comms-event-row:hover { background: var(--bg-hover); }
.comms-event-time { min-width: 50px; text-align: right; }
.comms-event-detail { margin-left: auto; }
+270 -66
View File
@@ -96,6 +96,10 @@
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg></span>
<span class="nav-label">Approvals</span>
</a>
<a class="nav-item" :class="{ active: page === 'comms' }" @click="navigate('comms')" :aria-current="page === 'comms' ? 'page' : false">
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/></svg></span>
<span class="nav-label">Comms</span>
</a>
</div>
</template>
</div>
@@ -565,7 +569,7 @@
<svg width="14" height="14" viewBox="0 0 24 24" 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>
</button>
<button class="btn btn-ghost btn-sm" @click="$store.app.toggleFocusMode()" title="Ctrl+Shift+F">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><template x-if="!$store.app.focusMode"><g><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></g></template><template x-if="$store.app.focusMode"><g><path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/></g></template></svg>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g x-show="!$store.app.focusMode"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></g><g x-show="$store.app.focusMode"><path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/></g></svg>
</button>
<button class="btn btn-danger btn-sm" @click="killAgent()">Stop</button>
</div>
@@ -692,6 +696,16 @@
</div>
</template>
</div>
<!-- Model autocomplete picker -->
<div x-show="showModelPicker && filteredModelPicker.length" class="slash-menu" style="max-height:280px;overflow-y:auto">
<div class="text-xs text-dim" style="padding:4px 10px;border-bottom:1px solid var(--border)">Available models — pick one or keep typing</div>
<template x-for="(m, idx) in filteredModelPicker" :key="m.id">
<div class="slash-menu-item" :class="{ 'slash-active': idx === modelPickerIdx }" @click="pickModel(m.id)" @mouseenter="modelPickerIdx = idx">
<span class="font-bold" style="font-size:12px;font-family:var(--font-mono)" x-text="m.id"></span>
<span class="text-xs text-dim" x-text="m.provider + (m.display_name && m.display_name !== m.id ? ' · ' + m.display_name : '')"></span>
</div>
</template>
</div>
<!-- Input row -->
<div class="input-row">
<button class="btn btn-ghost btn-sm" @click="$refs.fileInput.click()" title="Attach file" style="padding:6px 8px;flex-shrink:0">
@@ -709,10 +723,10 @@
<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.shiftKey){if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
@keydown.escape="showSlashMenu = false"
@keydown.arrow-up.prevent="if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
@keydown.arrow-down.prevent="if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
@keydown.enter.prevent="if(!$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)}"
@input="$el.style.height='auto';$el.style.height=Math.min($el.scrollHeight,150)+'px'"
x-model="inputText"
:class="{ 'streaming-active': sending }"></textarea>
@@ -807,7 +821,7 @@
<div class="tabs" style="margin-bottom:16px">
<div class="tab" :class="{ active: detailTab === 'info' }" @click="detailTab = 'info'">Info</div>
<div class="tab" :class="{ active: detailTab === 'files' }" @click="detailTab = 'files'; loadAgentFiles()">Files</div>
<div class="tab" :class="{ active: detailTab === 'config' }" @click="detailTab = 'config'">Config</div>
<div class="tab" :class="{ active: detailTab === 'config' }" @click="detailTab = 'config'; loadToolFilters()">Config</div>
</div>
<!-- Tab: Info -->
@@ -822,12 +836,29 @@
</div>
<div class="detail-row" x-show="detailAgent.profile"><span class="detail-label">Profile</span><span class="detail-value" style="text-transform:capitalize" x-text="detailAgent.profile || '-'"></span></div>
<div class="detail-row"><span class="detail-label">Provider</span><span class="detail-value" x-text="detailAgent.model_provider"></span></div>
<div class="detail-row"><span class="detail-label">Model</span><span class="detail-value" x-text="detailAgent.model_name"></span></div>
<div class="detail-row"><span class="detail-label">Model</span>
<template x-if="!editingModel">
<span>
<span class="detail-value" x-text="detailAgent.model_name"></span>
<button class="btn btn-ghost btn-sm" style="margin-left:8px;padding:2px 8px;font-size:11px" @click="editingModel = true; newModelValue = detailAgent.model_provider + '/' + detailAgent.model_name">Change</button>
</span>
</template>
<template x-if="editingModel">
<span class="flex gap-1" style="align-items:center">
<input class="form-input" style="width:240px;font-size:12px" x-model="newModelValue" placeholder="provider/model" @keydown.enter="changeModel()" @keydown.escape="editingModel = false">
<button class="btn btn-primary btn-sm" @click="changeModel()" :disabled="modelSaving" style="padding:2px 10px">
<span x-show="!modelSaving">Save</span><span x-show="modelSaving">...</span>
</button>
<button class="btn btn-ghost btn-sm" @click="editingModel = false" style="padding:2px 8px">Cancel</button>
</span>
</template>
</div>
<div class="detail-row"><span class="detail-label">Created</span><span class="detail-value" x-text="detailAgent.created_at ? new Date(detailAgent.created_at).toLocaleString() : '-'"></span></div>
</div>
<div class="flex gap-2 mt-4">
<button class="btn btn-primary" @click="chatWithAgent(detailAgent); showDetailModal = false">Chat</button>
<button class="btn btn-ghost" @click="cloneAgent(detailAgent)">Clone</button>
<button class="btn btn-ghost" @click="clearHistory(detailAgent)">Clear History</button>
<button class="btn btn-danger" @click="killAgent(detailAgent)">Stop</button>
</div>
</div>
@@ -886,6 +917,36 @@
<button class="btn btn-primary mt-4" @click="saveConfig()" :disabled="configSaving">
<span x-show="!configSaving">Save Config</span><span x-show="configSaving">Saving...</span>
</button>
<!-- Tool Filters -->
<div class="mt-4" style="border-top:1px solid var(--border);padding-top:16px">
<h4 style="margin-bottom:8px;font-size:13px">Tool Filters</h4>
<p class="text-xs text-dim" style="margin-bottom:12px">Allowlist: only these tools available (empty = all). Blocklist: these tools excluded.</p>
<div class="form-group">
<label style="font-size:12px">Allowlist <span class="text-dim" x-text="'(' + toolFilters.tool_allowlist.length + ')'"></span></label>
<div class="flex flex-wrap gap-1 mb-1">
<template x-for="(t, i) in toolFilters.tool_allowlist" :key="'al-'+i">
<span class="badge" style="cursor:pointer" @click="removeAllowTool(t)" :title="'Click to remove ' + t"><span x-text="t"></span> &times;</span>
</template>
</div>
<div class="flex gap-1">
<input class="form-input" style="font-size:12px;flex:1" x-model="newAllowTool" placeholder="tool name" @keydown.enter="addAllowTool()">
<button class="btn btn-ghost btn-sm" @click="addAllowTool()">Add</button>
</div>
</div>
<div class="form-group">
<label style="font-size:12px">Blocklist <span class="text-dim" x-text="'(' + toolFilters.tool_blocklist.length + ')'"></span></label>
<div class="flex flex-wrap gap-1 mb-1">
<template x-for="(t, i) in toolFilters.tool_blocklist" :key="'bl-'+i">
<span class="badge badge-danger" style="cursor:pointer" @click="removeBlockTool(t)" :title="'Click to remove ' + t"><span x-text="t"></span> &times;</span>
</template>
</div>
<div class="flex gap-1">
<input class="form-input" style="font-size:12px;flex:1" x-model="newBlockTool" placeholder="tool name" @keydown.enter="addBlockTool()">
<button class="btn btn-ghost btn-sm" @click="addBlockTool()">Add</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -1288,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>
@@ -2401,7 +2407,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<span style="font-size:1.4rem" x-text="getHandIcon(inst.hand_id)"></span>
<div class="card-header" style="margin:0" x-text="inst.agent_name || inst.hand_id"></div>
</div>
<span class="badge" :class="inst.status === 'Active' ? 'badge-success' : inst.status === 'Paused' ? 'badge-dim' : 'badge-info'" x-text="inst.status"></span>
<span class="badge" :class="{ 'badge-success': inst.status === 'Active', 'badge-dim': inst.status === 'Paused', 'badge-warn': inst.status && inst.status.startsWith('Error'), 'badge-info': inst.status === 'Inactive' }" x-text="inst.status"></span>
</div>
<div class="text-xs text-dim" x-text="'Activated: ' + new Date(inst.activated_at).toLocaleString()"></div>
<div class="text-xs text-dim" x-show="inst.agent_id" x-text="'Agent: ' + inst.agent_id"></div>
@@ -2428,6 +2434,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<template x-if="inst.status === 'Paused'">
<button class="btn btn-ghost btn-sm" @click="resumeHand(inst)">Resume</button>
</template>
<template x-if="inst.status && inst.status.startsWith('Error')">
<span class="text-xs text-dim">Error — deactivate and reactivate</span>
</template>
<button class="btn btn-danger btn-sm" @click="deactivate(inst)">Deactivate</button>
</div>
</div>
@@ -2958,6 +2967,31 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<option :value="t" x-text="t"></option>
</template>
</select>
<button class="btn btn-primary btn-sm" @click="showCustomModelForm = !showCustomModelForm" x-text="showCustomModelForm ? 'Cancel' : '+ Custom Model'"></button>
</div>
<!-- Custom model form -->
<div x-show="showCustomModelForm" class="info-card mb-4" style="border:1px solid var(--accent,#7c3aed)">
<h4 style="margin-top:0">Add Custom Model</h4>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem">
<div>
<label class="text-xs text-dim">Model ID (required)</label>
<input class="form-input" x-model="customModelId" placeholder="e.g. my-org/my-model">
</div>
<div>
<label class="text-xs text-dim">Provider</label>
<input class="form-input" x-model="customModelProvider" placeholder="openrouter">
</div>
<div>
<label class="text-xs text-dim">Context Window</label>
<input class="form-input" type="number" x-model.number="customModelContext" placeholder="128000">
</div>
<div>
<label class="text-xs text-dim">Max Output Tokens</label>
<input class="form-input" type="number" x-model.number="customModelMaxOutput" placeholder="8192">
</div>
</div>
<button class="btn btn-primary btn-sm mt-2" @click="addCustomModel()" :disabled="!customModelId.trim()">Add Model</button>
<span class="text-xs text-dim ml-2" x-text="customModelStatus"></span>
</div>
<div class="text-xs text-dim mb-2" x-text="filteredModels.length + ' of ' + models.length + ' models'"></div>
<div class="table-wrap" x-show="filteredModels.length">
@@ -2966,7 +3000,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<tbody>
<template x-for="m in filteredModels" :key="m.id">
<tr>
<td class="font-bold" style="font-size:11px" x-text="m.display_name || m.id"></td>
<td style="font-size:11px"><div class="font-bold" x-text="m.display_name || m.id"></div><div class="text-xs text-dim" style="font-family:var(--font-mono);opacity:0.7;user-select:all" x-show="m.display_name && m.display_name !== m.id" x-text="m.id"></div></td>
<td class="text-dim" x-text="m.provider"></td>
<td><span class="tier-badge" :class="tierBadgeClass(m.tier)" x-text="m.tier || '-'"></span></td>
<td class="text-xs" x-text="formatContext(m.context_window)"></td>
@@ -3995,6 +4029,176 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
</template>
<!-- Page: Comms -->
<template x-if="page === 'comms'">
<div x-data="commsPage" x-init="loadData()" @page-leave.window="stopSSE()">
<div class="page-header">
<h2>Agent Comms</h2>
<div class="flex items-center gap-2">
<button class="btn btn-primary btn-sm" @click="openSendModal()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4z"/></svg>
Send Message
</button>
<button class="btn btn-ghost btn-sm" @click="openTaskModal()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/></svg>
Post Task
</button>
<button class="btn btn-ghost btn-sm" @click="loadData()" title="Refresh">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/></svg>
</button>
</div>
</div>
<div class="page-body">
<!-- Loading -->
<div x-show="loading" style="animation:fadeIn 0.2s">
<div class="card mb-4"><div class="skeleton skeleton-text" style="width:160px;margin-bottom:8px"></div><div class="skeleton skeleton-card" style="height:120px"></div></div>
<div class="card"><div class="skeleton skeleton-text" style="width:120px;margin-bottom:8px"></div><div class="skeleton skeleton-card" style="height:200px"></div></div>
</div>
<!-- Error -->
<div x-show="!loading && loadError" class="error-state" style="animation:fadeIn 0.3s">
<h3 style="color:var(--error)">Connection Error</h3>
<p class="text-xs text-dim" x-text="loadError"></p>
<button class="btn btn-primary btn-sm" @click="loadData()" style="margin-top:8px">Retry</button>
</div>
<!-- Content -->
<div x-show="!loading && !loadError" style="animation:fadeIn 0.3s">
<!-- Topology -->
<div class="card mb-4">
<div class="card-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;margin-right:4px;vertical-align:-2px"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98"/></svg>
Agent Topology
<span class="badge badge-dim" style="margin-left:8px;font-weight:400" x-text="topology.nodes.length + ' agents'"></span>
</div>
<div style="padding:8px 0;font-family:var(--font-mono);font-size:12px;line-height:1.8">
<template x-if="topology.nodes.length === 0">
<div class="text-dim" style="text-align:center;padding:24px">No agents running</div>
</template>
<template x-for="root in rootNodes()" :key="root.id">
<div class="comms-topo-tree">
<div class="comms-topo-node" :title="root.id">
<span :class="stateBadgeClass(root.state)" style="font-size:10px;padding:1px 6px" x-text="root.state"></span>
<strong x-text="root.name" style="margin:0 4px"></strong>
<span class="text-dim" x-text="root.model"></span>
<template x-for="peer in peersOf(root.id)" :key="peer.id">
<span class="text-dim" style="margin-left:8px" x-text="'\u2194 ' + peer.name"></span>
</template>
</div>
<template x-for="(child, ci) in childrenOf(root.id)" :key="child.id">
<div class="comms-topo-child">
<span class="comms-topo-branch" x-text="ci < childrenOf(root.id).length - 1 ? '\u251c\u2500\u2500 ' : '\u2514\u2500\u2500 '"></span>
<span :class="stateBadgeClass(child.state)" style="font-size:10px;padding:1px 6px" x-text="child.state"></span>
<strong x-text="child.name" style="margin:0 4px"></strong>
<span class="text-dim" x-text="child.model"></span>
</div>
</template>
</div>
</template>
</div>
</div>
<!-- Live Event Feed -->
<div class="card">
<div class="card-header flex justify-between items-center">
<div>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;margin-right:4px;vertical-align:-2px"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
Live Event Feed
</div>
<div class="flex items-center gap-2">
<span class="badge badge-success" style="font-size:9px;padding:2px 6px;animation:pulse-ring 2s infinite">LIVE</span>
<span class="text-xs text-dim" x-text="events.length + ' events'"></span>
</div>
</div>
<div style="max-height:400px;overflow-y:auto">
<template x-if="events.length === 0">
<div class="text-dim" style="text-align:center;padding:24px">No inter-agent events yet</div>
</template>
<template x-for="ev in events" :key="ev.id">
<div class="comms-event-row">
<span class="comms-event-time text-xs text-dim" x-text="timeAgo(ev.timestamp)"></span>
<span :class="eventBadgeClass(ev.kind)" style="font-size:10px;padding:1px 6px;min-width:70px;text-align:center" x-text="eventLabel(ev.kind)"></span>
<span style="font-weight:600;font-size:12px" x-text="ev.source_name"></span>
<span class="text-dim" x-show="ev.target_name" x-text="'\u2192 ' + ev.target_name"></span>
<span class="comms-event-detail text-dim text-xs" x-text="ev.detail" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
</div>
</template>
</div>
</div>
</div>
<!-- Send Message Modal -->
<div x-show="showSendModal" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)" @click.self="showSendModal=false" x-transition>
<div class="card" style="width:420px;max-width:90vw" @click.stop>
<div class="card-header">Send Agent Message</div>
<div style="display:flex;flex-direction:column;gap:12px;margin-top:12px">
<div>
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">From Agent</label>
<select x-model="sendFrom" class="input" style="width:100%">
<option value="">Select agent...</option>
<template x-for="n in topology.nodes" :key="n.id">
<option :value="n.id" x-text="n.name + ' (' + n.state + ')'"></option>
</template>
</select>
</div>
<div>
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">To Agent</label>
<select x-model="sendTo" class="input" style="width:100%">
<option value="">Select agent...</option>
<template x-for="n in topology.nodes" :key="n.id">
<option :value="n.id" x-text="n.name + ' (' + n.state + ')'"></option>
</template>
</select>
</div>
<div>
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Message</label>
<textarea x-model="sendMsg" class="input" rows="3" placeholder="Type a message..." style="width:100%;resize:vertical"></textarea>
</div>
<div class="flex gap-2" style="justify-content:flex-end">
<button class="btn btn-ghost btn-sm" @click="showSendModal=false">Cancel</button>
<button class="btn btn-primary btn-sm" @click="submitSend()" :disabled="sendLoading || !sendFrom || !sendTo || !sendMsg.trim()">
<span x-show="sendLoading">Sending...</span>
<span x-show="!sendLoading">Send</span>
</button>
</div>
</div>
</div>
</div>
<!-- Post Task Modal -->
<div x-show="showTaskModal" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)" @click.self="showTaskModal=false" x-transition>
<div class="card" style="width:420px;max-width:90vw" @click.stop>
<div class="card-header">Post Task</div>
<div style="display:flex;flex-direction:column;gap:12px;margin-top:12px">
<div>
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Title</label>
<input type="text" x-model="taskTitle" class="input" placeholder="Task title..." style="width:100%">
</div>
<div>
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Description</label>
<textarea x-model="taskDesc" class="input" rows="3" placeholder="Task description..." style="width:100%;resize:vertical"></textarea>
</div>
<div>
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Assign To (optional)</label>
<select x-model="taskAssign" class="input" style="width:100%">
<option value="">Unassigned</option>
<template x-for="n in topology.nodes" :key="n.id">
<option :value="n.id" x-text="n.name"></option>
</template>
</select>
</div>
<div class="flex gap-2" style="justify-content:flex-end">
<button class="btn btn-ghost btn-sm" @click="showTaskModal=false">Cancel</button>
<button class="btn btn-primary btn-sm" @click="submitTask()" :disabled="taskLoading || !taskTitle.trim()">
<span x-show="taskLoading">Posting...</span>
<span x-show="!taskLoading">Post Task</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- Page: Setup Wizard -->
<template x-if="page === 'wizard'">
<div x-data="wizardPage">
+12 -3
View File
@@ -155,10 +155,19 @@ document.addEventListener('alpine:init', function() {
async checkAuth() {
try {
await OpenFangAPI.get('/api/providers');
// Use a protected endpoint (not in the public allowlist) to detect
// whether the server requires an API key.
await OpenFangAPI.get('/api/tools');
this.showAuthPrompt = false;
} catch(e) {
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0)) {
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0 || e.message.indexOf('Unauthorized') >= 0)) {
// Only show prompt if we don't already have a saved key
var saved = localStorage.getItem('openfang-api-key');
if (saved) {
// Saved key might be stale — clear it and show prompt
OpenFangAPI.setAuthToken('');
localStorage.removeItem('openfang-api-key');
}
this.showAuthPrompt = true;
}
}
@@ -209,7 +218,7 @@ function app() {
});
// Hash routing
var validPages = ['overview','agents','sessions','approvals','workflows','scheduler','channels','skills','hands','analytics','logs','settings','wizard'];
var validPages = ['overview','agents','sessions','approvals','comms','workflows','scheduler','channels','skills','hands','analytics','logs','settings','wizard'];
var pageRedirects = {
'chat': 'agents',
'templates': 'agents',
@@ -54,6 +54,15 @@ function agentsPage() {
filesLoading: false,
configForm: {},
configSaving: false,
// -- Tool filters --
toolFilters: { tool_allowlist: [], tool_blocklist: [] },
toolFiltersLoading: false,
newAllowTool: '',
newBlockTool: '',
// -- Model switch --
editingModel: false,
newModelValue: '',
modelSaving: false,
// -- Templates state --
tplTemplates: [],
@@ -559,6 +568,88 @@ function agentsPage() {
}
},
// ── Clear agent history ──
async clearHistory(agent) {
var self = this;
OpenFangToast.confirm('Clear History', 'Clear all conversation history for "' + agent.name + '"? This cannot be undone.', async function() {
try {
await OpenFangAPI.del('/api/agents/' + agent.id + '/history');
OpenFangToast.success('History cleared for "' + agent.name + '"');
} catch(e) {
OpenFangToast.error('Failed to clear history: ' + e.message);
}
});
},
// ── Model switch ──
async changeModel() {
if (!this.detailAgent || !this.newModelValue.trim()) return;
this.modelSaving = true;
try {
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
OpenFangToast.success('Model changed (memory reset)');
this.editingModel = false;
await Alpine.store('app').refreshAgents();
// Refresh detailAgent
var agents = Alpine.store('app').agents;
for (var i = 0; i < agents.length; i++) {
if (agents[i].id === this.detailAgent.id) { this.detailAgent = agents[i]; break; }
}
} catch(e) {
OpenFangToast.error('Failed to change model: ' + e.message);
}
this.modelSaving = false;
},
// ── Tool filters ──
async loadToolFilters() {
if (!this.detailAgent) return;
this.toolFiltersLoading = true;
try {
this.toolFilters = await OpenFangAPI.get('/api/agents/' + this.detailAgent.id + '/tools');
} catch(e) {
this.toolFilters = { tool_allowlist: [], tool_blocklist: [] };
}
this.toolFiltersLoading = false;
},
addAllowTool() {
var t = this.newAllowTool.trim();
if (t && this.toolFilters.tool_allowlist.indexOf(t) === -1) {
this.toolFilters.tool_allowlist.push(t);
this.newAllowTool = '';
this.saveToolFilters();
}
},
removeAllowTool(tool) {
this.toolFilters.tool_allowlist = this.toolFilters.tool_allowlist.filter(function(t) { return t !== tool; });
this.saveToolFilters();
},
addBlockTool() {
var t = this.newBlockTool.trim();
if (t && this.toolFilters.tool_blocklist.indexOf(t) === -1) {
this.toolFilters.tool_blocklist.push(t);
this.newBlockTool = '';
this.saveToolFilters();
}
},
removeBlockTool(tool) {
this.toolFilters.tool_blocklist = this.toolFilters.tool_blocklist.filter(function(t) { return t !== tool; });
this.saveToolFilters();
},
async saveToolFilters() {
if (!this.detailAgent) return;
try {
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/tools', this.toolFilters);
} catch(e) {
OpenFangToast.error('Failed to update tool filters: ' + e.message);
}
},
async spawnBuiltin(t) {
var toml = 'name = "' + t.name + '"\n';
toml += 'description = "' + t.description.replace(/"/g, '\\"') + '"\n';
+36 -2
View File
@@ -29,6 +29,11 @@ function chatPage() {
_audioChunks: [],
recordingTime: 0,
_recordingTimer: null,
// Model autocomplete state
showModelPicker: false,
modelPickerList: [],
modelPickerFilter: '',
modelPickerIdx: 0,
slashCommands: [
{ cmd: '/help', desc: 'Show available commands' },
{ cmd: '/agents', desc: 'Switch to Agents page' },
@@ -126,18 +131,47 @@ function chatPage() {
}
});
// Watch for slash commands
// Watch for slash commands + model autocomplete
this.$watch('inputText', function(val) {
if (val.startsWith('/')) {
var modelMatch = val.match(/^\/model\s+(.*)$/i);
if (modelMatch) {
self.showSlashMenu = false;
self.modelPickerFilter = modelMatch[1].toLowerCase();
if (!self.modelPickerList.length) {
OpenFangAPI.get('/api/models').then(function(data) {
self.modelPickerList = (data.models || []).filter(function(m) { return m.available; });
self.showModelPicker = true;
self.modelPickerIdx = 0;
}).catch(function() {});
} else {
self.showModelPicker = true;
}
} else if (val.startsWith('/')) {
self.showModelPicker = false;
self.slashFilter = val.slice(1).toLowerCase();
self.showSlashMenu = true;
self.slashIdx = 0;
} else {
self.showSlashMenu = false;
self.showModelPicker = false;
}
});
},
get filteredModelPicker() {
if (!this.modelPickerFilter) return this.modelPickerList.slice(0, 15);
var f = this.modelPickerFilter;
return this.modelPickerList.filter(function(m) {
return m.id.toLowerCase().indexOf(f) !== -1 || (m.display_name || '').toLowerCase().indexOf(f) !== -1 || m.provider.toLowerCase().indexOf(f) !== -1;
}).slice(0, 15);
},
pickModel(modelId) {
this.showModelPicker = false;
this.inputText = '/model ' + modelId;
this.sendMessage();
},
// Fetch dynamic slash commands from server
fetchCommands: function() {
var self = this;
@@ -0,0 +1,201 @@
// OpenFang Comms Page — Agent topology & inter-agent communication feed
'use strict';
function commsPage() {
return {
topology: { nodes: [], edges: [] },
events: [],
loading: true,
loadError: '',
sseSource: null,
showSendModal: false,
showTaskModal: false,
sendFrom: '',
sendTo: '',
sendMsg: '',
sendLoading: false,
taskTitle: '',
taskDesc: '',
taskAssign: '',
taskLoading: false,
async loadData() {
this.loading = true;
this.loadError = '';
try {
var results = await Promise.all([
OpenFangAPI.get('/api/comms/topology'),
OpenFangAPI.get('/api/comms/events?limit=200')
]);
this.topology = results[0] || { nodes: [], edges: [] };
this.events = results[1] || [];
this.startSSE();
} catch(e) {
this.loadError = e.message || 'Could not load comms data.';
}
this.loading = false;
},
startSSE() {
if (this.sseSource) this.sseSource.close();
var self = this;
var url = OpenFangAPI.baseUrl + '/api/comms/events/stream';
if (OpenFangAPI.apiKey) url += '?token=' + encodeURIComponent(OpenFangAPI.apiKey);
this.sseSource = new EventSource(url);
this.sseSource.onmessage = function(ev) {
if (ev.data === 'ping') return;
try {
var event = JSON.parse(ev.data);
self.events.unshift(event);
if (self.events.length > 200) self.events.length = 200;
// Refresh topology on spawn/terminate events
if (event.kind === 'agent_spawned' || event.kind === 'agent_terminated') {
self.refreshTopology();
}
} catch(e) { /* ignore parse errors */ }
};
},
stopSSE() {
if (this.sseSource) {
this.sseSource.close();
this.sseSource = null;
}
},
async refreshTopology() {
try {
this.topology = await OpenFangAPI.get('/api/comms/topology');
} catch(e) { /* silent */ }
},
rootNodes() {
var childIds = {};
var self = this;
this.topology.edges.forEach(function(e) {
if (e.kind === 'parent_child') childIds[e.to] = true;
});
return this.topology.nodes.filter(function(n) { return !childIds[n.id]; });
},
childrenOf(id) {
var childIds = {};
this.topology.edges.forEach(function(e) {
if (e.kind === 'parent_child' && e.from === id) childIds[e.to] = true;
});
return this.topology.nodes.filter(function(n) { return childIds[n.id]; });
},
peersOf(id) {
var peerIds = {};
this.topology.edges.forEach(function(e) {
if (e.kind === 'peer') {
if (e.from === id) peerIds[e.to] = true;
if (e.to === id) peerIds[e.from] = true;
}
});
return this.topology.nodes.filter(function(n) { return peerIds[n.id]; });
},
stateBadgeClass(state) {
switch(state) {
case 'Running': return 'badge badge-success';
case 'Suspended': return 'badge badge-warning';
case 'Terminated': case 'Crashed': return 'badge badge-danger';
default: return 'badge badge-dim';
}
},
eventBadgeClass(kind) {
switch(kind) {
case 'agent_message': return 'badge badge-info';
case 'agent_spawned': return 'badge badge-success';
case 'agent_terminated': return 'badge badge-danger';
case 'task_posted': return 'badge badge-warning';
case 'task_claimed': return 'badge badge-info';
case 'task_completed': return 'badge badge-success';
default: return 'badge badge-dim';
}
},
eventIcon(kind) {
switch(kind) {
case 'agent_message': return '\u2709';
case 'agent_spawned': return '+';
case 'agent_terminated': return '\u2715';
case 'task_posted': return '\u2691';
case 'task_claimed': return '\u2690';
case 'task_completed': return '\u2713';
default: return '\u2022';
}
},
eventLabel(kind) {
switch(kind) {
case 'agent_message': return 'Message';
case 'agent_spawned': return 'Spawned';
case 'agent_terminated': return 'Terminated';
case 'task_posted': return 'Task Posted';
case 'task_claimed': return 'Task Claimed';
case 'task_completed': return 'Task Done';
default: return kind;
}
},
timeAgo(dateStr) {
if (!dateStr) return '';
var d = new Date(dateStr);
var secs = Math.floor((Date.now() - d.getTime()) / 1000);
if (secs < 60) return secs + 's ago';
if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
return Math.floor(secs / 86400) + 'd ago';
},
openSendModal() {
this.sendFrom = '';
this.sendTo = '';
this.sendMsg = '';
this.showSendModal = true;
},
async submitSend() {
if (!this.sendFrom || !this.sendTo || !this.sendMsg.trim()) return;
this.sendLoading = true;
try {
await OpenFangAPI.post('/api/comms/send', {
from_agent_id: this.sendFrom,
to_agent_id: this.sendTo,
message: this.sendMsg
});
OpenFangToast.success('Message sent');
this.showSendModal = false;
} catch(e) {
OpenFangToast.error(e.message || 'Send failed');
}
this.sendLoading = false;
},
openTaskModal() {
this.taskTitle = '';
this.taskDesc = '';
this.taskAssign = '';
this.showTaskModal = true;
},
async submitTask() {
if (!this.taskTitle.trim()) return;
this.taskLoading = true;
try {
var body = { title: this.taskTitle, description: this.taskDesc };
if (this.taskAssign) body.assigned_to = this.taskAssign;
await OpenFangAPI.post('/api/comms/task', body);
OpenFangToast.success('Task posted');
this.showTaskModal = false;
} catch(e) {
OpenFangToast.error(e.message || 'Task failed');
}
this.taskLoading = false;
}
};
}
@@ -14,6 +14,12 @@ function settingsPage() {
modelSearch: '',
modelProviderFilter: '',
modelTierFilter: '',
showCustomModelForm: false,
customModelId: '',
customModelProvider: 'openrouter',
customModelContext: 128000,
customModelMaxOutput: 8192,
customModelStatus: '',
providerKeyInputs: {},
providerUrlInputs: {},
providerUrlSaving: {},
@@ -213,8 +219,13 @@ function settingsPage() {
this.providers = data.providers || [];
for (var i = 0; i < this.providers.length; i++) {
var p = this.providers[i];
if (p.is_local && p.base_url && !this.providerUrlInputs[p.id]) {
this.providerUrlInputs[p.id] = p.base_url;
if (p.is_local) {
if (!this.providerUrlInputs[p.id]) {
this.providerUrlInputs[p.id] = p.base_url || '';
}
if (this.providerUrlSaving[p.id] === undefined) {
this.providerUrlSaving[p.id] = false;
}
}
}
} catch(e) { this.providers = []; }
@@ -227,6 +238,26 @@ function settingsPage() {
} catch(e) { this.models = []; }
},
async addCustomModel() {
var id = this.customModelId.trim();
if (!id) return;
this.customModelStatus = 'Adding...';
try {
await OpenFangAPI.post('/api/models/custom', {
id: id,
provider: this.customModelProvider || 'openrouter',
context_window: this.customModelContext || 128000,
max_output_tokens: this.customModelMaxOutput || 8192,
});
this.customModelStatus = 'Added!';
this.customModelId = '';
this.showCustomModelForm = false;
await this.loadModels();
} catch(e) {
this.customModelStatus = 'Error: ' + (e.message || 'Failed');
}
},
async loadConfigSchema() {
try {
var results = await Promise.all([
+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(())
}
+808 -4
View File
@@ -73,6 +73,7 @@ const AFTER_HELP: &str = "\
openfang doctor Run diagnostic health checks
openfang channel setup Interactive channel setup wizard
openfang cron list List scheduled jobs
openfang uninstall Completely remove OpenFang from your system
\x1b[1;36mQuick Start:\x1b[0m
1. openfang init Set up config + API key
@@ -132,6 +133,9 @@ enum Commands {
/// Manage channel integrations (setup, test, enable, disable) [*].
#[command(subcommand)]
Channel(ChannelCommands),
/// Manage hands (list, activate, deactivate, info) [*].
#[command(subcommand)]
Hand(HandCommands),
/// Show or edit configuration (show, edit, get, set, keys) [*].
#[command(subcommand)]
Config(ConfigCommands),
@@ -276,6 +280,15 @@ enum Commands {
#[arg(long)]
confirm: bool,
},
/// Completely uninstall OpenFang from your system.
Uninstall {
/// Skip confirmation prompt (also --yes).
#[arg(long, alias = "yes")]
confirm: bool,
/// Keep config files (config.toml, .env, secrets.env).
#[arg(long)]
keep_config: bool,
},
}
#[derive(Subcommand)]
@@ -371,6 +384,49 @@ enum ChannelCommands {
},
}
#[derive(Subcommand)]
enum HandCommands {
/// List all available hands.
List,
/// Show currently active hand instances.
Active,
/// Activate a hand by ID.
Activate {
/// Hand ID (e.g. "clip", "lead", "researcher").
id: String,
},
/// Deactivate an active hand instance.
Deactivate {
/// Hand ID.
id: String,
},
/// Show detailed info about a hand.
Info {
/// 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)]
enum ConfigCommands {
/// Show the current configuration.
@@ -439,6 +495,15 @@ enum AgentCommands {
/// Agent ID (UUID).
agent_id: String,
},
/// Set an agent property (e.g., model).
Set {
/// Agent ID (UUID).
agent_id: String,
/// Field to set (model).
field: String,
/// New value.
value: String,
},
}
#[derive(Subcommand)]
@@ -567,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 {
@@ -803,6 +871,11 @@ fn main() {
AgentCommands::List { json } => cmd_agent_list(cli.config, json),
AgentCommands::Chat { agent_id } => cmd_agent_chat(cli.config, &agent_id),
AgentCommands::Kill { agent_id } => cmd_agent_kill(cli.config, &agent_id),
AgentCommands::Set {
agent_id,
field,
value,
} => cmd_agent_set(&agent_id, &field, &value),
},
Some(Commands::Workflow(sub)) => match sub {
WorkflowCommands::List => cmd_workflow_list(),
@@ -834,6 +907,17 @@ fn main() {
ChannelCommands::Enable { channel } => cmd_channel_toggle(&channel, true),
ChannelCommands::Disable { channel } => cmd_channel_toggle(&channel, false),
},
Some(Commands::Hand(sub)) => match sub {
HandCommands::List => cmd_hand_list(),
HandCommands::Active => cmd_hand_active(),
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(),
ConfigCommands::Edit => cmd_config_edit(),
@@ -883,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),
@@ -922,6 +1007,7 @@ fn main() {
SystemCommands::Version { json } => cmd_system_version(json),
},
Some(Commands::Reset { confirm }) => cmd_reset(confirm),
Some(Commands::Uninstall { confirm, keep_config }) => cmd_uninstall(confirm, keep_config),
}
}
@@ -1248,6 +1334,7 @@ fn write_config_if_missing(
r#"# OpenFang Agent OS configuration
# See https://github.com/RightNow-AI/openfang for documentation
# For Docker, change to "0.0.0.0:4200" or set OPENFANG_LISTEN env var.
api_listen = "127.0.0.1:4200"
[default_model]
@@ -1583,6 +1670,38 @@ fn cmd_agent_kill(config: Option<PathBuf>, agent_id_str: &str) {
}
}
fn cmd_agent_set(agent_id_str: &str, field: &str, value: &str) {
match field {
"model" => {
if let Some(base) = find_daemon() {
let client = daemon_client();
let body = daemon_json(
client
.put(format!("{base}/api/agents/{agent_id_str}/model"))
.json(&serde_json::json!({"model": value}))
.send(),
);
if body.get("status").is_some() {
println!("Agent {agent_id_str} model set to {value}.");
} else {
eprintln!(
"Failed to set model: {}",
body["error"].as_str().unwrap_or("Unknown error")
);
std::process::exit(1);
}
} else {
eprintln!("No running daemon found. Start one with: openfang start");
std::process::exit(1);
}
}
_ => {
eprintln!("Unknown field: {field}. Supported fields: model");
std::process::exit(1);
}
}
}
fn cmd_agent_new(config: Option<PathBuf>, template_name: Option<String>) {
let all_templates = templates::load_all_templates();
if all_templates.is_empty() {
@@ -1913,6 +2032,7 @@ fn cmd_doctor(json: bool, repair: bool) {
let default_config = r#"# OpenFang Agent OS configuration
# See https://github.com/RightNow-AI/openfang for documentation
# For Docker, change to "0.0.0.0:4200" or set OPENFANG_LISTEN env var.
api_listen = "127.0.0.1:4200"
[default_model]
@@ -3698,6 +3818,249 @@ fn cmd_channel_toggle(channel: &str, enable: bool) {
}
}
// ---------------------------------------------------------------------------
// Hand commands
// ---------------------------------------------------------------------------
fn cmd_hand_list() {
let base = require_daemon("hand list");
let client = daemon_client();
let body = daemon_json(client.get(format!("{base}/api/hands")).send());
// API returns {"hands": [...]} or a bare array
let arr_val;
if let Some(arr) = body.get("hands").and_then(|v| v.as_array()) {
arr_val = arr.clone();
} else if let Some(arr) = body.as_array() {
arr_val = arr.clone();
} else {
println!(
"{}",
serde_json::to_string_pretty(&body).unwrap_or_default()
);
return;
}
if let Some(arr) = Some(&arr_val) {
if arr.is_empty() {
println!("No hands available.");
return;
}
println!(
"{:<14} {:<20} {:<10} DESCRIPTION",
"ID", "NAME", "CATEGORY"
);
println!("{}", "-".repeat(72));
for h in arr {
println!(
"{:<14} {:<20} {:<10} {}",
h["id"].as_str().unwrap_or("?"),
h["name"].as_str().unwrap_or("?"),
h["category"].as_str().unwrap_or("?"),
h["description"].as_str().unwrap_or("").chars().take(40).collect::<String>(),
);
}
println!("\nUse `openfang hand activate <id>` to activate a hand.");
}
}
fn cmd_hand_active() {
let base = require_daemon("hand active");
let client = daemon_client();
let body = daemon_json(client.get(format!("{base}/api/hands/active")).send());
// API returns {"instances": [...]} or bare array
let arr = body
.get("instances")
.and_then(|v| v.as_array())
.or_else(|| body.as_array())
.cloned()
.unwrap_or_default();
if arr.is_empty() {
println!("No active hands.");
return;
}
println!(
"{:<38} {:<14} {:<10} AGENT",
"INSTANCE", "HAND", "STATUS"
);
println!("{}", "-".repeat(72));
for i in &arr {
println!(
"{:<38} {:<14} {:<10} {}",
i["instance_id"].as_str().unwrap_or("?"),
i["hand_id"].as_str().unwrap_or("?"),
i["status"].as_str().unwrap_or("?"),
i["agent_name"].as_str().unwrap_or("?"),
);
}
}
fn cmd_hand_activate(id: &str) {
let base = require_daemon("hand activate");
let client = daemon_client();
let body = daemon_json(
client
.post(format!("{base}/api/hands/{id}/activate"))
.header("content-type", "application/json")
.body("{}")
.send(),
);
if body.get("instance_id").is_some() {
println!(
"Hand '{}' activated (instance: {}, agent: {})",
id,
body["instance_id"].as_str().unwrap_or("?"),
body["agent_name"].as_str().unwrap_or("?"),
);
} else {
eprintln!(
"Failed to activate hand '{}': {}",
id,
body["error"].as_str().unwrap_or("Unknown error")
);
std::process::exit(1);
}
}
fn cmd_hand_deactivate(id: &str) {
let base = require_daemon("hand deactivate");
let client = daemon_client();
// First find the instance ID for this hand
let active = daemon_json(client.get(format!("{base}/api/hands/active")).send());
let arr = active
.get("instances")
.and_then(|v| v.as_array())
.or_else(|| active.as_array())
.cloned()
.unwrap_or_default();
let instance_id = arr.iter().find_map(|i| {
if i["hand_id"].as_str() == Some(id) {
i["instance_id"].as_str().map(|s| s.to_string())
} else {
None
}
});
match instance_id {
Some(iid) => {
let body = daemon_json(
client
.delete(format!("{base}/api/hands/instances/{iid}"))
.send(),
);
if body.get("status").is_some() {
println!("Hand '{id}' deactivated.");
} else {
eprintln!(
"Failed: {}",
body["error"].as_str().unwrap_or("Unknown error")
);
std::process::exit(1);
}
}
None => {
eprintln!("No active instance found for hand '{id}'.");
std::process::exit(1);
}
}
}
fn cmd_hand_info(id: &str) {
let base = require_daemon("hand info");
let client = daemon_client();
let body = daemon_json(client.get(format!("{base}/api/hands/{id}")).send());
if body.get("error").is_some() {
eprintln!(
"Hand not found: {}",
body["error"].as_str().unwrap_or(id)
);
std::process::exit(1);
}
println!(
"{}",
serde_json::to_string_pretty(&body).unwrap_or_default()
);
}
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
// ---------------------------------------------------------------------------
@@ -3966,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);
@@ -4870,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(),
);
@@ -5534,6 +5946,358 @@ fn cmd_reset(confirm: bool) {
}
}
// ---------------------------------------------------------------------------
// Uninstall
// ---------------------------------------------------------------------------
fn cmd_uninstall(confirm: bool, keep_config: bool) {
let home = match dirs::home_dir() {
Some(h) => h,
None => {
ui::error("Could not determine home directory");
std::process::exit(1);
}
};
let openfang_dir = home.join(".openfang");
let exe_path = std::env::current_exe().ok();
// Step 1: Show what will be removed
println!();
println!(
" {}",
"This will completely uninstall OpenFang from your system."
.bold()
.red()
);
println!();
if openfang_dir.exists() {
if keep_config {
println!(
" • Remove data in {} (keeping config files)",
openfang_dir.display()
);
} else {
println!(" • Remove {}", openfang_dir.display());
}
}
if let Some(ref exe) = exe_path {
println!(" • Remove binary: {}", exe.display());
}
// Check cargo bin path
let cargo_bin = home.join(".cargo").join("bin").join(if cfg!(windows) {
"openfang.exe"
} else {
"openfang"
});
if cargo_bin.exists() && exe_path.as_ref().is_none_or(|e| *e != cargo_bin) {
println!(" • Remove cargo binary: {}", cargo_bin.display());
}
println!(" • Remove auto-start entries (if any)");
println!(" • Clean PATH from shell configs (if any)");
println!();
// Step 2: Confirm
if !confirm {
let answer = prompt_input(" Type 'uninstall' to confirm: ");
if answer.trim() != "uninstall" {
println!(" Cancelled.");
return;
}
println!();
}
// Step 3: Stop running daemon
if find_daemon().is_some() {
println!(" Stopping running daemon...");
cmd_stop();
// Give it a moment
std::thread::sleep(std::time::Duration::from_secs(1));
// Force kill if still alive
if find_daemon().is_some() {
if let Some(info) = read_daemon_info(&openfang_dir) {
force_kill_pid(info.pid);
let _ = std::fs::remove_file(openfang_dir.join("daemon.json"));
}
}
}
// Step 4: Remove auto-start entries
remove_autostart_entries(&home);
// Step 5: Clean PATH from shell configs
if let Some(ref exe) = exe_path {
if let Some(bin_dir) = exe.parent() {
clean_path_entries(&home, &bin_dir.to_string_lossy());
}
}
// Step 6: Remove ~/.openfang/ data
if openfang_dir.exists() {
if keep_config {
remove_dir_except_config(&openfang_dir);
ui::success("Removed data (kept config files)");
} else {
match std::fs::remove_dir_all(&openfang_dir) {
Ok(()) => ui::success(&format!("Removed {}", openfang_dir.display())),
Err(e) => ui::error(&format!(
"Failed to remove {}: {e}",
openfang_dir.display()
)),
}
}
}
// Step 7: Remove cargo bin copy if it exists and is separate from current exe
if cargo_bin.exists() && exe_path.as_ref().is_none_or(|e| *e != cargo_bin) {
match std::fs::remove_file(&cargo_bin) {
Ok(()) => ui::success(&format!("Removed {}", cargo_bin.display())),
Err(e) => ui::error(&format!(
"Failed to remove {}: {e}",
cargo_bin.display()
)),
}
}
// Step 8: Remove the binary itself (must be last)
if let Some(exe) = exe_path {
remove_self_binary(&exe);
}
println!();
ui::success("OpenFang has been uninstalled. Goodbye!");
}
/// Remove auto-start / launch-agent / systemd entries.
#[allow(unused_variables)]
fn remove_autostart_entries(home: &std::path::Path) {
#[cfg(windows)]
{
// Windows: remove from HKCU\Software\Microsoft\Windows\CurrentVersion\Run
let output = std::process::Command::new("reg")
.args([
"delete",
r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run",
"/v",
"OpenFang",
"/f",
])
.output();
match output {
Ok(o) if o.status.success() => {
ui::success("Removed Windows auto-start registry entry");
}
_ => {} // Entry didn't exist — that's fine
}
}
#[cfg(target_os = "macos")]
{
let plist = home.join("Library/LaunchAgents/ai.openfang.desktop.plist");
if plist.exists() {
// Unload first
let _ = std::process::Command::new("launchctl")
.args(["unload", &plist.to_string_lossy()])
.output();
match std::fs::remove_file(&plist) {
Ok(()) => ui::success("Removed macOS launch agent"),
Err(e) => ui::error(&format!("Failed to remove launch agent: {e}")),
}
}
}
#[cfg(target_os = "linux")]
{
let desktop_file = home.join(".config/autostart/OpenFang.desktop");
if desktop_file.exists() {
match std::fs::remove_file(&desktop_file) {
Ok(()) => ui::success("Removed Linux autostart entry"),
Err(e) => ui::error(&format!("Failed to remove autostart entry: {e}")),
}
}
// Also check for systemd user service
let service_file = home.join(".config/systemd/user/openfang.service");
if service_file.exists() {
let _ = std::process::Command::new("systemctl")
.args(["--user", "disable", "--now", "openfang.service"])
.output();
match std::fs::remove_file(&service_file) {
Ok(()) => {
let _ = std::process::Command::new("systemctl")
.args(["--user", "daemon-reload"])
.output();
ui::success("Removed systemd user service");
}
Err(e) => ui::error(&format!("Failed to remove systemd service: {e}")),
}
}
}
}
/// Remove lines from shell config files that add openfang to PATH.
#[allow(unused_variables)]
fn clean_path_entries(home: &std::path::Path, openfang_dir: &str) {
#[cfg(not(windows))]
{
let shell_files = [
home.join(".bashrc"),
home.join(".bash_profile"),
home.join(".profile"),
home.join(".zshrc"),
home.join(".config/fish/config.fish"),
];
for path in &shell_files {
if !path.exists() {
continue;
}
let Ok(content) = std::fs::read_to_string(path) else {
continue;
};
let filtered: Vec<&str> = content
.lines()
.filter(|line| !is_openfang_path_line(line, openfang_dir))
.collect();
if filtered.len() < content.lines().count() {
let new_content = filtered.join("\n");
// Preserve trailing newline if original had one
let new_content = if content.ends_with('\n') {
format!("{new_content}\n")
} else {
new_content
};
if std::fs::write(path, &new_content).is_ok() {
ui::success(&format!("Cleaned PATH from {}", path.display()));
}
}
}
}
#[cfg(windows)]
{
// Read User PATH via PowerShell, filter out openfang entries, write back
let output = std::process::Command::new("powershell")
.args([
"-NoProfile",
"-Command",
"[Environment]::GetEnvironmentVariable('PATH', 'User')",
])
.output();
if let Ok(out) = output {
if out.status.success() {
let current = String::from_utf8_lossy(&out.stdout);
let current = current.trim();
if !current.is_empty() {
let dir_lower = openfang_dir.to_lowercase();
let filtered: Vec<&str> = current
.split(';')
.filter(|entry| {
let e = entry.trim().to_lowercase();
!e.is_empty() && !e.contains("openfang") && !e.contains(&dir_lower)
})
.collect();
if filtered.len() < current.split(';').count() {
let new_path = filtered.join(";");
let ps_cmd = format!(
"[Environment]::SetEnvironmentVariable('PATH', '{}', 'User')",
new_path.replace('\'', "''")
);
let result = std::process::Command::new("powershell")
.args(["-NoProfile", "-Command", &ps_cmd])
.output();
if result.is_ok_and(|o| o.status.success()) {
ui::success("Cleaned PATH from Windows user environment");
}
}
}
}
}
}
}
/// Returns true if a shell config line is an openfang PATH export.
/// Must match BOTH an openfang reference AND a PATH-setting pattern.
#[cfg(any(not(windows), test))]
fn is_openfang_path_line(line: &str, openfang_dir: &str) -> bool {
let lower = line.to_lowercase();
let has_openfang = lower.contains("openfang") || lower.contains(&openfang_dir.to_lowercase());
if !has_openfang {
return false;
}
// Match common PATH-setting patterns
lower.contains("export path=")
|| lower.contains("export path =")
|| lower.starts_with("path=")
|| lower.contains("set -gx path")
|| lower.contains("fish_add_path")
}
/// Remove everything in ~/.openfang/ except config files.
fn remove_dir_except_config(openfang_dir: &std::path::Path) {
let keep = ["config.toml", ".env", "secrets.env"];
let Ok(entries) = std::fs::read_dir(openfang_dir) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if keep.contains(&name_str.as_ref()) {
continue;
}
let path = entry.path();
if path.is_dir() {
let _ = std::fs::remove_dir_all(&path);
} else {
let _ = std::fs::remove_file(&path);
}
}
}
/// Remove the currently-running binary.
fn remove_self_binary(exe_path: &std::path::Path) {
#[cfg(unix)]
{
// On Unix, running binaries can be unlinked — the OS keeps the inode
// alive until the process exits.
match std::fs::remove_file(exe_path) {
Ok(()) => ui::success(&format!("Removed {}", exe_path.display())),
Err(e) => ui::error(&format!(
"Failed to remove binary {}: {e}",
exe_path.display()
)),
}
}
#[cfg(windows)]
{
// Windows locks running executables. Rename first, then spawn a
// detached process that waits briefly and deletes the renamed file.
let old_path = exe_path.with_extension("exe.old");
if std::fs::rename(exe_path, &old_path).is_err() {
ui::error(&format!(
"Could not rename binary for deferred deletion: {}",
exe_path.display()
));
return;
}
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
const DETACHED_PROCESS: u32 = 0x0000_0008;
let del_cmd = format!(
"ping -n 3 127.0.0.1 >nul & del /f /q \"{}\"",
old_path.display()
);
let _ = std::process::Command::new("cmd.exe")
.args(["/C", &del_cmd])
.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.spawn();
ui::success(&format!("Removed {} (deferred cleanup)", exe_path.display()));
}
}
#[cfg(test)]
mod tests {
@@ -5658,4 +6422,44 @@ args = ["-y", "@modelcontextprotocol/server-github"]
];
assert_eq!(events.len(), 4);
}
// --- Uninstall command unit tests ---
#[test]
fn test_uninstall_path_line_filter() {
use super::is_openfang_path_line;
let dir = "/home/user/.openfang/bin";
// Should match: openfang PATH exports
assert!(is_openfang_path_line(
r#"export PATH="$HOME/.openfang/bin:$PATH""#,
dir
));
assert!(is_openfang_path_line(
r#"export PATH="/home/user/.openfang/bin:$PATH""#,
dir
));
assert!(is_openfang_path_line(
"set -gx PATH $HOME/.openfang/bin $PATH",
dir
));
assert!(is_openfang_path_line(
"fish_add_path $HOME/.openfang/bin",
dir
));
// Should NOT match: unrelated PATH exports
assert!(!is_openfang_path_line(
r#"export PATH="$HOME/.cargo/bin:$PATH""#,
dir
));
assert!(!is_openfang_path_line(
r#"export PATH="/usr/local/bin:$PATH""#,
dir
));
// Should NOT match: openfang lines that aren't PATH-related
assert!(!is_openfang_path_line("# openfang config", dir));
assert!(!is_openfang_path_line("alias of=openfang", dir));
}
}
+191 -1
View File
@@ -189,6 +189,17 @@ pub enum AppEvent {
AgentSkillsUpdated(String),
/// Agent MCP servers updated.
AgentMcpServersUpdated(String),
/// Comms topology loaded.
CommsTopologyLoaded {
nodes: Vec<super::screens::comms::CommsNode>,
edges: Vec<super::screens::comms::CommsEdge>,
},
/// Comms events loaded.
CommsEventsLoaded(Vec<super::screens::comms::CommsEventItem>),
/// Comms send result.
CommsSendResult(String),
/// Comms task post result.
CommsTaskResult(String),
}
/// Spawn the crossterm polling + tick thread. Returns sender + receiver.
@@ -1481,7 +1492,13 @@ pub fn spawn_browse_clawhub(backend: BackendRef, sort: String, tx: mpsc::Sender<
}
fn parse_clawhub_results(body: &serde_json::Value) -> Vec<ClawHubResult> {
body.as_array()
// API returns {"items": [...]} wrapper, fall back to bare array for compat
let items = body
.get("items")
.and_then(|v| v.as_array())
.or_else(|| body.as_array());
items
.map(|arr| {
arr.iter()
.map(|r| ClawHubResult {
@@ -2592,3 +2609,176 @@ pub fn spawn_reconnect_extension(backend: BackendRef, id: String, tx: mpsc::Send
}
});
}
/// Fetch comms topology + events.
pub fn spawn_fetch_comms(backend: BackendRef, tx: mpsc::Sender<AppEvent>) {
use super::screens::comms::{CommsEdge, CommsEventItem, CommsNode};
std::thread::spawn(move || match backend {
BackendRef::Daemon(base_url) => {
let client = daemon_client();
// Fetch topology
if let Ok(resp) = client.get(format!("{base_url}/api/comms/topology")).send() {
if let Ok(body) = resp.json::<serde_json::Value>() {
let nodes: Vec<CommsNode> = body["nodes"]
.as_array()
.map(|arr| {
arr.iter()
.map(|n| CommsNode {
id: n["id"].as_str().unwrap_or("").to_string(),
name: n["name"].as_str().unwrap_or("").to_string(),
state: n["state"].as_str().unwrap_or("").to_string(),
model: n["model"].as_str().unwrap_or("").to_string(),
})
.collect()
})
.unwrap_or_default();
let edges: Vec<CommsEdge> = body["edges"]
.as_array()
.map(|arr| {
arr.iter()
.map(|e| CommsEdge {
from: e["from"].as_str().unwrap_or("").to_string(),
to: e["to"].as_str().unwrap_or("").to_string(),
kind: e["kind"].as_str().unwrap_or("").to_string(),
})
.collect()
})
.unwrap_or_default();
let _ = tx.send(AppEvent::CommsTopologyLoaded { nodes, edges });
}
}
// Fetch events
if let Ok(resp) = client
.get(format!("{base_url}/api/comms/events?limit=100"))
.send()
{
if let Ok(body) = resp.json::<serde_json::Value>() {
let events: Vec<CommsEventItem> = body
.as_array()
.map(|arr| {
arr.iter()
.map(|e| CommsEventItem {
id: e["id"].as_str().unwrap_or("").to_string(),
timestamp: e["timestamp"].as_str().unwrap_or("").to_string(),
kind: e["kind"].as_str().unwrap_or("").to_string(),
source_name: e["source_name"]
.as_str()
.unwrap_or("")
.to_string(),
target_name: e["target_name"]
.as_str()
.unwrap_or("")
.to_string(),
detail: e["detail"].as_str().unwrap_or("").to_string(),
})
.collect()
})
.unwrap_or_default();
let _ = tx.send(AppEvent::CommsEventsLoaded(events));
}
}
}
BackendRef::InProcess(_) => {
let _ = tx.send(AppEvent::CommsTopologyLoaded {
nodes: Vec::new(),
edges: Vec::new(),
});
let _ = tx.send(AppEvent::CommsEventsLoaded(Vec::new()));
}
});
}
/// Send a message between agents via comms endpoint.
pub fn spawn_comms_send(
backend: BackendRef,
from: String,
to: String,
msg: String,
tx: mpsc::Sender<AppEvent>,
) {
std::thread::spawn(move || match backend {
BackendRef::Daemon(base_url) => {
let client = daemon_client();
let body = serde_json::json!({
"from_agent_id": from,
"to_agent_id": to,
"message": msg,
});
match client
.post(format!("{base_url}/api/comms/send"))
.json(&body)
.send()
{
Ok(resp) => {
if resp.status().is_success() {
let _ = tx.send(AppEvent::CommsSendResult("Message sent".to_string()));
} else {
let err = resp
.json::<serde_json::Value>()
.ok()
.and_then(|v| v["error"].as_str().map(String::from))
.unwrap_or_else(|| "Send failed".to_string());
let _ = tx.send(AppEvent::CommsSendResult(err));
}
}
Err(e) => {
let _ = tx.send(AppEvent::CommsSendResult(format!("Error: {e}")));
}
}
}
BackendRef::InProcess(_) => {
let _ = tx.send(AppEvent::CommsSendResult(
"Send not supported in-process".to_string(),
));
}
});
}
/// Post a task via comms endpoint.
pub fn spawn_comms_task(
backend: BackendRef,
title: String,
desc: String,
assign: String,
tx: mpsc::Sender<AppEvent>,
) {
std::thread::spawn(move || match backend {
BackendRef::Daemon(base_url) => {
let client = daemon_client();
let mut body = serde_json::json!({
"title": title,
"description": desc,
});
if !assign.is_empty() {
body["assigned_to"] = serde_json::Value::String(assign);
}
match client
.post(format!("{base_url}/api/comms/task"))
.json(&body)
.send()
{
Ok(resp) => {
if resp.status().is_success() {
let _ = tx.send(AppEvent::CommsTaskResult("Task posted".to_string()));
} else {
let err = resp
.json::<serde_json::Value>()
.ok()
.and_then(|v| v["error"].as_str().map(String::from))
.unwrap_or_else(|| "Post failed".to_string());
let _ = tx.send(AppEvent::CommsTaskResult(err));
}
}
Err(e) => {
let _ = tx.send(AppEvent::CommsTaskResult(format!("Error: {e}")));
}
}
}
BackendRef::InProcess(_) => {
let _ = tx.send(AppEvent::CommsTaskResult(
"Task post not supported in-process".to_string(),
));
}
});
}
+63 -2
View File
@@ -12,8 +12,8 @@ use openfang_kernel::OpenFangKernel;
use openfang_runtime::llm_driver::StreamEvent;
use openfang_types::agent::AgentId;
use screens::{
agents, audit, channels, chat, dashboard, extensions, hands, logs, memory, peers, security,
sessions, settings, skills, templates, triggers, usage, welcome, wizard, workflows,
agents, audit, channels, chat, comms, dashboard, extensions, hands, logs, memory, peers,
security, sessions, settings, skills, templates, triggers, usage, welcome, wizard, workflows,
};
use std::path::PathBuf;
use std::sync::{mpsc, Arc};
@@ -53,6 +53,7 @@ enum Tab {
Extensions,
Templates,
Peers,
Comms,
Security,
Audit,
Usage,
@@ -74,6 +75,7 @@ const TABS: &[Tab] = &[
Tab::Extensions,
Tab::Templates,
Tab::Peers,
Tab::Comms,
Tab::Security,
Tab::Audit,
Tab::Usage,
@@ -97,6 +99,7 @@ impl Tab {
Tab::Extensions => "Extensions",
Tab::Templates => "Templates",
Tab::Peers => "Peers",
Tab::Comms => "Comms",
Tab::Security => "Security",
Tab::Audit => "Audit",
Tab::Usage => "Usage",
@@ -169,6 +172,7 @@ struct App {
usage: usage::UsageState,
settings: settings::SettingsState,
peers: peers::PeersState,
comms: comms::CommsState,
logs: logs::LogsState,
kernel_booting: bool,
@@ -207,6 +211,7 @@ impl App {
usage: usage::UsageState::new(),
settings: settings::SettingsState::new(),
peers: peers::PeersState::new(),
comms: comms::CommsState::new(),
logs: logs::LogsState::new(),
kernel_booting: false,
kernel_boot_error: None,
@@ -504,6 +509,26 @@ impl App {
}
self.peers.loading = false;
}
AppEvent::CommsTopologyLoaded { nodes, edges } => {
self.comms.nodes = nodes;
self.comms.edges = edges;
self.comms.loading = false;
}
AppEvent::CommsEventsLoaded(events) => {
self.comms.events = events;
if !self.comms.events.is_empty()
&& self.comms.event_list_state.selected().is_none()
{
self.comms.event_list_state.select(Some(0));
}
}
AppEvent::CommsSendResult(msg) => {
self.comms.status_msg = msg;
self.refresh_comms();
}
AppEvent::CommsTaskResult(msg) => {
self.comms.status_msg = msg;
}
AppEvent::LogsLoaded(entries) => {
self.logs.entries = entries;
self.logs.refilter();
@@ -845,6 +870,10 @@ impl App {
let action = self.peers.handle_key(key);
self.handle_peers_action(action);
}
Tab::Comms => {
let action = self.comms.handle_key(key);
self.handle_comms_action(action);
}
Tab::Logs => {
let action = self.logs.handle_key(key);
self.handle_logs_action(action);
@@ -876,6 +905,7 @@ impl App {
self.usage.tick();
self.settings.tick();
self.peers.tick();
self.comms.tick();
self.logs.tick();
// Auto-poll for active tabs
@@ -883,6 +913,7 @@ impl App {
match self.active_tab {
Tab::Logs if self.logs.should_poll() => self.refresh_logs(),
Tab::Peers if self.peers.should_poll() => self.refresh_peers(),
Tab::Comms if self.comms.should_poll() => self.refresh_comms(),
_ => {}
}
}
@@ -932,6 +963,7 @@ impl App {
Tab::Usage => self.refresh_usage(),
Tab::Settings => self.refresh_settings_providers(),
Tab::Peers => self.refresh_peers(),
Tab::Comms => self.refresh_comms(),
Tab::Logs => self.refresh_logs(),
Tab::Chat => {} // Chat doesn't need refresh on enter
}
@@ -1089,6 +1121,13 @@ impl App {
}
}
fn refresh_comms(&mut self) {
if let Some(backend) = self.backend.to_ref() {
self.comms.loading = true;
event::spawn_fetch_comms(backend, self.event_tx.clone());
}
}
fn refresh_logs(&mut self) {
if let Some(backend) = self.backend.to_ref() {
self.logs.loading = true;
@@ -1660,6 +1699,27 @@ impl App {
}
}
fn handle_comms_action(&mut self, action: comms::CommsAction) {
match action {
comms::CommsAction::Continue => {}
comms::CommsAction::Refresh => self.refresh_comms(),
comms::CommsAction::SendMessage { from, to, msg } => {
if let Some(backend) = self.backend.to_ref() {
event::spawn_comms_send(backend, from, to, msg, self.event_tx.clone());
}
}
comms::CommsAction::PostTask {
title,
desc,
assign,
} => {
if let Some(backend) = self.backend.to_ref() {
event::spawn_comms_task(backend, title, desc, assign, self.event_tx.clone());
}
}
}
}
fn handle_logs_action(&mut self, action: logs::LogsAction) {
match action {
logs::LogsAction::Continue => {}
@@ -2029,6 +2089,7 @@ impl App {
Tab::Usage => usage::draw(frame, chunks[1], &mut self.usage),
Tab::Settings => settings::draw(frame, chunks[1], &mut self.settings),
Tab::Peers => peers::draw(frame, chunks[1], &mut self.peers),
Tab::Comms => comms::draw(frame, chunks[1], &mut self.comms),
Tab::Logs => logs::draw(frame, chunks[1], &mut self.logs),
}
}
@@ -0,0 +1,763 @@
//! Comms screen: Agent communication topology + live event feed.
use crate::tui::theme;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Padding, Paragraph};
use ratatui::Frame;
// ── Data types ──────────────────────────────────────────────────────────────
#[derive(Clone, Default)]
pub struct CommsNode {
pub id: String,
pub name: String,
pub state: String,
pub model: String,
}
#[derive(Clone, Default)]
pub struct CommsEdge {
pub from: String,
pub to: String,
pub kind: String, // "parent_child" or "peer"
}
#[derive(Clone, Default)]
pub struct CommsEventItem {
/// Event ID — used by the dashboard for dedup, kept for wire compat.
#[allow(dead_code)]
pub id: String,
pub timestamp: String,
pub kind: String,
pub source_name: String,
pub target_name: String,
pub detail: String,
}
// ── State ───────────────────────────────────────────────────────────────────
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum CommsFocus {
Topology,
EventList,
}
pub struct CommsState {
pub nodes: Vec<CommsNode>,
pub edges: Vec<CommsEdge>,
pub events: Vec<CommsEventItem>,
pub event_list_state: ListState,
pub focus: CommsFocus,
pub loading: bool,
pub tick: usize,
pub poll_tick: usize,
// Send modal
pub show_send_modal: bool,
pub send_from: String,
pub send_to: String,
pub send_msg: String,
pub send_field: usize,
// Task modal
pub show_task_modal: bool,
pub task_title: String,
pub task_desc: String,
pub task_assign: String,
pub task_field: usize,
// Status
pub status_msg: String,
}
pub enum CommsAction {
Continue,
Refresh,
SendMessage {
from: String,
to: String,
msg: String,
},
PostTask {
title: String,
desc: String,
assign: String,
},
}
impl CommsState {
pub fn new() -> Self {
Self {
nodes: Vec::new(),
edges: Vec::new(),
events: Vec::new(),
event_list_state: ListState::default(),
focus: CommsFocus::Topology,
loading: false,
tick: 0,
poll_tick: 0,
show_send_modal: false,
send_from: String::new(),
send_to: String::new(),
send_msg: String::new(),
send_field: 0,
show_task_modal: false,
task_title: String::new(),
task_desc: String::new(),
task_assign: String::new(),
task_field: 0,
status_msg: String::new(),
}
}
pub fn tick(&mut self) {
self.tick = self.tick.wrapping_add(1);
self.poll_tick = self.poll_tick.wrapping_add(1);
}
/// Auto-refresh every ~5s at 20fps tick rate.
pub fn should_poll(&self) -> bool {
self.poll_tick > 0 && self.poll_tick.is_multiple_of(100)
}
pub fn handle_key(&mut self, key: KeyEvent) -> CommsAction {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return CommsAction::Continue;
}
// Modal key handling
if self.show_send_modal {
return self.handle_send_modal_key(key);
}
if self.show_task_modal {
return self.handle_task_modal_key(key);
}
match key.code {
KeyCode::Tab => {
self.focus = match self.focus {
CommsFocus::Topology => CommsFocus::EventList,
CommsFocus::EventList => CommsFocus::Topology,
};
}
KeyCode::Char('s') => {
self.show_send_modal = true;
self.send_from.clear();
self.send_to.clear();
self.send_msg.clear();
self.send_field = 0;
}
KeyCode::Char('t') => {
self.show_task_modal = true;
self.task_title.clear();
self.task_desc.clear();
self.task_assign.clear();
self.task_field = 0;
}
KeyCode::Char('r') => return CommsAction::Refresh,
KeyCode::Up | KeyCode::Char('k') => {
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
let i = self.event_list_state.selected().unwrap_or(0);
let next = if i == 0 {
self.events.len() - 1
} else {
i - 1
};
self.event_list_state.select(Some(next));
}
}
KeyCode::Down | KeyCode::Char('j') => {
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
let i = self.event_list_state.selected().unwrap_or(0);
let next = (i + 1) % self.events.len();
self.event_list_state.select(Some(next));
}
}
_ => {}
}
CommsAction::Continue
}
fn handle_send_modal_key(&mut self, key: KeyEvent) -> CommsAction {
match key.code {
KeyCode::Esc => {
self.show_send_modal = false;
}
KeyCode::Tab => {
self.send_field = (self.send_field + 1) % 3;
}
KeyCode::BackTab => {
self.send_field = if self.send_field == 0 {
2
} else {
self.send_field - 1
};
}
KeyCode::Enter => {
if !self.send_from.is_empty()
&& !self.send_to.is_empty()
&& !self.send_msg.is_empty()
{
self.show_send_modal = false;
return CommsAction::SendMessage {
from: self.send_from.clone(),
to: self.send_to.clone(),
msg: self.send_msg.clone(),
};
}
}
KeyCode::Char(c) => match self.send_field {
0 => self.send_from.push(c),
1 => self.send_to.push(c),
_ => self.send_msg.push(c),
},
KeyCode::Backspace => match self.send_field {
0 => {
self.send_from.pop();
}
1 => {
self.send_to.pop();
}
_ => {
self.send_msg.pop();
}
},
_ => {}
}
CommsAction::Continue
}
fn handle_task_modal_key(&mut self, key: KeyEvent) -> CommsAction {
match key.code {
KeyCode::Esc => {
self.show_task_modal = false;
}
KeyCode::Tab => {
self.task_field = (self.task_field + 1) % 3;
}
KeyCode::BackTab => {
self.task_field = if self.task_field == 0 {
2
} else {
self.task_field - 1
};
}
KeyCode::Enter => {
if !self.task_title.is_empty() {
self.show_task_modal = false;
return CommsAction::PostTask {
title: self.task_title.clone(),
desc: self.task_desc.clone(),
assign: self.task_assign.clone(),
};
}
}
KeyCode::Char(c) => match self.task_field {
0 => self.task_title.push(c),
1 => self.task_desc.push(c),
_ => self.task_assign.push(c),
},
KeyCode::Backspace => match self.task_field {
0 => {
self.task_title.pop();
}
1 => {
self.task_desc.pop();
}
_ => {
self.task_assign.pop();
}
},
_ => {}
}
CommsAction::Continue
}
// ── Topology helpers ─────────────────────────────────────────────────────
fn root_nodes(&self) -> Vec<&CommsNode> {
let child_ids: std::collections::HashSet<&str> = self
.edges
.iter()
.filter(|e| e.kind == "parent_child")
.map(|e| e.to.as_str())
.collect();
self.nodes
.iter()
.filter(|n| !child_ids.contains(n.id.as_str()))
.collect()
}
fn children_of(&self, id: &str) -> Vec<&CommsNode> {
let child_ids: Vec<&str> = self
.edges
.iter()
.filter(|e| e.kind == "parent_child" && e.from == id)
.map(|e| e.to.as_str())
.collect();
self.nodes
.iter()
.filter(|n| child_ids.contains(&n.id.as_str()))
.collect()
}
fn peers_of(&self, id: &str) -> Vec<&CommsNode> {
let peer_ids: std::collections::HashSet<&str> = self
.edges
.iter()
.filter(|e| e.kind == "peer")
.filter_map(|e| {
if e.from == id {
Some(e.to.as_str())
} else if e.to == id {
Some(e.from.as_str())
} else {
None
}
})
.collect();
self.nodes
.iter()
.filter(|n| peer_ids.contains(n.id.as_str()))
.collect()
}
}
// ── Drawing ─────────────────────────────────────────────────────────────────
pub fn draw(f: &mut Frame, area: Rect, state: &mut CommsState) {
let block = Block::default()
.title(Line::from(vec![Span::styled(
" Comms ",
theme::title_style(),
)]))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::ACCENT))
.padding(Padding::horizontal(1));
let inner = block.inner(area);
f.render_widget(block, area);
let chunks = Layout::vertical([
Constraint::Length(2), // header
Constraint::Length(1), // separator
Constraint::Percentage(35), // topology
Constraint::Length(1), // separator
Constraint::Min(4), // event list
Constraint::Length(1), // hints
])
.split(inner);
// Header
f.render_widget(
Paragraph::new(vec![
Line::from(vec![Span::styled(
format!(
" Agent Topology ({} agents, {} edges)",
state.nodes.len(),
state.edges.len()
),
Style::default()
.fg(theme::CYAN)
.add_modifier(Modifier::BOLD),
)]),
Line::from(""),
]),
chunks[0],
);
// Separator
f.render_widget(
Paragraph::new(Line::from(Span::styled(
"\u{2500}".repeat(inner.width as usize),
theme::dim_style(),
))),
chunks[1],
);
// Topology tree
draw_topology(f, chunks[2], state);
// Separator
let event_label = if state.focus == CommsFocus::EventList {
" \u{25b6} Live Event Feed"
} else {
" Live Event Feed"
};
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
event_label,
Style::default()
.fg(theme::CYAN)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" ({} events)", state.events.len()),
theme::dim_style(),
),
])),
chunks[3],
);
// Event list
draw_event_list(f, chunks[4], state);
// Status message or hints
let hint_text = if !state.status_msg.is_empty() {
format!(
" {} | [s]end [t]ask [r]efresh [Tab] focus [\u{2191}\u{2193}] scroll",
state.status_msg
)
} else {
" [s]end [t]ask [r]efresh [Tab] focus [\u{2191}\u{2193}] scroll".to_string()
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(hint_text, theme::hint_style()))),
chunks[5],
);
// Modal overlays
if state.show_send_modal {
draw_send_modal(f, area, state);
}
if state.show_task_modal {
draw_task_modal(f, area, state);
}
}
fn draw_topology(f: &mut Frame, area: Rect, state: &CommsState) {
if state.loading && state.nodes.is_empty() {
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
Span::styled("Loading topology\u{2026}", theme::dim_style()),
])),
area,
);
return;
}
if state.nodes.is_empty() {
f.render_widget(
Paragraph::new(Span::styled(
" No agents running.",
theme::dim_style(),
)),
area,
);
return;
}
let focus_highlight = state.focus == CommsFocus::Topology;
let mut lines = Vec::new();
for root in state.root_nodes() {
let state_style = state_color(&root.state);
let mut spans = vec![
Span::styled(" ", Style::default()),
Span::styled(format!("[{}]", &root.state), state_style),
Span::styled(
format!(" {} ", root.name),
Style::default()
.fg(if focus_highlight {
theme::CYAN
} else {
theme::TEXT
})
.add_modifier(Modifier::BOLD),
),
Span::styled(format!("({})", root.model), theme::dim_style()),
];
// Peer annotations
for peer in state.peers_of(&root.id) {
spans.push(Span::styled(
format!(" \u{2194} {}", peer.name),
Style::default().fg(theme::PURPLE),
));
}
lines.push(Line::from(spans));
// Children
let children = state.children_of(&root.id);
for (i, child) in children.iter().enumerate() {
let branch = if i < children.len() - 1 {
"\u{251c}\u{2500}\u{2500} "
} else {
"\u{2514}\u{2500}\u{2500} "
};
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(branch, theme::dim_style()),
Span::styled(format!("[{}]", child.state), state_color(&child.state)),
Span::styled(format!(" {} ", child.name), Style::default().fg(theme::TEXT)),
Span::styled(format!("({})", child.model), theme::dim_style()),
]));
}
}
f.render_widget(Paragraph::new(lines), area);
}
fn draw_event_list(f: &mut Frame, area: Rect, state: &mut CommsState) {
if state.events.is_empty() {
f.render_widget(
Paragraph::new(Span::styled(
" No inter-agent events yet.",
theme::dim_style(),
)),
area,
);
return;
}
let items: Vec<ListItem> = state
.events
.iter()
.map(|ev| {
let kind_style = kind_color(&ev.kind);
let kind_label = kind_short(&ev.kind);
let target_part = if ev.target_name.is_empty() {
String::new()
} else {
format!(" \u{2192} {}", ev.target_name)
};
let detail = truncate(&ev.detail, 50);
ListItem::new(Line::from(vec![
Span::styled(
format!(" {:<8}", short_time(&ev.timestamp)),
theme::dim_style(),
),
Span::styled(format!(" {:<10}", kind_label), kind_style),
Span::styled(
format!(" {}", ev.source_name),
Style::default()
.fg(theme::CYAN)
.add_modifier(Modifier::BOLD),
),
Span::styled(target_part, Style::default().fg(theme::PURPLE)),
Span::styled(format!(" {detail}"), theme::dim_style()),
]))
})
.collect();
let list = List::new(items)
.highlight_style(theme::selected_style())
.highlight_symbol("> ");
f.render_stateful_widget(list, area, &mut state.event_list_state);
}
fn draw_send_modal(f: &mut Frame, area: Rect, state: &CommsState) {
let modal = centered_rect(50, 12, area);
f.render_widget(Clear, modal);
let block = Block::default()
.title(Span::styled(" Send Message ", theme::title_style()))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::ACCENT))
.padding(Padding::uniform(1));
let inner = block.inner(modal);
f.render_widget(block, modal);
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.split(inner);
let field_style = |idx: usize| {
if state.send_field == idx {
Style::default()
.fg(theme::CYAN)
.add_modifier(Modifier::BOLD)
} else {
theme::dim_style()
}
};
f.render_widget(
Paragraph::new(Span::styled("From (agent ID):", field_style(0))),
rows[0],
);
f.render_widget(
Paragraph::new(Span::styled(
format!(" {}\u{2588}", &state.send_from),
Style::default().fg(theme::TEXT),
)),
rows[1],
);
f.render_widget(
Paragraph::new(Span::styled("To (agent ID):", field_style(1))),
rows[2],
);
f.render_widget(
Paragraph::new(Span::styled(
format!(" {}\u{2588}", &state.send_to),
Style::default().fg(theme::TEXT),
)),
rows[3],
);
f.render_widget(
Paragraph::new(Span::styled("Message:", field_style(2))),
rows[4],
);
f.render_widget(
Paragraph::new(Span::styled(
format!(" {}\u{2588}", &state.send_msg),
Style::default().fg(theme::TEXT),
)),
rows[5],
);
f.render_widget(
Paragraph::new(Span::styled(
"[Tab] field [Enter] send [Esc] cancel",
theme::hint_style(),
)),
rows[6],
);
}
fn draw_task_modal(f: &mut Frame, area: Rect, state: &CommsState) {
let modal = centered_rect(50, 12, area);
f.render_widget(Clear, modal);
let block = Block::default()
.title(Span::styled(" Post Task ", theme::title_style()))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::ACCENT))
.padding(Padding::uniform(1));
let inner = block.inner(modal);
f.render_widget(block, modal);
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.split(inner);
let field_style = |idx: usize| {
if state.task_field == idx {
Style::default()
.fg(theme::CYAN)
.add_modifier(Modifier::BOLD)
} else {
theme::dim_style()
}
};
f.render_widget(
Paragraph::new(Span::styled("Title:", field_style(0))),
rows[0],
);
f.render_widget(
Paragraph::new(Span::styled(
format!(" {}\u{2588}", &state.task_title),
Style::default().fg(theme::TEXT),
)),
rows[1],
);
f.render_widget(
Paragraph::new(Span::styled("Description:", field_style(1))),
rows[2],
);
f.render_widget(
Paragraph::new(Span::styled(
format!(" {}\u{2588}", &state.task_desc),
Style::default().fg(theme::TEXT),
)),
rows[3],
);
f.render_widget(
Paragraph::new(Span::styled("Assign to (agent ID, optional):", field_style(2))),
rows[4],
);
f.render_widget(
Paragraph::new(Span::styled(
format!(" {}\u{2588}", &state.task_assign),
Style::default().fg(theme::TEXT),
)),
rows[5],
);
f.render_widget(
Paragraph::new(Span::styled(
"[Tab] field [Enter] post [Esc] cancel",
theme::hint_style(),
)),
rows[6],
);
}
// ── Helpers ─────────────────────────────────────────────────────────────────
fn state_color(state: &str) -> Style {
match state {
"Running" => Style::default().fg(theme::GREEN),
"Suspended" => Style::default().fg(theme::YELLOW),
"Terminated" | "Crashed" => Style::default().fg(theme::RED),
_ => theme::dim_style(),
}
}
fn kind_color(kind: &str) -> Style {
match kind {
"agent_message" => Style::default().fg(theme::CYAN),
"agent_spawned" => Style::default().fg(theme::GREEN),
"agent_terminated" => Style::default().fg(theme::RED),
"task_posted" => Style::default().fg(theme::YELLOW),
"task_claimed" => Style::default().fg(theme::CYAN),
"task_completed" => Style::default().fg(theme::GREEN),
_ => theme::dim_style(),
}
}
fn kind_short(kind: &str) -> &str {
match kind {
"agent_message" => "MSG",
"agent_spawned" => "SPAWNED",
"agent_terminated" => "KILLED",
"task_posted" => "TASK+",
"task_claimed" => "CLAIM",
"task_completed" => "DONE",
_ => kind,
}
}
fn short_time(ts: &str) -> String {
// Extract HH:MM:SS from ISO-8601
if let Some(t_pos) = ts.find('T') {
let time_part = &ts[t_pos + 1..];
if time_part.len() >= 8 {
return time_part[..8].to_string();
}
}
ts.chars().take(8).collect()
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
fn centered_rect(percent_x: u16, height: u16, area: Rect) -> Rect {
let w = area.width * percent_x / 100;
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
Rect::new(x, y, w, height.min(area.height))
}
@@ -408,7 +408,7 @@ impl State {
for m in &models {
match m.tier {
ModelTier::Fast | ModelTier::Local => {
ModelTier::Fast | ModelTier::Local | ModelTier::Custom => {
if fast.is_none() {
fast = Some(&m.id);
}
@@ -461,6 +461,7 @@ fn tier_label(tier: ModelTier) -> &'static str {
ModelTier::Balanced => "balanced",
ModelTier::Fast => "fast",
ModelTier::Local => "local",
ModelTier::Custom => "custom",
}
}
@@ -1,6 +1,7 @@
pub mod agents;
pub mod audit;
pub mod channels;
pub mod comms;
pub mod chat;
pub mod dashboard;
pub mod extensions;
+1
View File
@@ -34,6 +34,7 @@ pub const PURPLE: Color = Color::Rgb(168, 85, 247); // #A855F7 — decorators
pub const CYAN: Color = BLUE;
pub const DIM: Color = TEXT_SECONDARY;
pub const TEXT: Color = TEXT_PRIMARY;
// ── Reusable styles ─────────────────────────────────────────────────────────
+15
View File
@@ -230,6 +230,21 @@ impl HandRegistry {
.collect())
}
/// Update config for an active hand instance.
pub fn update_config(
&self,
instance_id: Uuid,
config: HashMap<String, serde_json::Value>,
) -> HandResult<()> {
let mut entry = self
.instances
.get_mut(&instance_id)
.ok_or(HandError::InstanceNotFound(instance_id))?;
entry.config = config;
entry.updated_at = chrono::Utc::now();
Ok(())
}
/// Mark an instance as errored.
pub fn set_error(&self, instance_id: Uuid, message: String) -> HandResult<()> {
let mut entry = self
+2
View File
@@ -181,6 +181,7 @@ mod tests {
require_approval: vec!["file_write".to_string(), "file_delete".to_string()],
timeout_secs: 30,
auto_approve_autonomous: false,
auto_approve: false,
};
let mgr = ApprovalManager::new(policy);
assert!(mgr.requires_approval("file_write"));
@@ -258,6 +259,7 @@ mod tests {
require_approval: vec!["file_write".to_string()],
timeout_secs: 120,
auto_approve_autonomous: true,
auto_approve: false,
};
mgr.update_policy(new_policy);
+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));
}
// -----------------------------------------------------------------------
+48 -9
View File
@@ -234,9 +234,9 @@ impl CronScheduler {
if meta.job.enabled && meta.job.next_run.map(|t| t <= now).unwrap_or(false) {
due.push(meta.job.clone());
// Pre-advance next_run so the job won't fire again on the next
// tick while it's still executing. record_success/record_failure
// will recompute it again after execution completes.
meta.job.next_run = Some(compute_next_run(&meta.job.schedule));
// tick while it's still executing. Use `now` as the base so the
// next fire time is computed strictly after the current moment.
meta.job.next_run = Some(compute_next_run_after(&meta.job.schedule, now));
}
}
due
@@ -287,7 +287,8 @@ impl CronScheduler {
);
meta.job.enabled = false;
} else {
meta.job.next_run = Some(compute_next_run(&meta.job.schedule));
meta.job.next_run =
Some(compute_next_run_after(&meta.job.schedule, Utc::now()));
}
}
}
@@ -297,7 +298,7 @@ impl CronScheduler {
// compute_next_run
// ---------------------------------------------------------------------------
/// Compute the next fire time for a schedule.
/// Compute the next fire time for a schedule, based on `now`.
///
/// - `At { at }` — returns `at` directly.
/// - `Every { every_secs }` — returns `now + every_secs`.
@@ -306,9 +307,23 @@ impl CronScheduler {
/// 6-field (`sec min hour dom month dow`) formats by converting to the
/// 7-field format required by the `cron` crate.
pub fn compute_next_run(schedule: &CronSchedule) -> chrono::DateTime<Utc> {
compute_next_run_after(schedule, Utc::now())
}
/// Compute the next fire time for a schedule, strictly after `after`.
///
/// Uses `after + 1 second` as the base time so the `cron` crate's
/// inclusive `.after()` always returns a strictly future time. Without
/// this offset, calling `compute_next_run` right after a job fires can
/// return the same minute (or even the same second), causing the
/// scheduler to re-fire immediately.
pub fn compute_next_run_after(
schedule: &CronSchedule,
after: chrono::DateTime<Utc>,
) -> chrono::DateTime<Utc> {
match schedule {
CronSchedule::At { at } => *at,
CronSchedule::Every { every_secs } => Utc::now() + Duration::seconds(*every_secs as i64),
CronSchedule::Every { every_secs } => after + Duration::seconds(*every_secs as i64),
CronSchedule::Cron { expr, tz: _ } => {
// Convert standard 5/6-field cron to 7-field for the `cron` crate.
// Standard 5-field: min hour dom month dow
@@ -322,14 +337,17 @@ pub fn compute_next_run(schedule: &CronSchedule) -> chrono::DateTime<Utc> {
_ => expr.clone(),
};
// Add 1 second so `.after()` (inclusive) skips the current second.
let base = after + Duration::seconds(1);
match seven_field.parse::<cron::Schedule>() {
Ok(sched) => sched
.after(&Utc::now())
.after(&base)
.next()
.unwrap_or_else(|| Utc::now() + Duration::hours(1)),
.unwrap_or_else(|| after + Duration::hours(1)),
Err(e) => {
warn!("Failed to parse cron expression '{}': {}", expr, e);
Utc::now() + Duration::hours(1)
after + Duration::hours(1)
}
}
}
@@ -729,6 +747,27 @@ mod tests {
// -- error message truncation in record_failure -------------------------
#[test]
fn test_compute_next_run_after_skips_current_second() {
// A "every 4 hours" cron: next_run should be >= 4 hours from now,
// not in the same minute (the bug from #55).
let schedule = CronSchedule::Cron {
expr: "0 */4 * * *".into(),
tz: None,
};
let now = Utc::now();
let next = compute_next_run_after(&schedule, now);
// Must be strictly after `now` and at least ~1 hour away
// (the closest 4-hourly boundary is at least minutes away).
assert!(next > now, "next_run should be strictly after now");
let diff = next - now;
assert!(
diff.num_minutes() >= 1,
"Expected next_run at least 1 min away, got {} seconds",
diff.num_seconds()
);
}
#[test]
fn test_record_failure_truncates_long_error() {
let (sched, _tmp) = make_scheduler(100);
+350 -49
View File
@@ -14,7 +14,9 @@ use crate::triggers::{TriggerEngine, TriggerId, TriggerPattern};
use crate::workflow::{StepAgent, Workflow, WorkflowEngine, WorkflowId, WorkflowRunId};
use openfang_memory::MemorySubstrate;
use openfang_runtime::agent_loop::{run_agent_loop, run_agent_loop_streaming, AgentLoopResult};
use openfang_runtime::agent_loop::{
run_agent_loop, run_agent_loop_streaming, strip_provider_prefix, AgentLoopResult,
};
use openfang_runtime::audit::AuditLog;
use openfang_runtime::drivers;
use openfang_runtime::kernel_handle::{self, KernelHandle};
@@ -129,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>>,
}
@@ -446,7 +450,7 @@ fn read_identity_file(workspace: &Path, filename: &str) -> Option<String> {
return None;
}
if content.len() > MAX_IDENTITY_FILE_BYTES {
Some(content[..MAX_IDENTITY_FILE_BYTES].to_string())
Some(openfang_types::truncate_str(&content, MAX_IDENTITY_FILE_BYTES).to_string())
} else {
Some(content)
}
@@ -483,6 +487,11 @@ impl OpenFangKernel {
pub fn boot_with_config(mut config: KernelConfig) -> KernelResult<Self> {
use openfang_types::config::KernelMode;
// Env var overrides — useful for Docker where config.toml is baked in.
if let Ok(listen) = std::env::var("OPENFANG_LISTEN") {
config.api_listen = listen;
}
// Clamp configuration bounds to prevent zero-value or unbounded misconfigs
config.clamp_bounds();
@@ -523,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}")))?;
@@ -539,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) => {
@@ -598,6 +614,9 @@ impl OpenFangKernel {
config.provider_urls.len()
);
}
// Load user's custom models from ~/.openfang/custom_models.json
let custom_models_path = config.home_dir.join("custom_models.json");
model_catalog.load_custom_models(&custom_models_path);
let available_count = model_catalog.available_models().len();
let total_count = model_catalog.list_models().len();
let local_count = model_catalog
@@ -873,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(),
};
@@ -884,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);
@@ -902,6 +977,33 @@ impl OpenFangKernel {
restored_entry.manifest.exec_policy =
Some(kernel.config.exec_policy.clone());
}
// Apply global budget defaults to restored agents
apply_budget_defaults(
&kernel.config.budget,
&mut restored_entry.manifest.resources,
);
// Apply default_model to restored agents (same logic as spawn)
if restored_entry.manifest.model.api_key_env.is_none()
&& restored_entry.manifest.model.base_url.is_none()
{
let dm = &kernel.config.default_model;
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();
}
}
}
if let Err(e) = kernel.registry.register(restored_entry) {
tracing::warn!(agent = %name, "Failed to restore agent: {e}");
} else {
@@ -963,29 +1065,47 @@ impl OpenFangKernel {
if manifest.exec_policy.is_none() {
manifest.exec_policy = Some(self.config.exec_policy.clone());
}
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();
}
}
}
// Create workspace directory for the agent
// Normalize: strip provider prefix from model name if present
let normalized = strip_provider_prefix(&manifest.model.model, &manifest.model.provider);
if normalized != manifest.model.model {
manifest.model.model = normalized;
}
// Apply global budget defaults to agent resource quotas
apply_budget_defaults(&self.config.budget, &mut manifest.resources);
// Create workspace directory for the agent (name-based, so SOUL.md survives recreation)
let workspace_dir = manifest.workspace.clone().unwrap_or_else(|| {
self.config.effective_workspaces_dir().join(format!(
"{}-{}",
&name,
&agent_id.0.to_string()[..8]
))
self.config.effective_workspaces_dir().join(&name)
});
ensure_workspace(&workspace_dir)?;
if manifest.generate_identity_files {
@@ -1309,11 +1429,7 @@ impl OpenFangKernel {
// Lazy backfill: create workspace for existing agents spawned before workspaces
if manifest.workspace.is_none() {
let workspace_dir = self.config.effective_workspaces_dir().join(format!(
"{}-{}",
&manifest.name,
&agent_id.0.to_string()[..8]
));
let workspace_dir = self.config.effective_workspaces_dir().join(&manifest.name);
if let Err(e) = ensure_workspace(&workspace_dir) {
warn!(agent_id = %agent_id, "Failed to backfill workspace (streaming): {e}");
} else {
@@ -1335,6 +1451,19 @@ impl OpenFangKernel {
.flatten()
.and_then(|v| v.as_str().map(String::from));
let peer_agents: Vec<(String, String, String)> = self
.registry
.list()
.iter()
.map(|a| {
(
a.name.clone(),
format!("{:?}", a.state),
a.manifest.model.model.clone(),
)
})
.collect();
let prompt_ctx = openfang_runtime::prompt_builder::PromptContext {
agent_name: manifest.name.clone(),
agent_description: manifest.description.clone(),
@@ -1399,6 +1528,8 @@ impl OpenFangKernel {
} else {
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);
@@ -1766,11 +1897,7 @@ impl OpenFangKernel {
// Lazy backfill: create workspace for existing agents spawned before workspaces
if manifest.workspace.is_none() {
let workspace_dir = self.config.effective_workspaces_dir().join(format!(
"{}-{}",
&manifest.name,
&agent_id.0.to_string()[..8]
));
let workspace_dir = self.config.effective_workspaces_dir().join(&manifest.name);
if let Err(e) = ensure_workspace(&workspace_dir) {
warn!(agent_id = %agent_id, "Failed to backfill workspace: {e}");
} else {
@@ -1793,6 +1920,19 @@ impl OpenFangKernel {
.flatten()
.and_then(|v| v.as_str().map(String::from));
let peer_agents: Vec<(String, String, String)> = self
.registry
.list()
.iter()
.map(|a| {
(
a.name.clone(),
format!("{:?}", a.state),
a.manifest.model.model.clone(),
)
})
.collect();
let prompt_ctx = openfang_runtime::prompt_builder::PromptContext {
agent_name: manifest.name.clone(),
agent_description: manifest.description.clone(),
@@ -1857,6 +1997,8 @@ impl OpenFangKernel {
} else {
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);
@@ -1890,7 +2032,7 @@ impl OpenFangKernel {
router.resolve_aliases(&self.model_catalog.read().unwrap_or_else(|e| e.into_inner()));
// Build a probe request to score complexity
let probe = CompletionRequest {
model: manifest.model.model.clone(),
model: strip_provider_prefix(&manifest.model.model, &manifest.model.provider),
messages: vec![openfang_types::message::Message::user(message)],
tools: tools.clone(),
max_tokens: manifest.model.max_tokens,
@@ -2076,6 +2218,35 @@ impl OpenFangKernel {
Ok(())
}
/// Clear ALL conversation history for an agent (sessions + canonical).
///
/// Creates a fresh empty session afterward so the agent is still usable.
pub fn clear_agent_history(&self, agent_id: AgentId) -> KernelResult<()> {
let _entry = self.registry.get(agent_id).ok_or_else(|| {
KernelError::OpenFang(OpenFangError::AgentNotFound(agent_id.to_string()))
})?;
// Delete all regular sessions
let _ = self.memory.delete_agent_sessions(agent_id);
// Delete canonical (cross-channel) session
let _ = self.memory.delete_canonical_session(agent_id);
// Create a fresh session
let new_session = self
.memory
.create_session(agent_id)
.map_err(KernelError::OpenFang)?;
// Update registry with new session ID
self.registry
.update_session_id(agent_id, new_session.id)
.map_err(KernelError::OpenFang)?;
info!(agent_id = %agent_id, "All agent history cleared");
Ok(())
}
/// List all sessions for a specific agent.
pub fn list_agent_sessions(&self, agent_id: AgentId) -> KernelResult<Vec<serde_json::Value>> {
// Verify agent exists
@@ -2255,16 +2426,23 @@ impl OpenFangKernel {
// If catalog lookup failed, try to infer provider from model name prefix
let provider = resolved_provider.or_else(|| infer_provider_from_model(model));
// Strip the provider prefix from the model name (e.g. "openrouter/deepseek/deepseek-chat" → "deepseek/deepseek-chat")
let normalized_model = if let Some(ref prov) = provider {
strip_provider_prefix(model, prov)
} else {
model.to_string()
};
if let Some(provider) = provider {
self.registry
.update_model_and_provider(agent_id, model.to_string(), provider.clone())
.update_model_and_provider(agent_id, normalized_model.clone(), provider.clone())
.map_err(KernelError::OpenFang)?;
info!(agent_id = %agent_id, model = %model, provider = %provider, "Agent model+provider updated");
info!(agent_id = %agent_id, model = %normalized_model, provider = %provider, "Agent model+provider updated");
} else {
self.registry
.update_model(agent_id, model.to_string())
.update_model(agent_id, normalized_model.clone())
.map_err(KernelError::OpenFang)?;
info!(agent_id = %agent_id, model = %model, "Agent model updated (provider unchanged)");
info!(agent_id = %agent_id, model = %normalized_model, "Agent model updated (provider unchanged)");
}
// Persist the updated entry
@@ -2272,6 +2450,10 @@ impl OpenFangKernel {
let _ = self.memory.save_agent(&entry);
}
// Clear canonical session to prevent memory poisoning from old model's responses
let _ = self.memory.delete_canonical_session(agent_id);
debug!(agent_id = %agent_id, "Cleared canonical session after model switch");
Ok(())
}
@@ -2344,6 +2526,30 @@ impl OpenFangKernel {
Ok(())
}
/// Update an agent's tool allowlist and/or blocklist.
pub fn set_agent_tool_filters(
&self,
agent_id: AgentId,
allowlist: Option<Vec<String>>,
blocklist: Option<Vec<String>>,
) -> KernelResult<()> {
self.registry
.update_tool_filters(agent_id, allowlist.clone(), blocklist.clone())
.map_err(KernelError::OpenFang)?;
if let Some(entry) = self.registry.get(agent_id) {
let _ = self.memory.save_agent(&entry);
}
info!(
agent_id = %agent_id,
allowlist = ?allowlist,
blocklist = ?blocklist,
"Agent tool filters updated"
);
Ok(())
}
/// Get session token usage and estimated cost for an agent.
pub fn session_usage_cost(&self, agent_id: AgentId) -> KernelResult<(u64, u64, f64)> {
let entry = self.registry.get(agent_id).ok_or_else(|| {
@@ -2686,6 +2892,18 @@ impl OpenFangKernel {
if let Err(e) = self.kill_agent(agent_id) {
warn!(agent = %agent_id, error = %e, "Failed to kill hand agent (may already be dead)");
}
} else {
// Fallback: if agent_id was never set (incomplete activation), search by hand tag
let hand_tag = format!("hand:{}", instance.hand_id);
for entry in self.registry.list() {
if entry.tags.contains(&hand_tag) {
if let Err(e) = self.kill_agent(entry.id) {
warn!(agent = %entry.id, error = %e, "Failed to kill orphaned hand agent");
} else {
info!(agent_id = %entry.id, hand_id = %instance.hand_id, "Cleaned up orphaned hand agent");
}
}
}
}
Ok(())
}
@@ -2802,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
@@ -2956,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
@@ -3158,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
{
@@ -3528,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 {
@@ -3547,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(),
@@ -3555,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);
}
@@ -3566,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),
));
}
}
@@ -3966,6 +4217,31 @@ impl OpenFangKernel {
}
}
// Apply per-agent tool allowlist/blocklist (manifest-level filtering)
let (tool_allowlist, tool_blocklist) = entry
.as_ref()
.map(|e| (e.manifest.tool_allowlist.clone(), e.manifest.tool_blocklist.clone()))
.unwrap_or_default();
if !tool_allowlist.is_empty() {
all_tools.retain(|t| tool_allowlist.iter().any(|a| a == &t.name));
}
if !tool_blocklist.is_empty() {
all_tools.retain(|t| !tool_blocklist.iter().any(|b| b == &t.name));
}
// Remove shell_exec from tool list if exec_policy won't allow it,
// so the LLM doesn't try to call a tool that will be blocked.
let exec_blocks_shell = entry.as_ref().is_some_and(|e| {
e.manifest
.exec_policy
.as_ref()
.is_some_and(|p| p.mode == openfang_types::config::ExecSecurityMode::Deny)
});
if exec_blocks_shell {
all_tools.retain(|t| t.name != "shell_exec");
}
let caps = self.capabilities.list(agent_id);
// If agent has ToolAll, return all tools
@@ -4241,6 +4517,27 @@ fn manifest_to_capabilities(manifest: &AgentManifest) -> Vec<Capability> {
caps
}
/// Apply global budget defaults to an agent's resource quota.
///
/// When the global budget config specifies limits and the agent still has
/// the built-in defaults, override them so agents respect the user's config.
fn apply_budget_defaults(
budget: &openfang_types::config::BudgetConfig,
resources: &mut ResourceQuota,
) {
// Only override hourly if agent has the built-in default (1.0) and global is set
if budget.max_hourly_usd > 0.0 && resources.max_cost_per_hour_usd == 1.0 {
resources.max_cost_per_hour_usd = budget.max_hourly_usd;
}
// Only override daily/monthly if agent has unlimited (0.0) and global is set
if budget.max_daily_usd > 0.0 && resources.max_cost_per_day_usd == 0.0 {
resources.max_cost_per_day_usd = budget.max_daily_usd;
}
if budget.max_monthly_usd > 0.0 && resources.max_cost_per_month_usd == 0.0 {
resources.max_cost_per_month_usd = budget.max_monthly_usd;
}
}
/// Infer provider from a model name when catalog lookup fails.
///
/// Uses well-known model name prefixes to map to the correct provider.
@@ -4966,6 +5263,8 @@ mod tests {
workspace: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: vec![],
tool_blocklist: vec![],
};
manifest.capabilities.tools = vec!["file_read".to_string(), "web_fetch".to_string()];
manifest.capabilities.agent_spawn = true;
@@ -5001,6 +5300,8 @@ mod tests {
workspace: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: vec![],
tool_blocklist: vec![],
}
}
+48
View File
@@ -199,6 +199,27 @@ impl AgentRegistry {
Ok(())
}
/// Update an agent's tool allowlist and blocklist.
pub fn update_tool_filters(
&self,
id: AgentId,
allowlist: Option<Vec<String>>,
blocklist: Option<Vec<String>>,
) -> OpenFangResult<()> {
let mut entry = self
.agents
.get_mut(&id)
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
if let Some(al) = allowlist {
entry.manifest.tool_allowlist = al;
}
if let Some(bl) = blocklist {
entry.manifest.tool_blocklist = bl;
}
entry.last_active = chrono::Utc::now();
Ok(())
}
/// Update an agent's system prompt (hot-swap, takes effect on next message).
pub fn update_system_prompt(&self, id: AgentId, new_prompt: String) -> OpenFangResult<()> {
let mut entry = self
@@ -241,6 +262,31 @@ impl AgentRegistry {
Ok(())
}
/// Update an agent's resource quota (budget limits).
pub fn update_resources(
&self,
id: AgentId,
hourly: Option<f64>,
daily: Option<f64>,
monthly: Option<f64>,
) -> OpenFangResult<()> {
let mut entry = self
.agents
.get_mut(&id)
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
if let Some(v) = hourly {
entry.manifest.resources.max_cost_per_hour_usd = v;
}
if let Some(v) = daily {
entry.manifest.resources.max_cost_per_day_usd = v;
}
if let Some(v) = monthly {
entry.manifest.resources.max_cost_per_month_usd = v;
}
entry.last_active = chrono::Utc::now();
Ok(())
}
/// Mark an agent's onboarding as complete.
pub fn mark_onboarding_complete(&self, id: AgentId) -> OpenFangResult<()> {
let mut entry = self
@@ -295,6 +341,8 @@ mod tests {
workspace: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: vec![],
tool_blocklist: vec![],
},
state: AgentState::Created,
mode: AgentMode::default(),
+2
View File
@@ -179,6 +179,8 @@ impl SetupWizard {
profile: None,
fallback_models: vec![],
exec_policy: None,
tool_allowlist: vec![],
tool_blocklist: vec![],
};
let skills_to_install: Vec<String> = intent
+15
View File
@@ -128,6 +128,20 @@ impl SessionStore {
Ok(())
}
/// Delete the canonical (cross-channel) session for an agent.
pub fn delete_canonical_session(&self, agent_id: AgentId) -> OpenFangResult<()> {
let conn = self
.conn
.lock()
.map_err(|e| OpenFangError::Internal(e.to_string()))?;
conn.execute(
"DELETE FROM canonical_sessions WHERE agent_id = ?1",
rusqlite::params![agent_id.0.to_string()],
)
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
Ok(())
}
/// List all sessions with metadata (session_id, agent_id, message_count, created_at).
pub fn list_sessions(&self) -> OpenFangResult<Vec<serde_json::Value>> {
let conn = self
@@ -556,6 +570,7 @@ impl SessionStore {
}
ContentBlock::ToolResult {
tool_use_id,
tool_name: _,
content,
is_error,
} => {
+10
View File
@@ -164,6 +164,16 @@ impl MemorySubstrate {
self.sessions.delete_session(session_id)
}
/// Delete all sessions belonging to an agent.
pub fn delete_agent_sessions(&self, agent_id: AgentId) -> OpenFangResult<()> {
self.sessions.delete_agent_sessions(agent_id)
}
/// Delete the canonical (cross-channel) session for an agent.
pub fn delete_canonical_session(&self, agent_id: AgentId) -> OpenFangResult<()> {
self.sessions.delete_canonical_session(agent_id)
}
/// Set or clear a session label.
pub fn set_session_label(
&self,
+1
View File
@@ -29,6 +29,7 @@ hex = { workspace = true }
zeroize = { workspace = true }
dashmap = { workspace = true }
regex-lite = { workspace = true }
tokio-tungstenite = "0.24"
[dev-dependencies]
tokio-test = { workspace = true }
+28 -2
View File
@@ -51,6 +51,20 @@ const MAX_CONTINUATIONS: u32 = 5;
/// Maximum message history size before auto-trimming to prevent context overflow.
const MAX_HISTORY_MESSAGES: usize = 20;
/// Strip a provider prefix from a model ID before sending to the API.
///
/// Many models are stored as `provider/org/model` (e.g. `openrouter/google/gemini-2.5-flash`)
/// but the upstream API expects just `org/model`. This also handles special routers
/// like `openrouter/auto` → `auto`.
pub fn strip_provider_prefix(model: &str, provider: &str) -> String {
let prefix = format!("{}/", provider);
if model.starts_with(&prefix) {
model[prefix.len()..].to_string()
} else {
model.to_string()
}
}
/// Default context window size (tokens) for token-based trimming.
const DEFAULT_CONTEXT_WINDOW: usize = 200_000;
@@ -281,8 +295,11 @@ pub async fn run_agent_loop(
// Context guard: compact oversized tool results before LLM call
apply_context_guard(&mut messages, &context_budget, available_tools);
// Strip provider prefix: "openrouter/google/gemini-2.5-flash" → "google/gemini-2.5-flash"
let api_model = strip_provider_prefix(&manifest.model.model, &manifest.model.provider);
let request = CompletionRequest {
model: manifest.model.model.clone(),
model: api_model,
messages: messages.clone(),
tools: available_tools.to_vec(),
max_tokens: manifest.model.max_tokens,
@@ -536,6 +553,7 @@ pub async fn run_agent_loop(
warn!(tool = %tool_call.name, "Tool call blocked by loop guard");
tool_result_blocks.push(ContentBlock::ToolResult {
tool_use_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
content: msg.clone(),
is_error: true,
});
@@ -573,6 +591,7 @@ pub async fn run_agent_loop(
if let Err(reason) = hook_reg.fire(&ctx) {
tool_result_blocks.push(ContentBlock::ToolResult {
tool_use_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
content: format!(
"Hook blocked tool '{}': {}",
tool_call.name, reason
@@ -656,6 +675,7 @@ pub async fn run_agent_loop(
tool_result_blocks.push(ContentBlock::ToolResult {
tool_use_id: result.tool_use_id,
tool_name: tool_call.name.clone(),
content: final_content,
is_error: result.is_error,
});
@@ -1186,8 +1206,11 @@ pub async fn run_agent_loop_streaming(
// Context guard: compact oversized tool results before LLM call
apply_context_guard(&mut messages, &context_budget, available_tools);
// Strip provider prefix: "openrouter/google/gemini-2.5-flash" → "google/gemini-2.5-flash"
let api_model = strip_provider_prefix(&manifest.model.model, &manifest.model.provider);
let request = CompletionRequest {
model: manifest.model.model.clone(),
model: api_model,
messages: messages.clone(),
tools: available_tools.to_vec(),
max_tokens: manifest.model.max_tokens,
@@ -1439,6 +1462,7 @@ pub async fn run_agent_loop_streaming(
warn!(tool = %tool_call.name, "Tool call blocked by loop guard (streaming)");
tool_result_blocks.push(ContentBlock::ToolResult {
tool_use_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
content: msg.clone(),
is_error: true,
});
@@ -1476,6 +1500,7 @@ pub async fn run_agent_loop_streaming(
if let Err(reason) = hook_reg.fire(&ctx) {
tool_result_blocks.push(ContentBlock::ToolResult {
tool_use_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
content: format!(
"Hook blocked tool '{}': {}",
tool_call.name, reason
@@ -1573,6 +1598,7 @@ pub async fn run_agent_loop_streaming(
tool_result_blocks.push(ContentBlock::ToolResult {
tool_use_id: result.tool_use_id,
tool_name: tool_call.name.clone(),
content: final_content,
is_error: result.is_error,
});
File diff suppressed because it is too large Load Diff
@@ -1,188 +0,0 @@
#!/usr/bin/env python3
"""OpenFang Browser Bridge — Playwright automation over JSON-line stdio protocol.
Reads JSON commands from stdin (one per line), executes browser actions via
Playwright, and writes JSON responses to stdout (one per line).
Usage:
python browser_bridge.py [--headless] [--width 1280] [--height 720] [--timeout 30]
"""
import argparse
import base64
import json
import re
import sys
import traceback
def main():
parser = argparse.ArgumentParser(description="OpenFang Browser Bridge")
parser.add_argument("--headless", action="store_true", default=True)
parser.add_argument("--no-headless", dest="headless", action="store_false")
parser.add_argument("--width", type=int, default=1280)
parser.add_argument("--height", type=int, default=720)
parser.add_argument("--timeout", type=int, default=30)
args = parser.parse_args()
timeout_ms = args.timeout * 1000
try:
from playwright.sync_api import sync_playwright
except ImportError:
respond({"success": False, "error": "playwright not installed. Run: pip install playwright && playwright install chromium"})
return
pw = sync_playwright().start()
browser = pw.chromium.launch(headless=args.headless)
context = browser.new_context(
viewport={"width": args.width, "height": args.height},
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
page = context.new_page()
page.set_default_timeout(timeout_ms)
page.set_default_navigation_timeout(timeout_ms)
# Signal ready
respond({"success": True, "data": {"status": "ready"}})
for line in sys.stdin:
line = line.strip()
if not line:
continue
action = None
try:
cmd = json.loads(line)
action = cmd.get("action", "")
result = handle_command(page, context, action, cmd, timeout_ms)
respond(result)
except Exception as e:
respond({"success": False, "error": f"{type(e).__name__}: {e}"})
if action == "Close":
break
# Cleanup
try:
context.close()
browser.close()
pw.stop()
except Exception:
pass
def handle_command(page, context, action, cmd, timeout_ms):
if action == "Navigate":
url = cmd.get("url", "")
if not url:
return {"success": False, "error": "Missing 'url' parameter"}
page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms)
title = page.title()
content = extract_readable(page)
return {"success": True, "data": {"title": title, "url": page.url, "content": content}}
elif action == "Click":
selector = cmd.get("selector", "")
if not selector:
return {"success": False, "error": "Missing 'selector' parameter"}
# Try CSS selector first, then text content
try:
page.click(selector, timeout=timeout_ms)
except Exception:
# Fallback: try as text
page.get_by_text(selector, exact=False).first.click(timeout=timeout_ms)
page.wait_for_load_state("domcontentloaded", timeout=timeout_ms)
title = page.title()
return {"success": True, "data": {"clicked": selector, "title": title, "url": page.url}}
elif action == "Type":
selector = cmd.get("selector", "")
text = cmd.get("text", "")
if not selector:
return {"success": False, "error": "Missing 'selector' parameter"}
if not text:
return {"success": False, "error": "Missing 'text' parameter"}
page.fill(selector, text, timeout=timeout_ms)
return {"success": True, "data": {"typed": text, "selector": selector}}
elif action == "Screenshot":
screenshot_bytes = page.screenshot(full_page=False)
b64 = base64.b64encode(screenshot_bytes).decode("utf-8")
return {"success": True, "data": {"image_base64": b64, "format": "png", "url": page.url}}
elif action == "ReadPage":
title = page.title()
content = extract_readable(page)
return {"success": True, "data": {"title": title, "url": page.url, "content": content}}
elif action == "Close":
return {"success": True, "data": {"status": "closed"}}
else:
return {"success": False, "error": f"Unknown action: {action}"}
def extract_readable(page):
"""Extract readable text content from the page, stripping nav/footer/script noise."""
try:
# Remove script, style, nav, footer, header elements
content = page.evaluate("""() => {
const clone = document.body.cloneNode(true);
const remove = ['script', 'style', 'nav', 'footer', 'header', 'aside',
'iframe', 'noscript', 'svg', 'canvas'];
remove.forEach(tag => {
clone.querySelectorAll(tag).forEach(el => el.remove());
});
// Try to find main content area
const main = clone.querySelector('main, article, [role="main"], .content, #content');
const source = main || clone;
// Extract text with basic structure
const lines = [];
const walk = (node) => {
if (node.nodeType === 3) {
const text = node.textContent.trim();
if (text) lines.push(text);
} else if (node.nodeType === 1) {
const tag = node.tagName.toLowerCase();
if (['h1','h2','h3','h4','h5','h6'].includes(tag)) {
lines.push('\\n## ' + node.textContent.trim());
} else if (tag === 'li') {
lines.push('- ' + node.textContent.trim());
} else if (tag === 'a' && node.href) {
lines.push('[' + node.textContent.trim() + '](' + node.href + ')');
} else if (['p', 'div', 'section', 'td', 'th'].includes(tag)) {
for (const child of node.childNodes) walk(child);
lines.push('');
} else {
for (const child of node.childNodes) walk(child);
}
}
};
walk(source);
return lines.join('\\n').replace(/\\n{3,}/g, '\\n\\n').trim();
}""")
# Truncate to prevent huge payloads
max_chars = 50000
if len(content) > max_chars:
content = content[:max_chars] + f"\n\n[Truncated — {len(content)} total chars]"
return content
except Exception:
# Fallback: plain innerText
try:
text = page.inner_text("body")
if len(text) > 50000:
text = text[:50000] + f"\n\n[Truncated — {len(text)} total chars]"
return text
except Exception:
return "(could not extract page content)"
def respond(data):
"""Write a JSON response line to stdout."""
sys.stdout.write(json.dumps(data) + "\n")
sys.stdout.flush()
if __name__ == "__main__":
main()
+13 -2
View File
@@ -426,8 +426,14 @@ async fn summarize_messages(
let effective_max = (config.max_chunk_chars as f64 / config.safety_margin) as usize;
if conversation_text.len() > effective_max {
// Keep the tail (most recent) which is usually more important
conversation_text =
conversation_text[conversation_text.len() - effective_max..].to_string();
let start = conversation_text.len() - effective_max;
// Find valid char boundary at or after start
let safe_start = if conversation_text.is_char_boundary(start) {
start
} else {
conversation_text[start..].char_indices().next().map(|(i, _)| start + i).unwrap_or(conversation_text.len())
};
conversation_text = conversation_text[safe_start..].to_string();
}
let summarize_prompt = format!(
@@ -850,6 +856,7 @@ mod tests {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "tu-1".to_string(),
tool_name: String::new(),
content: "Search results here".to_string(),
is_error: false,
}]),
@@ -1184,6 +1191,7 @@ mod tests {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "tu-1".to_string(),
tool_name: String::new(),
content: "Results found".to_string(),
is_error: false,
}]),
@@ -1324,6 +1332,7 @@ mod tests {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "t1".to_string(),
tool_name: String::new(),
content: tool_content,
is_error: false,
}]),
@@ -1343,6 +1352,7 @@ mod tests {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "t2".to_string(),
tool_name: String::new(),
content: large_result,
is_error: false,
}]),
@@ -1366,6 +1376,7 @@ mod tests {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "t3".to_string(),
tool_name: String::new(),
content: short_result.to_string(),
is_error: false,
}]),
+54 -7
View File
@@ -64,12 +64,23 @@ pub fn truncate_tool_result_dynamic(content: &str, budget: &ContextBudget) -> St
return content.to_string();
}
// Find last newline before the cap to break cleanly
let search_start = cap.saturating_sub(200);
let break_point = content[search_start..cap]
// Find last newline before the cap to break cleanly (char-boundary safe)
let safe_cap = if content.is_char_boundary(cap) {
cap
} else {
content[..cap].char_indices().next_back().map(|(i, _)| i).unwrap_or(0)
};
let search_start = safe_cap.saturating_sub(200);
let break_point = content[search_start..safe_cap]
.rfind('\n')
.map(|pos| search_start + pos)
.unwrap_or(cap.saturating_sub(100));
.unwrap_or(safe_cap.saturating_sub(100));
// Ensure break_point is also a char boundary
let break_point = if content.is_char_boundary(break_point) {
break_point
} else {
content[..break_point].char_indices().next_back().map(|(i, _)| i).unwrap_or(0)
};
format!(
"{}\n\n[TRUNCATED: result was {} chars, showing first {} (budget: {}% of {}K context window)]",
@@ -134,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);
@@ -156,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();
@@ -177,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]",
@@ -248,6 +293,7 @@ mod tests {
role: openfang_types::message::Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "t1".to_string(),
tool_name: String::new(),
content: big_result.clone(),
is_error: false,
}]),
@@ -256,6 +302,7 @@ mod tests {
role: openfang_types::message::Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "t2".to_string(),
tool_name: String::new(),
content: big_result,
is_error: false,
}]),
@@ -103,11 +103,17 @@ pub fn recover_from_overflow(
if let ContentBlock::ToolResult { content, .. } = block {
if content.len() > tool_truncation_limit {
let keep = tool_truncation_limit.saturating_sub(80);
// Find a valid char boundary at or before `keep`
let safe_keep = if content.is_char_boundary(keep) {
keep
} else {
content[..keep].char_indices().next_back().map(|(i, _)| i).unwrap_or(0)
};
*content = format!(
"{}\n\n[OVERFLOW RECOVERY: truncated from {} to {} chars]",
&content[..keep],
&content[..safe_keep],
content.len(),
keep
safe_keep
);
truncated += 1;
}
@@ -202,6 +208,7 @@ mod tests {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "t1".to_string(),
tool_name: String::new(),
content: big_result.clone(),
is_error: false,
}]),
@@ -210,6 +217,7 @@ mod tests {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "t2".to_string(),
tool_name: String::new(),
content: big_result,
is_error: false,
}]),
@@ -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();
}
}
@@ -576,6 +576,7 @@ fn convert_message(msg: &Message) -> ApiMessage {
tool_use_id,
content,
is_error,
..
} => Some(ApiContentBlock::ToolResult {
tool_use_id: tool_use_id.clone(),
content: content.clone(),
+69 -14
View File
@@ -10,17 +10,25 @@ use tracing::warn;
/// A driver that wraps multiple LLM drivers and tries each in order.
///
/// On failure, moves to the next driver. Rate-limit and overload errors
/// are bubbled up for retry logic to handle.
/// 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,16 +38,26 @@ 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 { .. }) => {
// Retryable errors — bubble up for the retry loop to handle
return Err(e);
warn!(
driver_index = i,
model = %model_name,
error = %e,
"Driver rate-limited/overloaded, trying next fallback"
);
last_error = Some(e);
}
Err(e) => {
warn!(
driver_index = i,
model = %model_name,
error = %e,
"Fallback driver failed, trying next"
);
@@ -61,15 +79,26 @@ 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 { .. }) => {
return Err(e);
warn!(
driver_index = i,
model = %model_name,
error = %e,
"Driver rate-limited/overloaded (stream), trying next fallback"
);
last_error = Some(e);
}
Err(e) => {
warn!(
driver_index = i,
model = %model_name,
error = %e,
"Fallback driver (stream) failed, trying next"
);
@@ -166,7 +195,7 @@ mod tests {
}
#[tokio::test]
async fn test_rate_limit_bubbles_up() {
async fn test_rate_limit_falls_through() {
struct RateLimitDriver;
#[async_trait]
@@ -186,7 +215,33 @@ mod tests {
Arc::new(OkDriver) as Arc<dyn LlmDriver>,
]);
let result = driver.complete(test_request()).await;
// Rate limit should NOT fall through to next driver
// Rate limit should fall through to the OkDriver fallback
assert!(result.is_ok());
assert_eq!(result.unwrap().text(), "OK");
}
#[tokio::test]
async fn test_rate_limit_all_fail() {
struct RateLimitDriver;
#[async_trait]
impl LlmDriver for RateLimitDriver {
async fn complete(
&self,
_req: CompletionRequest,
) -> Result<CompletionResponse, LlmError> {
Err(LlmError::RateLimited {
retry_after_ms: 5000,
})
}
}
let driver = FallbackDriver::new(vec![
Arc::new(RateLimitDriver) as Arc<dyn LlmDriver>,
Arc::new(RateLimitDriver) as Arc<dyn LlmDriver>,
]);
let result = driver.complete(test_request()).await;
// All drivers rate-limited — error should bubble up
assert!(matches!(result, Err(LlmError::RateLimited { .. })));
}
}
+11 -4
View File
@@ -213,10 +213,17 @@ fn convert_messages(
},
});
}
ContentBlock::ToolResult { content, .. } => {
ContentBlock::ToolResult {
content, tool_name, ..
} => {
let fn_name = if tool_name.is_empty() {
"unknown_function".to_string()
} else {
tool_name.clone()
};
parts.push(GeminiPart::FunctionResponse {
function_response: GeminiFunctionResponseData {
name: String::new(),
name: fn_name,
response: serde_json::json!({ "result": content }),
},
});
@@ -518,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
+154 -17
View File
@@ -115,6 +115,19 @@ impl ModelCatalog {
.collect()
}
/// Return the default model ID for a provider (first model in catalog order).
pub fn default_model_for_provider(&self, provider: &str) -> Option<String> {
// Check aliases first — e.g. "minimax" alias resolves to "MiniMax-M2.5"
if let Some(model_id) = self.aliases.get(provider) {
return Some(model_id.clone());
}
// Fall back to the first model registered for this provider
self.models
.iter()
.find(|m| m.provider == provider)
.map(|m| m.id.clone())
}
/// List models that are available (from configured providers only).
pub fn available_models(&self) -> Vec<&ModelCatalogEntry> {
let configured: Vec<&str> = self
@@ -214,6 +227,73 @@ impl ModelCatalog {
}
}
}
/// Add a custom model at runtime.
///
/// Returns `true` if the model was added, `false` if a model with that ID
/// already exists (case-insensitive).
pub fn add_custom_model(&mut self, entry: ModelCatalogEntry) -> bool {
let lower = entry.id.to_lowercase();
if self.models.iter().any(|m| m.id.to_lowercase() == lower) {
return false;
}
let provider = entry.provider.clone();
self.models.push(entry);
// Update provider model count
if let Some(p) = self.providers.iter_mut().find(|p| p.id == provider) {
p.model_count = self
.models
.iter()
.filter(|m| m.provider == provider)
.count();
}
true
}
/// Remove a custom model by ID.
///
/// Only removes models with `Custom` tier to prevent accidental deletion
/// of builtin models. Returns `true` if removed.
pub fn remove_custom_model(&mut self, model_id: &str) -> bool {
let lower = model_id.to_lowercase();
let before = self.models.len();
self.models
.retain(|m| !(m.id.to_lowercase() == lower && m.tier == ModelTier::Custom));
self.models.len() < before
}
/// Load custom models from a JSON file.
///
/// Merges them into the catalog. Skips models that already exist.
pub fn load_custom_models(&mut self, path: &std::path::Path) {
if !path.exists() {
return;
}
let Ok(data) = std::fs::read_to_string(path) else {
return;
};
let Ok(entries) = serde_json::from_str::<Vec<ModelCatalogEntry>>(&data) else {
return;
};
for entry in entries {
self.add_custom_model(entry);
}
}
/// Save all custom-tier models to a JSON file.
pub fn save_custom_models(&self, path: &std::path::Path) -> Result<(), String> {
let custom: Vec<&ModelCatalogEntry> = self
.models
.iter()
.filter(|m| m.tier == ModelTier::Custom)
.collect();
let json = serde_json::to_string_pretty(&custom)
.map_err(|e| format!("Failed to serialize custom models: {e}"))?;
std::fs::write(path, json)
.map_err(|e| format!("Failed to write custom models file: {e}"))?;
Ok(())
}
}
impl Default for ModelCatalog {
@@ -567,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"),
@@ -972,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(),
@@ -1301,7 +1368,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// OpenRouter (5)
// OpenRouter (11)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "openrouter/auto".into(),
@@ -1373,6 +1440,76 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "openrouter/meta-llama/llama-3.3-70b-instruct".into(),
display_name: "Llama 3.3 70B (OpenRouter, free)".into(),
provider: "openrouter".into(),
tier: ModelTier::Balanced,
context_window: 128_000,
max_output_tokens: 32_768,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "openrouter/mistralai/mistral-7b-instruct".into(),
display_name: "Mistral 7B (OpenRouter, free)".into(),
provider: "openrouter".into(),
tier: ModelTier::Fast,
context_window: 32_768,
max_output_tokens: 8_192,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: false,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "openrouter/google/gemma-2-9b-it".into(),
display_name: "Gemma 2 9B (OpenRouter, free)".into(),
provider: "openrouter".into(),
tier: ModelTier::Fast,
context_window: 8_192,
max_output_tokens: 4_096,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: false,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "openrouter/qwen/qwen-2.5-72b-instruct".into(),
display_name: "Qwen 2.5 72B (OpenRouter, free)".into(),
provider: "openrouter".into(),
tier: ModelTier::Balanced,
context_window: 128_000,
max_output_tokens: 32_768,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "openrouter/deepseek/deepseek-chat-v3-0324".into(),
display_name: "DeepSeek V3 0324 (OpenRouter)".into(),
provider: "openrouter".into(),
tier: ModelTier::Smart,
context_window: 128_000,
max_output_tokens: 32_768,
input_cost_per_m: 0.14,
output_cost_per_m: 0.28,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// Mistral (6)
// ══════════════════════════════════════════════════════════════
+65 -2
View File
@@ -51,6 +51,10 @@ pub struct PromptContext {
pub identity_md: Option<String>,
/// HEARTBEAT.md content (autonomous agent checklist).
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`.
@@ -64,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());
@@ -139,6 +148,11 @@ pub fn build_system_prompt(ctx: &PromptContext) -> String {
}
}
// Section 9.5 — Peer Agent Awareness (skip for subagents)
if !ctx.is_subagent && !ctx.peer_agents.is_empty() {
sections.push(build_peer_agents_section(&ctx.agent_name, &ctx.peer_agents));
}
// Section 10 — Safety & Oversight (skip for subagents)
if !ctx.is_subagent {
sections.push(SAFETY_SECTION.to_string());
@@ -201,7 +215,13 @@ 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.
- Start with the answer, not meta-commentary about how you'll help.";
- 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 \
code blocks always call the tool instead.";
/// Build the grouped tools section (Section 3).
pub fn build_tools_section(granted_tools: &[String]) -> String {
@@ -315,9 +335,10 @@ fn build_persona_section(
if let Some(soul) = soul_md {
if !soul.trim().is_empty() {
let sanitized = strip_code_blocks(soul);
parts.push(format!(
"## Persona\nEmbody this identity in your tone and communication style. Be natural, not stiff or generic.\n{}",
cap_str(soul, 1000)
cap_str(&sanitized, 1000)
));
}
}
@@ -392,6 +413,24 @@ fn build_channel_section(channel: &str) -> String {
)
}
fn build_peer_agents_section(self_name: &str, peers: &[(String, String, String)]) -> String {
let mut out = String::from(
"## Peer Agents\n\
You are part of a multi-agent system. These agents are running alongside you:\n",
);
for (name, state, model) in peers {
if name == self_name {
continue; // Don't list yourself
}
out.push_str(&format!("- **{}** ({}) — model: {}\n", name, state, model));
}
out.push_str(
"\nYou can communicate with them using `agent_send` (by name) and see all agents with `agent_list`. \
Delegate tasks to specialized agents when appropriate.",
);
out
}
/// Static safety section.
const SAFETY_SECTION: &str = "\
## Safety
@@ -527,6 +566,30 @@ pub fn tool_hint(name: &str) -> &'static str {
// ---------------------------------------------------------------------------
/// Cap a string to `max_chars`, appending "..." if truncated.
/// Strip markdown triple-backtick code blocks from content.
///
/// Prevents LLMs from copying code blocks as text output instead of making
/// tool calls when SOUL.md contains command examples.
fn strip_code_blocks(content: &str) -> String {
let mut result = String::with_capacity(content.len());
let mut in_block = false;
for line in content.lines() {
if line.trim_start().starts_with("```") {
in_block = !in_block;
continue;
}
if !in_block {
result.push_str(line);
result.push('\n');
}
}
// Collapse multiple blank lines left by stripped blocks
while result.contains("\n\n\n") {
result = result.replace("\n\n\n", "\n\n");
}
result.trim().to_string()
}
fn cap_str(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
@@ -370,6 +370,7 @@ fn insert_synthetic_results(messages: &mut Vec<Message>) -> usize {
.or_default()
.push(ContentBlock::ToolResult {
tool_use_id,
tool_name: String::new(),
content: "[Tool execution was interrupted or lost]".to_string(),
is_error: true,
});
@@ -702,6 +703,7 @@ mod tests {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "orphan-id".to_string(),
tool_name: String::new(),
content: "some result".to_string(),
is_error: false,
}]),
@@ -762,6 +764,7 @@ mod tests {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "tu-1".to_string(),
tool_name: String::new(),
content: "Results found".to_string(),
is_error: false,
}]),
@@ -793,6 +796,7 @@ mod tests {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "tu-reorder".to_string(),
tool_name: String::new(),
content: "Search results".to_string(),
is_error: false,
}]),
@@ -881,6 +885,7 @@ mod tests {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "tu-dup".to_string(),
tool_name: String::new(),
content: "First result".to_string(),
is_error: false,
}]),
@@ -889,6 +894,7 @@ mod tests {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "tu-dup".to_string(),
tool_name: String::new(),
content: "Duplicate result".to_string(),
is_error: false,
}]),
@@ -978,6 +984,7 @@ mod tests {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "orphan".to_string(),
tool_name: String::new(),
content: "lost".to_string(),
is_error: false,
}]),
@@ -1057,6 +1064,7 @@ mod tests {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "tu-a".to_string(),
tool_name: String::new(),
content: "search result".to_string(),
is_error: false,
}]),
@@ -1066,6 +1074,7 @@ mod tests {
role: Role::User,
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "tu-ghost".to_string(),
tool_name: String::new(),
content: "ghost result".to_string(),
is_error: false,
}]),
@@ -1114,11 +1123,13 @@ mod tests {
content: MessageContent::Blocks(vec![
ContentBlock::ToolResult {
tool_use_id: "orphan-1".to_string(),
tool_name: String::new(),
content: "lost 1".to_string(),
is_error: false,
},
ContentBlock::ToolResult {
tool_use_id: "orphan-2".to_string(),
tool_name: String::new(),
content: "lost 2".to_string(),
is_error: false,
},
+94 -10
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
}
@@ -213,7 +216,11 @@ pub async fn execute_tool(
{
return ToolResult {
tool_use_id: tool_use_id.to_string(),
content: format!("Exec policy denied: {reason}"),
content: format!(
"shell_exec blocked: {reason}. Current exec_policy.mode = '{:?}'. \
To allow shell commands, set exec_policy.mode = 'full' in the agent manifest or config.toml.",
policy.mode
),
is_error: true,
};
}
@@ -330,7 +337,7 @@ pub async fn execute_tool(
crate::browser::tool_browser_navigate(input, mgr, aid).await
}
None => Err(
"Browser tools not available. Ensure Python and playwright are installed."
"Browser tools not available. Ensure Chrome/Chromium is installed."
.to_string(),
),
}
@@ -340,35 +347,63 @@ pub async fn execute_tool(
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_click(input, mgr, aid).await
}
None => Err("Browser tools not available.".to_string()),
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
},
"browser_type" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_type(input, mgr, aid).await
}
None => Err("Browser tools not available.".to_string()),
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
},
"browser_screenshot" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_screenshot(input, mgr, aid).await
}
None => Err("Browser tools not available.".to_string()),
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
},
"browser_read_page" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_read_page(input, mgr, aid).await
}
None => Err("Browser tools not available.".to_string()),
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
},
"browser_close" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_close(input, mgr, aid).await
}
None => Err("Browser tools not available.".to_string()),
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
},
"browser_scroll" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_scroll(input, mgr, aid).await
}
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
},
"browser_wait" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_wait(input, mgr, aid).await
}
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
},
"browser_run_js" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_run_js(input, mgr, aid).await
}
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
},
"browser_back" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_back(input, mgr, aid).await
}
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
},
// Canvas / A2UI tool
@@ -501,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"]
}),
@@ -828,6 +866,48 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
"properties": {}
}),
},
ToolDefinition {
name: "browser_scroll".to_string(),
description: "Scroll the browser page. Use this to see content below the fold or navigate long pages.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"direction": { "type": "string", "description": "Scroll direction: 'up', 'down', 'left', 'right' (default: 'down')" },
"amount": { "type": "integer", "description": "Pixels to scroll (default: 600)" }
}
}),
},
ToolDefinition {
name: "browser_wait".to_string(),
description: "Wait for a CSS selector to appear on the page. Useful for dynamic content that loads asynchronously.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"selector": { "type": "string", "description": "CSS selector to wait for" },
"timeout_ms": { "type": "integer", "description": "Max wait time in milliseconds (default: 5000, max: 30000)" }
},
"required": ["selector"]
}),
},
ToolDefinition {
name: "browser_run_js".to_string(),
description: "Run JavaScript on the current browser page and return the result. For advanced interactions that other browser tools cannot handle.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"expression": { "type": "string", "description": "JavaScript expression to run in the page context" }
},
"required": ["expression"]
}),
},
ToolDefinition {
name: "browser_back".to_string(),
description: "Go back to the previous page in browser history.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
},
// --- Media understanding tools ---
ToolDefinition {
name: "media_describe".to_string(),
@@ -3018,6 +3098,10 @@ mod tests {
assert!(names.contains(&"browser_screenshot"));
assert!(names.contains(&"browser_read_page"));
assert!(names.contains(&"browser_close"));
assert!(names.contains(&"browser_scroll"));
assert!(names.contains(&"browser_wait"));
assert!(names.contains(&"browser_run_js"));
assert!(names.contains(&"browser_back"));
// 3 media/image generation tools
assert!(names.contains(&"media_describe"));
assert!(names.contains(&"media_transcribe"));
+78 -24
View File
@@ -6,6 +6,31 @@
use sha2::{Digest, Sha256};
// ---------------------------------------------------------------------------
// ASCII case-insensitive find — byte offsets always valid on original string
// ---------------------------------------------------------------------------
/// Find `needle` in `haystack` starting at byte offset `from`, comparing
/// ASCII characters case-insensitively. Since HTML tags are ASCII, this
/// avoids the byte-length mismatch caused by `str::to_lowercase()` on
/// multi-byte Unicode (e.g. `İ` 2 bytes → `i̇` 4 bytes).
fn find_ci(haystack: &str, needle: &str, from: usize) -> Option<usize> {
let h = haystack.as_bytes();
let n = needle.as_bytes();
if n.is_empty() || from + n.len() > h.len() {
return None;
}
'outer: for i in from..=(h.len() - n.len()) {
for j in 0..n.len() {
if !h[i + j].eq_ignore_ascii_case(&n[j]) {
continue 'outer;
}
}
return Some(i);
}
None
}
// ---------------------------------------------------------------------------
// External content markers
// ---------------------------------------------------------------------------
@@ -79,19 +104,17 @@ fn remove_non_content_blocks(html: &str) -> String {
/// Remove all occurrences of a specific tag and its contents (case-insensitive).
fn remove_tag_blocks(html: &str, tag: &str) -> String {
let mut result = String::with_capacity(html.len());
let lower = html.to_lowercase();
let open_tag = format!("<{}", tag);
let close_tag = format!("</{}>", tag);
let mut pos = 0;
while pos < html.len() {
if let Some(start) = lower[pos..].find(&open_tag) {
let abs_start = pos + start;
if let Some(abs_start) = find_ci(html, &open_tag, pos) {
result.push_str(&html[pos..abs_start]);
// Find the matching close tag
if let Some(end) = lower[abs_start..].find(&close_tag) {
pos = abs_start + end + close_tag.len();
if let Some(end) = find_ci(html, &close_tag, abs_start) {
pos = end + close_tag.len();
} else {
// No close tag — remove to end of self-closing or skip the open tag
if let Some(gt) = html[abs_start..].find('>') {
@@ -110,16 +133,15 @@ fn remove_tag_blocks(html: &str, tag: &str) -> String {
/// Extract the content from <main>, <article>, or <body> (in priority order).
fn extract_main_content(html: &str) -> String {
let lower = html.to_lowercase();
for tag in &["main", "article", "body"] {
let open = format!("<{}", tag);
let close = format!("</{}>", tag);
if let Some(start) = lower.find(&open) {
if let Some(start) = find_ci(html, &open, 0) {
// Skip past the opening tag's >
if let Some(gt) = html[start..].find('>') {
let content_start = start + gt + 1;
if let Some(end) = lower[content_start..].find(&close) {
return html[content_start..content_start + end].to_string();
if let Some(end) = find_ci(html, &close, content_start) {
return html[content_start..end].to_string();
}
}
}
@@ -193,23 +215,21 @@ fn convert_inline_tag(
md_close: &str,
) -> String {
let mut result = String::with_capacity(html.len());
let lower = html.to_lowercase();
let mut pos = 0;
while pos < html.len() {
if let Some(start) = lower[pos..].find(open_prefix) {
let abs_start = pos + start;
if let Some(abs_start) = find_ci(html, open_prefix, pos) {
result.push_str(&html[pos..abs_start]);
// Find the end of the opening tag
if let Some(gt) = html[abs_start..].find('>') {
let content_start = abs_start + gt + 1;
// Find the close tag
if let Some(end) = lower[content_start..].find(close) {
if let Some(end) = find_ci(html, close, content_start) {
result.push_str(md_open);
result.push_str(&html[content_start..content_start + end]);
result.push_str(&html[content_start..end]);
result.push_str(md_close);
pos = content_start + end + close.len();
pos = end + close.len();
} else {
// No close tag, just skip the open tag
result.push_str(md_open);
@@ -230,12 +250,10 @@ fn convert_inline_tag(
/// Convert <a href="url">text</a> to [text](url).
fn convert_links(html: &str) -> String {
let mut result = String::with_capacity(html.len());
let lower = html.to_lowercase();
let mut pos = 0;
while pos < html.len() {
if let Some(start) = lower[pos..].find("<a ") {
let abs_start = pos + start;
if let Some(abs_start) = find_ci(html, "<a ", pos) {
result.push_str(&html[pos..abs_start]);
// Extract href
@@ -244,14 +262,14 @@ fn convert_links(html: &str) -> String {
if let Some(gt) = tag_content.find('>') {
let text_start = abs_start + gt + 1;
if let Some(end) = lower[text_start..].find("</a>") {
let link_text = strip_all_tags(&html[text_start..text_start + end]);
if let Some(end) = find_ci(html, "</a>", text_start) {
let link_text = strip_all_tags(&html[text_start..end]);
if let Some(url) = href {
result.push_str(&format!("[{}]({})", link_text.trim(), url));
} else {
result.push_str(link_text.trim());
}
pos = text_start + end + 4; // skip </a>
pos = end + 4; // skip </a>
} else {
pos = text_start;
}
@@ -269,9 +287,8 @@ fn convert_links(html: &str) -> String {
/// Extract an attribute value from an HTML tag.
fn extract_attribute(tag: &str, attr: &str) -> Option<String> {
let lower = tag.to_lowercase();
let pattern = format!("{}=\"", attr);
if let Some(start) = lower.find(&pattern) {
if let Some(start) = find_ci(tag, &pattern, 0) {
let val_start = start + pattern.len();
if let Some(end) = tag[val_start..].find('"') {
return Some(tag[val_start..val_start + end].to_string());
@@ -279,7 +296,7 @@ fn extract_attribute(tag: &str, attr: &str) -> Option<String> {
}
// Try single quotes
let pattern_sq = format!("{}='", attr);
if let Some(start) = lower.find(&pattern_sq) {
if let Some(start) = find_ci(tag, &pattern_sq, 0) {
let val_start = start + pattern_sq.len();
if let Some(end) = tag[val_start..].find('\'') {
return Some(tag[val_start..val_start + end].to_string());
@@ -389,4 +406,41 @@ mod tests {
assert!(result.contains("Keep"));
assert!(result.contains("this"));
}
#[test]
fn test_find_ci_basic() {
assert_eq!(find_ci("Hello World", "hello", 0), Some(0));
assert_eq!(find_ci("Hello World", "WORLD", 0), Some(6));
assert_eq!(find_ci("Hello World", "xyz", 0), None);
assert_eq!(find_ci("Hello World", "world", 6), Some(6));
assert_eq!(find_ci("Hello World", "hello", 1), None);
}
#[test]
fn test_unicode_no_panic() {
// Turkish dotted I: İ is 2 bytes, but lowercase i̇ is 4 bytes.
// German sharp S: ẞ is 3 bytes, lowercase ß is 2 bytes.
// This used to panic because to_lowercase() changed byte lengths.
let html = "<body>İstanbul ẞtraße <B>bold</B> text</body>";
let md = html_to_markdown(html);
assert!(md.contains("**bold**"), "Expected bold, got: {md}");
assert!(md.contains("İstanbul"), "Expected unicode preserved, got: {md}");
}
#[test]
fn test_unicode_in_script_removal() {
let html = "<div>Ünïcödé <SCRIPT>İstanbul</SCRIPT> keep</div>";
let result = remove_non_content_blocks(html);
assert!(!result.contains("İstanbul"));
assert!(result.contains("Ünïcödé"));
assert!(result.contains("keep"));
}
#[test]
fn test_mixed_case_tags() {
let html = "<HTML><BODY><H1>Title</H1><P>Hello <STRONG>world</STRONG>.</P></BODY></HTML>";
let md = html_to_markdown(html);
assert!(md.contains("# Title"), "Expected heading, got: {md}");
assert!(md.contains("**world**"), "Expected bold, got: {md}");
}
}
+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)
}
+10
View File
@@ -476,6 +476,12 @@ pub struct AgentManifest {
/// Per-agent exec policy override. If None, uses global exec_policy.
#[serde(default)]
pub exec_policy: Option<crate::config::ExecPolicy>,
/// Tool allowlist — only these tools are available (empty = all tools).
#[serde(default, deserialize_with = "crate::serde_compat::vec_lenient")]
pub tool_allowlist: Vec<String>,
/// Tool blocklist — these tools are excluded (applied after allowlist).
#[serde(default, deserialize_with = "crate::serde_compat::vec_lenient")]
pub tool_blocklist: Vec<String>,
}
fn default_true() -> bool {
@@ -508,6 +514,8 @@ impl Default for AgentManifest {
workspace: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: Vec::new(),
tool_blocklist: Vec::new(),
}
}
}
@@ -763,6 +771,8 @@ mod tests {
workspace: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: Vec::new(),
tool_blocklist: Vec::new(),
};
let json = serde_json::to_string(&manifest).unwrap();
let deserialized: AgentManifest = serde_json::from_str(&json).unwrap();
+81
View File
@@ -167,11 +167,19 @@ pub struct ApprovalResponse {
#[serde(default)]
pub struct ApprovalPolicy {
/// Tools that always require approval. Default: `["shell_exec"]`.
///
/// Accepts either a list of tool names or a boolean shorthand:
/// - `require_approval = false` → empty list (no tools require approval)
/// - `require_approval = true` → `["shell_exec"]` (the default set)
#[serde(deserialize_with = "deserialize_require_approval")]
pub require_approval: Vec<String>,
/// Timeout in seconds. Default: 60, range: 10..=300.
pub timeout_secs: u64,
/// Auto-approve in autonomous mode. Default: `false`.
pub auto_approve_autonomous: bool,
/// Alias: if `auto_approve = true`, clears the require list at boot.
#[serde(default, alias = "auto_approve")]
pub auto_approve: bool,
}
impl Default for ApprovalPolicy {
@@ -180,11 +188,57 @@ impl Default for ApprovalPolicy {
require_approval: vec!["shell_exec".to_string()],
timeout_secs: 60,
auto_approve_autonomous: false,
auto_approve: false,
}
}
}
/// Custom deserializer that accepts:
/// - A list of strings: `["shell_exec", "file_write"]`
/// - A boolean: `false` → `[]`, `true` → `["shell_exec"]`
fn deserialize_require_approval<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct RequireApprovalVisitor;
impl<'de> de::Visitor<'de> for RequireApprovalVisitor {
type Value = Vec<String>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a list of tool names or a boolean")
}
fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
Ok(if v {
vec!["shell_exec".to_string()]
} else {
vec![]
})
}
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
let mut v = Vec::new();
while let Some(s) = seq.next_element::<String>()? {
v.push(s);
}
Ok(v)
}
}
deserializer.deserialize_any(RequireApprovalVisitor)
}
impl ApprovalPolicy {
/// Apply the `auto_approve` shorthand: if true, clears the require list.
pub fn apply_shorthands(&mut self) {
if self.auto_approve {
self.require_approval.clear();
}
}
/// Validate this policy's fields.
///
/// Returns `Ok(())` or an error message describing the first validation failure.
@@ -485,6 +539,7 @@ mod tests {
assert_eq!(policy.require_approval, vec!["shell_exec".to_string()]);
assert_eq!(policy.timeout_secs, 60);
assert!(!policy.auto_approve_autonomous);
assert!(!policy.auto_approve);
}
#[test]
@@ -496,6 +551,31 @@ mod tests {
assert!(!policy.auto_approve_autonomous);
}
#[test]
fn policy_require_approval_bool_false() {
// require_approval = false → empty list
let policy: ApprovalPolicy =
serde_json::from_str(r#"{"require_approval": false}"#).unwrap();
assert!(policy.require_approval.is_empty());
}
#[test]
fn policy_require_approval_bool_true() {
// require_approval = true → ["shell_exec"]
let policy: ApprovalPolicy =
serde_json::from_str(r#"{"require_approval": true}"#).unwrap();
assert_eq!(policy.require_approval, vec!["shell_exec"]);
}
#[test]
fn policy_auto_approve_clears_list() {
let mut policy = ApprovalPolicy::default();
assert!(!policy.require_approval.is_empty());
policy.auto_approve = true;
policy.apply_shorthands();
assert!(policy.require_approval.is_empty());
}
// -----------------------------------------------------------------------
// ApprovalPolicy — timeout_secs
// -----------------------------------------------------------------------
@@ -608,6 +688,7 @@ mod tests {
require_approval: vec!["shell_exec".into(), "file_delete".into()],
timeout_secs: 120,
auto_approve_autonomous: true,
auto_approve: false,
};
let json = serde_json::to_string(&policy).unwrap();
let back: ApprovalPolicy = serde_json::from_str(&json).unwrap();
+170
View File
@@ -0,0 +1,170 @@
//! Shared wire types for the Agent Communication UI.
//!
//! These types are used by both the REST API and the TUI to represent
//! agent topology graphs, inter-agent communication events, and
//! request payloads for sending messages / posting tasks.
use serde::{Deserialize, Serialize};
/// A node in the agent topology graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopoNode {
/// Agent ID.
pub id: String,
/// Human-readable agent name.
pub name: String,
/// Current lifecycle state (e.g. "Running", "Suspended").
pub state: String,
/// Model name the agent is using.
pub model: String,
}
/// An edge in the agent topology graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopoEdge {
/// Source agent ID.
pub from: String,
/// Target agent ID.
pub to: String,
/// Relationship kind.
pub kind: EdgeKind,
}
/// The kind of relationship between two agents.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EdgeKind {
/// Parent spawned child.
ParentChild,
/// Peer-to-peer message exchange.
Peer,
}
/// The full agent topology: nodes + edges.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Topology {
pub nodes: Vec<TopoNode>,
pub edges: Vec<TopoEdge>,
}
/// A communication event between agents.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommsEvent {
/// Unique event ID.
pub id: String,
/// ISO-8601 timestamp.
pub timestamp: String,
/// Event kind.
pub kind: CommsEventKind,
/// Source agent ID.
pub source_id: String,
/// Source agent name.
pub source_name: String,
/// Target agent ID (empty for lifecycle events without a target).
pub target_id: String,
/// Target agent name.
pub target_name: String,
/// Human-readable detail text.
pub detail: String,
}
/// The kind of inter-agent communication event.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommsEventKind {
/// Agent-to-agent message.
AgentMessage,
/// A new agent was spawned.
AgentSpawned,
/// An agent was terminated.
AgentTerminated,
/// A task was posted to the queue.
TaskPosted,
/// A task was claimed by an agent.
TaskClaimed,
/// A task was completed.
TaskCompleted,
}
/// Request body for POST /api/comms/send.
#[derive(Debug, Clone, Deserialize)]
pub struct CommsSendRequest {
pub from_agent_id: String,
pub to_agent_id: String,
pub message: String,
}
/// Request body for POST /api/comms/task.
#[derive(Debug, Clone, Deserialize)]
pub struct CommsTaskRequest {
pub title: String,
pub description: String,
#[serde(default)]
pub assigned_to: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn comms_event_kind_roundtrip() {
let kind = CommsEventKind::AgentMessage;
let json = serde_json::to_string(&kind).unwrap();
assert_eq!(json, "\"agent_message\"");
let parsed: CommsEventKind = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, kind);
}
#[test]
fn edge_kind_roundtrip() {
let kind = EdgeKind::ParentChild;
let json = serde_json::to_string(&kind).unwrap();
assert_eq!(json, "\"parent_child\"");
let parsed: EdgeKind = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, kind);
}
#[test]
fn topology_serialization() {
let topo = Topology {
nodes: vec![TopoNode {
id: "a1".into(),
name: "agent-1".into(),
state: "Running".into(),
model: "gpt-4".into(),
}],
edges: vec![TopoEdge {
from: "a1".into(),
to: "a2".into(),
kind: EdgeKind::Peer,
}],
};
let json = serde_json::to_string(&topo).unwrap();
assert!(json.contains("\"agent-1\""));
assert!(json.contains("\"peer\""));
}
#[test]
fn comms_send_request_deser() {
let json = r#"{"from_agent_id":"a","to_agent_id":"b","message":"hello"}"#;
let req: CommsSendRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.from_agent_id, "a");
assert_eq!(req.message, "hello");
}
#[test]
fn comms_task_request_deser() {
let json = r#"{"title":"t","description":"d"}"#;
let req: CommsTaskRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.title, "t");
assert!(req.assigned_to.is_none());
}
#[test]
fn comms_task_request_with_assign() {
let json = r#"{"title":"t","description":"d","assigned_to":"agent-x"}"#;
let req: CommsTaskRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.assigned_to.as_deref(), Some("agent-x"));
}
}
+11 -11
View File
@@ -281,8 +281,8 @@ pub struct BrowserConfig {
pub idle_timeout_secs: u64,
/// Maximum concurrent browser sessions.
pub max_sessions: usize,
/// Python executable path (e.g., "python3" on Unix, "python" on Windows).
pub python_path: String,
/// Path to Chromium/Chrome binary. Auto-detected if None.
pub chromium_path: Option<String>,
}
impl Default for BrowserConfig {
@@ -294,11 +294,7 @@ impl Default for BrowserConfig {
timeout_secs: 30,
idle_timeout_secs: 300,
max_sessions: 5,
python_path: if cfg!(windows) {
"python".to_string()
} else {
"python3".to_string()
},
chromium_path: None,
}
}
}
@@ -750,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,
}
@@ -1553,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)]
@@ -1569,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(),
}
}
@@ -3226,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]
+1
View File
@@ -6,6 +6,7 @@
pub mod agent;
pub mod approval;
pub mod capability;
pub mod comms;
pub mod config;
pub mod error;
pub mod event;
+3
View File
@@ -66,6 +66,9 @@ pub enum ContentBlock {
ToolResult {
/// The tool_use ID this result corresponds to.
tool_use_id: String,
/// The tool name (for Gemini FunctionResponse). Empty for legacy sessions.
#[serde(default)]
tool_name: String,
/// The result content.
content: String,
/// Whether the tool execution errored.
+5 -1
View File
@@ -34,7 +34,7 @@ pub const GITHUB_COPILOT_BASE_URL: &str = "https://api.githubcopilot.com";
// ── Chinese providers ─────────────────────────────────────────────
pub const QWEN_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
pub const MINIMAX_BASE_URL: &str = "https://api.minimax.chat/v1";
pub const MINIMAX_BASE_URL: &str = "https://api.minimax.io/v1";
pub const ZHIPU_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4";
pub const ZHIPU_CODING_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4";
pub const MOONSHOT_BASE_URL: &str = "https://api.moonshot.cn/v1";
@@ -58,6 +58,8 @@ pub enum ModelTier {
Fast,
/// Local models (Ollama, vLLM, LM Studio).
Local,
/// User-defined custom models added at runtime.
Custom,
}
impl fmt::Display for ModelTier {
@@ -68,6 +70,7 @@ impl fmt::Display for ModelTier {
ModelTier::Balanced => write!(f, "balanced"),
ModelTier::Fast => write!(f, "fast"),
ModelTier::Local => write!(f, "local"),
ModelTier::Custom => write!(f, "custom"),
}
}
}
@@ -188,6 +191,7 @@ mod tests {
assert_eq!(ModelTier::Balanced.to_string(), "balanced");
assert_eq!(ModelTier::Fast.to_string(), "fast");
assert_eq!(ModelTier::Local.to_string(), "local");
assert_eq!(ModelTier::Custom.to_string(), "custom");
}
#[test]