batch fixes

This commit is contained in:
jaberjaber23
2026-03-02 03:31:59 +03:00
parent 7ec0e024c7
commit 73ad49a3a1
13 changed files with 417 additions and 43 deletions
Generated
+14 -14
View File
@@ -3866,7 +3866,7 @@ dependencies = [
[[package]]
name = "openfang-api"
version = "0.2.4"
version = "0.2.5"
dependencies = [
"async-trait",
"axum",
@@ -3902,7 +3902,7 @@ dependencies = [
[[package]]
name = "openfang-channels"
version = "0.2.4"
version = "0.2.5"
dependencies = [
"async-trait",
"axum",
@@ -3933,7 +3933,7 @@ dependencies = [
[[package]]
name = "openfang-cli"
version = "0.2.4"
version = "0.2.5"
dependencies = [
"clap",
"clap_complete",
@@ -3960,7 +3960,7 @@ dependencies = [
[[package]]
name = "openfang-desktop"
version = "0.2.4"
version = "0.2.5"
dependencies = [
"axum",
"open",
@@ -3986,7 +3986,7 @@ dependencies = [
[[package]]
name = "openfang-extensions"
version = "0.2.4"
version = "0.2.5"
dependencies = [
"aes-gcm",
"argon2",
@@ -4014,7 +4014,7 @@ dependencies = [
[[package]]
name = "openfang-hands"
version = "0.2.4"
version = "0.2.5"
dependencies = [
"chrono",
"dashmap",
@@ -4031,7 +4031,7 @@ dependencies = [
[[package]]
name = "openfang-kernel"
version = "0.2.4"
version = "0.2.5"
dependencies = [
"async-trait",
"chrono",
@@ -4067,7 +4067,7 @@ dependencies = [
[[package]]
name = "openfang-memory"
version = "0.2.4"
version = "0.2.5"
dependencies = [
"async-trait",
"chrono",
@@ -4086,7 +4086,7 @@ dependencies = [
[[package]]
name = "openfang-migrate"
version = "0.2.4"
version = "0.2.5"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -4105,7 +4105,7 @@ dependencies = [
[[package]]
name = "openfang-runtime"
version = "0.2.4"
version = "0.2.5"
dependencies = [
"anyhow",
"async-trait",
@@ -4136,7 +4136,7 @@ dependencies = [
[[package]]
name = "openfang-skills"
version = "0.2.4"
version = "0.2.5"
dependencies = [
"chrono",
"hex",
@@ -4158,7 +4158,7 @@ dependencies = [
[[package]]
name = "openfang-types"
version = "0.2.4"
version = "0.2.5"
dependencies = [
"async-trait",
"chrono",
@@ -4177,7 +4177,7 @@ dependencies = [
[[package]]
name = "openfang-wire"
version = "0.2.4"
version = "0.2.5"
dependencies = [
"async-trait",
"chrono",
@@ -8789,7 +8789,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xtask"
version = "0.2.4"
version = "0.2.5"
[[package]]
name = "yoke"
+1 -1
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.2.5"
version = "0.2.6"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
+86
View File
@@ -5799,6 +5799,92 @@ pub async fn set_model(
}
}
/// GET /api/agents/{id}/tools — Get an agent's tool allowlist/blocklist.
pub async fn get_agent_tools(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
let agent_id: AgentId = match id.parse() {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid agent ID"})),
)
}
};
let entry = match state.kernel.registry.get(agent_id) {
Some(e) => e,
None => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Agent not found"})),
)
}
};
(
StatusCode::OK,
Json(serde_json::json!({
"tool_allowlist": entry.manifest.tool_allowlist,
"tool_blocklist": entry.manifest.tool_blocklist,
})),
)
}
/// PUT /api/agents/{id}/tools — Update an agent's tool allowlist/blocklist.
pub async fn set_agent_tools(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
let agent_id: AgentId = match id.parse() {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid agent ID"})),
)
}
};
let allowlist = body
.get("tool_allowlist")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect::<Vec<_>>()
});
let blocklist = body
.get("tool_blocklist")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect::<Vec<_>>()
});
if allowlist.is_none() && blocklist.is_none() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Provide 'tool_allowlist' and/or 'tool_blocklist'"})),
);
}
match state
.kernel
.set_agent_tool_filters(agent_id, allowlist, blocklist)
{
Ok(()) => (
StatusCode::OK,
Json(serde_json::json!({"status": "ok"})),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("{e}")})),
),
}
}
// ── Per-Agent Skill & MCP Endpoints ────────────────────────────────────
/// GET /api/agents/{id}/skills — Get an agent's skill assignment info.
+4
View File
@@ -172,6 +172,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),
+64 -7
View File
@@ -696,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">
@@ -713,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>
@@ -811,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 -->
@@ -826,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>
@@ -890,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>
@@ -2995,7 +3052,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>
@@ -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;
+7 -1
View File
@@ -1492,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 {
+49 -16
View File
@@ -1021,13 +1021,9 @@ impl OpenFangKernel {
// Apply global budget defaults to agent resource quotas
apply_budget_defaults(&self.config.budget, &mut manifest.resources);
// Create workspace directory for the agent
// 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 {
@@ -1351,11 +1347,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 {
@@ -1822,11 +1814,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 {
@@ -2378,6 +2366,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(())
}
@@ -2450,6 +2442,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(|| {
@@ -4072,6 +4088,19 @@ 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));
}
let caps = self.capabilities.list(agent_id);
// If agent has ToolAll, return all tools
@@ -5093,6 +5122,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;
@@ -5128,6 +5159,8 @@ mod tests {
workspace: None,
generate_identity_files: true,
exec_policy: None,
tool_allowlist: vec![],
tool_blocklist: vec![],
}
}
+23
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
@@ -320,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
+30 -2
View File
@@ -208,7 +208,10 @@ 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.";
- 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 {
@@ -322,9 +325,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)
));
}
}
@@ -552,6 +556,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()
+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();