mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-07-30 06:32:17 +00:00
batch fixes
This commit is contained in:
Generated
+14
-14
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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> ×</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> ×</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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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![],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user