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
+15
-14
@@ -3866,7 +3866,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-api"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3902,7 +3902,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-channels"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3933,7 +3933,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-cli"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -3960,7 +3960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-desktop"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"open",
|
||||
@@ -3986,7 +3986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-extensions"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -4014,7 +4014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-hands"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
@@ -4031,7 +4031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-kernel"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4067,7 +4067,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-memory"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4086,7 +4086,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-migrate"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -4105,7 +4105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-runtime"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
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.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -4158,7 +4159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-types"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4177,7 +4178,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-wire"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -8789,7 +8790,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.6"
|
||||
version = "0.2.7"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
|
||||
@@ -119,15 +119,22 @@ pub async fn auth(
|
||||
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;
|
||||
|
||||
@@ -6195,14 +6195,25 @@ pub async fn test_provider(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let (env_var, base_url, key_required) = {
|
||||
let (env_var, base_url, key_required, default_model) = {
|
||||
let catalog = state
|
||||
.kernel
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
match catalog.get_provider(&name) {
|
||||
Some(p) => (p.api_key_env.clone(), p.base_url.clone(), p.key_required),
|
||||
Some(p) => {
|
||||
// Find a default model for this provider to use in the test request
|
||||
let model_id = catalog
|
||||
.default_model_for_provider(&name)
|
||||
.unwrap_or_default();
|
||||
(
|
||||
p.api_key_env.clone(),
|
||||
p.base_url.clone(),
|
||||
p.key_required,
|
||||
model_id,
|
||||
)
|
||||
}
|
||||
None => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -6237,7 +6248,7 @@ pub async fn test_provider(
|
||||
Ok(driver) => {
|
||||
// Send a minimal completion request to test connectivity
|
||||
let test_req = openfang_runtime::llm_driver::CompletionRequest {
|
||||
model: String::new(), // Driver will use default
|
||||
model: default_model.clone(),
|
||||
messages: vec![openfang_types::message::Message::user("Hi")],
|
||||
tools: vec![],
|
||||
max_tokens: 1,
|
||||
|
||||
@@ -2462,7 +2462,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>
|
||||
@@ -2489,6 +2489,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>
|
||||
|
||||
@@ -996,6 +996,7 @@ 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`.
|
||||
@@ -2808,6 +2809,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(())
|
||||
}
|
||||
@@ -4101,6 +4114,18 @@ impl OpenFangKernel {
|
||||
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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
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()
|
||||
@@ -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
|
||||
|
||||
@@ -213,7 +213,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 +334,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 +344,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
|
||||
@@ -828,6 +860,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 +3092,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"));
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user