Spaces:
Paused
Paused
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- plugins/README.md +100 -3
- plugins/_browser_agent/api/status.py +45 -3
- plugins/_browser_agent/assets/init_override.js +246 -3
- plugins/_browser_agent/extensions/python/_functions/agent/Agent/get_browser_model/start/_10_browser_agent.py +7 -3
- plugins/_browser_agent/extensions/webui/get_message_handler/browser-agent-handler.js +54 -3
- plugins/_browser_agent/extensions/webui/get_tool_message_handler/browser-tool-handler.js +15 -3
- plugins/_browser_agent/helpers/__init__.py +1 -3
- plugins/_browser_agent/helpers/browser_llm.py +161 -3
- plugins/_browser_agent/helpers/browser_use.py +4 -3
- plugins/_browser_agent/helpers/browser_use_monkeypatch.py +166 -3
- plugins/_browser_agent/helpers/browser_use_openrouter_compat.py +93 -3
- plugins/_browser_agent/helpers/browser_use_output_sanitize.py +79 -3
- plugins/_browser_agent/helpers/playwright.py +38 -3
- plugins/_browser_agent/prompts/agent.system.tool.browser.md +36 -3
- plugins/_browser_agent/prompts/browser_agent.system.md +22 -3
- plugins/_browser_agent/tools/browser_agent.py +440 -3
- plugins/_browser_agent/webui/main.html +204 -3
- plugins/_browser_agent/webui/thumbnail.jpg +0 -0
- plugins/_chat_branching/README.md +34 -3
- plugins/_chat_branching/api/branch_chat.py +147 -3
- plugins/_chat_branching/extensions/webui/set_messages_after_loop/inject-branch-buttons.js +32 -3
- plugins/_chat_branching/webui/thumbnail.jpg +0 -0
- plugins/_chat_compaction/api/compact_chat.py +68 -3
- plugins/_chat_compaction/extensions/webui/chat-input-bottom-actions-start/compact-button.html +373 -3
- plugins/_chat_compaction/helpers/compactor.py +279 -3
- plugins/_chat_compaction/prompts/compact.msg.md +5 -3
- plugins/_chat_compaction/prompts/compact.sys.md +17 -3
- plugins/_chat_compaction/webui/compact-modal.html +390 -3
- plugins/_chat_compaction/webui/compact-store.js +102 -3
- plugins/_chat_compaction/webui/thumbnail.png +0 -0
- plugins/_code_execution/README.md +52 -3
- plugins/_code_execution/extensions/webui/get_message_handler/code-exe-handler.js +83 -3
- plugins/_code_execution/helpers/shell_local.py +50 -3
- plugins/_code_execution/helpers/shell_ssh.py +245 -3
- plugins/_code_execution/helpers/tty_session.py +327 -3
- plugins/_code_execution/prompts/agent.system.tool.code_exe.md +90 -3
- plugins/_code_execution/prompts/agent.system.tool.input.md +19 -3
- plugins/_code_execution/prompts/fw.code.info.md +1 -3
- plugins/_code_execution/prompts/fw.code.max_time.md +1 -3
- plugins/_code_execution/prompts/fw.code.no_out_time.md +1 -3
- plugins/_code_execution/prompts/fw.code.no_output.md +1 -3
- plugins/_code_execution/prompts/fw.code.pause_dialog.md +1 -3
- plugins/_code_execution/prompts/fw.code.pause_time.md +1 -3
- plugins/_code_execution/prompts/fw.code.reset.md +1 -3
- plugins/_code_execution/prompts/fw.code.running.md +1 -3
- plugins/_code_execution/prompts/fw.code.runtime_wrong.md +5 -3
- plugins/_code_execution/tools/code_execution_tool.py +529 -3
- plugins/_code_execution/tools/input.py +26 -3
- plugins/_code_execution/webui/config.html +204 -3
- plugins/_code_execution/webui/thumbnail.jpg +0 -0
plugins/README.md
CHANGED
|
@@ -1,3 +1,100 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Agent Zero - Core Plugins
|
| 2 |
+
|
| 3 |
+
This directory contains the system-level plugins bundled with Agent Zero.
|
| 4 |
+
|
| 5 |
+
## Directory Structure
|
| 6 |
+
|
| 7 |
+
- `plugins/`: Core system plugins. Reserved for framework updates — do not place custom plugins here.
|
| 8 |
+
- `usr/plugins/`: The correct location for all user-developed and custom plugins. This directory is gitignored.
|
| 9 |
+
|
| 10 |
+
## Documentation
|
| 11 |
+
|
| 12 |
+
For detailed guides on how to create, extend, or configure plugins, refer to:
|
| 13 |
+
|
| 14 |
+
- [`docs/agents/AGENTS.plugins.md`](../docs/agents/AGENTS.plugins.md): Full-stack plugin architecture, manifest format, extension points, and Plugin Index submission.
|
| 15 |
+
- [`docs/developer/plugins.md`](../docs/developer/plugins.md): Human-facing developer guide covering the full plugin lifecycle.
|
| 16 |
+
- [`AGENTS.md`](../AGENTS.md): Main framework guide and backend context.
|
| 17 |
+
- [`skills/a0-plugin-router/SKILL.md`](../skills/a0-plugin-router/SKILL.md): Agent-facing entry point that routes plugin tasks to the appropriate specialist skill.
|
| 18 |
+
- [`skills/a0-create-plugin/SKILL.md`](../skills/a0-create-plugin/SKILL.md): Agent-facing authoring workflow (local and community plugins).
|
| 19 |
+
|
| 20 |
+
## What a Plugin Can Provide
|
| 21 |
+
|
| 22 |
+
Plugins are automatically discovered based on the presence of a `plugin.yaml` file. Each plugin can contribute:
|
| 23 |
+
|
| 24 |
+
- **Backend**: API handlers, tools, helpers, named lifecycle extensions, and implicit `@extensible` hooks under `extensions/python/_functions/...`
|
| 25 |
+
- **Frontend**: HTML/JS UI contributions via core extension breakpoints
|
| 26 |
+
- **Settings**: Isolated configuration scoped per-project and per-agent profile
|
| 27 |
+
- **Activation**: Global and scoped ON/OFF rules via `.toggle-1` and `.toggle-0` files, including advanced per-scope switching in the WebUI
|
| 28 |
+
- **Agent profiles**: Plugin-distributed subagent definitions under `agents/<profile>/agent.yaml`
|
| 29 |
+
|
| 30 |
+
Backend extension layouts:
|
| 31 |
+
- `extensions/python/<point>/` for named lifecycle hooks
|
| 32 |
+
- `extensions/python/_functions/<module>/<qualname>/<start|end>/` for implicit `@extensible` hooks
|
| 33 |
+
|
| 34 |
+
Do not use the retired flattened `extensions/python/<module>_<qualname>_<start|end>/` form.
|
| 35 |
+
|
| 36 |
+
## Plugin Manifest
|
| 37 |
+
|
| 38 |
+
Every plugin requires a `plugin.yaml` at its root:
|
| 39 |
+
|
| 40 |
+
```yaml
|
| 41 |
+
name: my_plugin # required for community plugins
|
| 42 |
+
title: My Plugin
|
| 43 |
+
description: What this plugin does.
|
| 44 |
+
version: 1.0.0
|
| 45 |
+
settings_sections:
|
| 46 |
+
- agent
|
| 47 |
+
per_project_config: false
|
| 48 |
+
per_agent_config: false
|
| 49 |
+
always_enabled: false
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
## Plugin Script (`execute.py`)
|
| 53 |
+
|
| 54 |
+
Plugins can include an optional `execute.py` at the plugin root for user-triggered operations such as setup, post-install steps, maintenance, repairs, migrations, or resource refreshes. Users trigger it from the Plugin List UI.
|
| 55 |
+
|
| 56 |
+
Guidelines:
|
| 57 |
+
- Treat it as a manual plugin script, not as the primary way to use the plugin
|
| 58 |
+
- Prefer making it safe to rerun, or detect state and explain why a rerun is not appropriate
|
| 59 |
+
- Return `0` on success and print progress messages for user feedback
|
| 60 |
+
- Use `hooks.py` instead when the behavior is framework-internal or should happen automatically
|
| 61 |
+
|
| 62 |
+
## Runtime Hooks (`hooks.py`)
|
| 63 |
+
|
| 64 |
+
Plugins can also include an optional `hooks.py` at the plugin root. The framework loads it on demand and can call exported hook functions by name through `helpers.plugins.call_plugin_hook(...)`.
|
| 65 |
+
|
| 66 |
+
- `hooks.py` runs inside the **Agent Zero framework runtime and Python environment**.
|
| 67 |
+
- Use it for framework-internal work such as install hooks, cache preparation, registration, or filesystem setup.
|
| 68 |
+
- If it runs `sys.executable -m pip install ...`, packages are installed into the same Python environment that runs Agent Zero.
|
| 69 |
+
- If you need to install into the separate agent runtime or into the system environment, explicitly target that environment from a subprocess by selecting the correct interpreter, virtualenv, or package manager.
|
| 70 |
+
|
| 71 |
+
In Docker, `hooks.py` normally affects `/opt/venv-a0`; the agent execution runtime is `/opt/venv`.
|
| 72 |
+
|
| 73 |
+
## Plugin Index & Community Sharing
|
| 74 |
+
|
| 75 |
+
The **Plugin Index** at https://github.com/agent0ai/a0-plugins is the community-maintained registry of plugins available to all Agent Zero users.
|
| 76 |
+
|
| 77 |
+
To share a plugin with the community:
|
| 78 |
+
|
| 79 |
+
1. Create a standalone GitHub repository with the plugin contents at the repo root. The runtime `plugin.yaml` must include a `name` field matching the intended index folder name. Add a `LICENSE` file at the repo root (required for Plugin Index listings so users have explicit terms of use).
|
| 80 |
+
2. Fork `https://github.com/agent0ai/a0-plugins` and add a folder `plugins/<your_plugin_name>/` containing a separate index manifest named `index.yaml` (not `plugin.yaml`):
|
| 81 |
+
|
| 82 |
+
```yaml
|
| 83 |
+
title: My Plugin
|
| 84 |
+
description: What this plugin does.
|
| 85 |
+
github: https://github.com/yourname/your-plugin-repo
|
| 86 |
+
tags:
|
| 87 |
+
- tools
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
Optional additional fields: `screenshots` (up to 5 image URLs).
|
| 91 |
+
|
| 92 |
+
3. Open a Pull Request. CI validates the submission; a maintainer reviews and merges.
|
| 93 |
+
|
| 94 |
+
Note: The index `index.yaml` is a **different file with a different schema** from the runtime `plugin.yaml`. Folder names use `^[a-z0-9_]+$` (underscores, no hyphens) and must match the `name` field in the remote `plugin.yaml` exactly.
|
| 95 |
+
|
| 96 |
+
## Plugin Hub
|
| 97 |
+
|
| 98 |
+
Agent Zero now includes a built-in Plugin Hub flow through the always-enabled **Plugin Installer** plugin. From the **Plugins** dialog, users can either open the **Browse** tab or click **Install**, which opens the installer modal on its own **Browse** tab.
|
| 99 |
+
|
| 100 |
+
The Plugin Hub surfaces Plugin Index entries directly in the UI and lets users search, filter, inspect, and install community plugins without leaving Agent Zero.
|
plugins/_browser_agent/api/status.py
CHANGED
|
@@ -1,3 +1,45 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import importlib.metadata
|
| 2 |
+
|
| 3 |
+
from helpers.api import ApiHandler, Request, Response
|
| 4 |
+
from plugins._browser_agent.helpers.playwright import (
|
| 5 |
+
get_playwright_binary,
|
| 6 |
+
get_playwright_cache_dir,
|
| 7 |
+
)
|
| 8 |
+
from plugins._model_config.helpers.model_config import get_chat_model_config
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Status(ApiHandler):
|
| 12 |
+
async def process(self, input: dict, request: Request) -> dict | Response:
|
| 13 |
+
cfg = get_chat_model_config()
|
| 14 |
+
binary = get_playwright_binary()
|
| 15 |
+
|
| 16 |
+
browser_use_ok = False
|
| 17 |
+
browser_use_error = ""
|
| 18 |
+
browser_use_version = ""
|
| 19 |
+
try:
|
| 20 |
+
import browser_use # noqa: F401
|
| 21 |
+
|
| 22 |
+
browser_use_ok = True
|
| 23 |
+
browser_use_version = importlib.metadata.version("browser-use")
|
| 24 |
+
except Exception as e:
|
| 25 |
+
browser_use_error = str(e)
|
| 26 |
+
|
| 27 |
+
return {
|
| 28 |
+
"plugin": "_browser_agent",
|
| 29 |
+
"model_source": "Main Model via _model_config",
|
| 30 |
+
"model": {
|
| 31 |
+
"provider": cfg.get("provider", ""),
|
| 32 |
+
"name": cfg.get("name", ""),
|
| 33 |
+
"vision": bool(cfg.get("vision", False)),
|
| 34 |
+
},
|
| 35 |
+
"playwright": {
|
| 36 |
+
"cache_dir": get_playwright_cache_dir(),
|
| 37 |
+
"binary_found": bool(binary),
|
| 38 |
+
"binary_path": str(binary) if binary else "",
|
| 39 |
+
},
|
| 40 |
+
"browser_use": {
|
| 41 |
+
"import_ok": browser_use_ok,
|
| 42 |
+
"version": browser_use_version,
|
| 43 |
+
"error": browser_use_error,
|
| 44 |
+
},
|
| 45 |
+
}
|
plugins/_browser_agent/assets/init_override.js
CHANGED
|
@@ -1,3 +1,246 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// open all shadow doms
|
| 2 |
+
(function () {
|
| 3 |
+
const originalAttachShadow = Element.prototype.attachShadow;
|
| 4 |
+
Element.prototype.attachShadow = function attachShadow(options) {
|
| 5 |
+
return originalAttachShadow.call(this, { ...options, mode: "open" });
|
| 6 |
+
};
|
| 7 |
+
})();
|
| 8 |
+
|
| 9 |
+
// // Create a global bridge for iframe communication
|
| 10 |
+
// (function() {
|
| 11 |
+
// let elementCounter = 0;
|
| 12 |
+
// const ignoredTags = [
|
| 13 |
+
// "style",
|
| 14 |
+
// "script",
|
| 15 |
+
// "meta",
|
| 16 |
+
// "link",
|
| 17 |
+
// "svg",
|
| 18 |
+
// "noscript",
|
| 19 |
+
// "path",
|
| 20 |
+
// ];
|
| 21 |
+
|
| 22 |
+
// function isElementVisible(element) {
|
| 23 |
+
// // Return true for non-element nodes
|
| 24 |
+
// if (element.nodeType !== Node.ELEMENT_NODE) {
|
| 25 |
+
// return true;
|
| 26 |
+
// }
|
| 27 |
+
|
| 28 |
+
// const computedStyle = window.getComputedStyle(element);
|
| 29 |
+
|
| 30 |
+
// // Check if element is hidden via CSS
|
| 31 |
+
// if (
|
| 32 |
+
// computedStyle.display === "none" ||
|
| 33 |
+
// computedStyle.visibility === "hidden" ||
|
| 34 |
+
// computedStyle.opacity === "0"
|
| 35 |
+
// ) {
|
| 36 |
+
// return false;
|
| 37 |
+
// }
|
| 38 |
+
|
| 39 |
+
// // Check for hidden input type
|
| 40 |
+
// if (element.tagName === "INPUT" && element.type === "hidden") {
|
| 41 |
+
// return false;
|
| 42 |
+
// }
|
| 43 |
+
|
| 44 |
+
// // Check for hidden attribute
|
| 45 |
+
// if (
|
| 46 |
+
// element.hasAttribute("hidden") ||
|
| 47 |
+
// element.getAttribute("aria-hidden") === "true"
|
| 48 |
+
// ) {
|
| 49 |
+
// return false;
|
| 50 |
+
// }
|
| 51 |
+
|
| 52 |
+
// return true;
|
| 53 |
+
// }
|
| 54 |
+
|
| 55 |
+
// function convertAttribute(tag, attr) {
|
| 56 |
+
// let out = {
|
| 57 |
+
// name: attr.name,
|
| 58 |
+
// value: attr.value,
|
| 59 |
+
// };
|
| 60 |
+
|
| 61 |
+
// if (["srcset"].includes(out.name)) return null;
|
| 62 |
+
// if (out.name.startsWith("data-") && out.name != "data-A0UID" && out.name != "data-a0-frame-id") return null;
|
| 63 |
+
|
| 64 |
+
// if (tag === "img" && out.value.startsWith("data:")) out.value = "data...";
|
| 65 |
+
|
| 66 |
+
// return out;
|
| 67 |
+
// }
|
| 68 |
+
|
| 69 |
+
// // This function will be available in all frames
|
| 70 |
+
// window.__A0_extractFrameContent = function() {
|
| 71 |
+
// // Get the current frame's DOM content
|
| 72 |
+
// const extractContent = (node) => {
|
| 73 |
+
// if (!node) return "";
|
| 74 |
+
|
| 75 |
+
// let content = "";
|
| 76 |
+
// const tagName = node.tagName ? node.tagName.toLowerCase() : "";
|
| 77 |
+
|
| 78 |
+
// // Skip ignored tags
|
| 79 |
+
// if (tagName && ignoredTags.includes(tagName)) {
|
| 80 |
+
// return "";
|
| 81 |
+
// }
|
| 82 |
+
|
| 83 |
+
// if (node.nodeType === Node.ELEMENT_NODE) {
|
| 84 |
+
// // Add unique ID to the actual DOM element
|
| 85 |
+
// if (tagName) {
|
| 86 |
+
// const uid = elementCounter++;
|
| 87 |
+
// node.setAttribute("data-A0UID", uid);
|
| 88 |
+
// }
|
| 89 |
+
|
| 90 |
+
// content += `<${tagName}`;
|
| 91 |
+
|
| 92 |
+
// // Add invisible attribute if element is not visible
|
| 93 |
+
// if (!isElementVisible(node)) {
|
| 94 |
+
// content += " invisible";
|
| 95 |
+
// }
|
| 96 |
+
|
| 97 |
+
// // Add attributes with conversion
|
| 98 |
+
// for (let attr of node.attributes) {
|
| 99 |
+
// const out = convertAttribute(tagName, attr);
|
| 100 |
+
// if (out) content += ` ${out.name}="${out.value}"`;
|
| 101 |
+
// }
|
| 102 |
+
|
| 103 |
+
// if (tagName) {
|
| 104 |
+
// content += ` selector="${node.getAttribute("data-A0UID")}"`;
|
| 105 |
+
// }
|
| 106 |
+
|
| 107 |
+
// content += ">";
|
| 108 |
+
|
| 109 |
+
// // Handle shadow DOM
|
| 110 |
+
// if (node.shadowRoot) {
|
| 111 |
+
// content += "<!-- Shadow DOM Start -->";
|
| 112 |
+
// for (let shadowChild of node.shadowRoot.childNodes) {
|
| 113 |
+
// content += extractContent(shadowChild);
|
| 114 |
+
// }
|
| 115 |
+
// content += "<!-- Shadow DOM End -->";
|
| 116 |
+
// }
|
| 117 |
+
|
| 118 |
+
// // Handle child nodes
|
| 119 |
+
// for (let child of node.childNodes) {
|
| 120 |
+
// content += extractContent(child);
|
| 121 |
+
// }
|
| 122 |
+
|
| 123 |
+
// content += `</${tagName}>`;
|
| 124 |
+
// } else if (node.nodeType === Node.TEXT_NODE) {
|
| 125 |
+
// content += node.textContent;
|
| 126 |
+
// } else if (node.nodeType === Node.COMMENT_NODE) {
|
| 127 |
+
// content += `<!--${node.textContent}-->`;
|
| 128 |
+
// }
|
| 129 |
+
|
| 130 |
+
// return content;
|
| 131 |
+
// };
|
| 132 |
+
|
| 133 |
+
// return extractContent(document.documentElement);
|
| 134 |
+
// };
|
| 135 |
+
|
| 136 |
+
// // Setup message listener in each frame
|
| 137 |
+
// window.addEventListener('message', function(event) {
|
| 138 |
+
// if (event.data === 'A0_REQUEST_CONTENT') {
|
| 139 |
+
// // Extract content and send it back to parent
|
| 140 |
+
// const content = window.__A0_extractFrameContent();
|
| 141 |
+
// // Use '*' as targetOrigin since we're in a controlled environment
|
| 142 |
+
// window.parent.postMessage({
|
| 143 |
+
// type: 'A0_FRAME_CONTENT',
|
| 144 |
+
// content: content,
|
| 145 |
+
// frameId: window.frameElement?.getAttribute('data-a0-frame-id')
|
| 146 |
+
// }, '*');
|
| 147 |
+
// }
|
| 148 |
+
// });
|
| 149 |
+
|
| 150 |
+
// // Function to extract content from all frames
|
| 151 |
+
// window.__A0_extractAllFramesContent = async function(rootNode = document) {
|
| 152 |
+
// let content = "";
|
| 153 |
+
|
| 154 |
+
// // Extract content from current document
|
| 155 |
+
// content += window.__A0_extractFrameContent();
|
| 156 |
+
|
| 157 |
+
// // Find all iframes
|
| 158 |
+
// const iframes = rootNode.getElementsByTagName('iframe');
|
| 159 |
+
|
| 160 |
+
// // Create a map to store frame contents
|
| 161 |
+
// const frameContents = new Map();
|
| 162 |
+
|
| 163 |
+
// // Setup promise for each iframe
|
| 164 |
+
// const framePromises = Array.from(iframes).map((iframe) => {
|
| 165 |
+
// return new Promise((resolve) => {
|
| 166 |
+
// const frameId = 'frame_' + Math.random().toString(36).substr(2, 9);
|
| 167 |
+
// iframe.setAttribute('data-a0-frame-id', frameId);
|
| 168 |
+
|
| 169 |
+
// // Setup one-time message listener for this specific frame
|
| 170 |
+
// const listener = function(event) {
|
| 171 |
+
// if (event.data?.type === 'A0_FRAME_CONTENT' &&
|
| 172 |
+
// event.data?.frameId === frameId) {
|
| 173 |
+
// frameContents.set(frameId, event.data.content);
|
| 174 |
+
// window.removeEventListener('message', listener);
|
| 175 |
+
// resolve();
|
| 176 |
+
// }
|
| 177 |
+
// };
|
| 178 |
+
// window.addEventListener('message', listener);
|
| 179 |
+
|
| 180 |
+
// // Request content from frame
|
| 181 |
+
// iframe.contentWindow.postMessage('A0_REQUEST_CONTENT', '*');
|
| 182 |
+
|
| 183 |
+
// // Timeout after 2 seconds
|
| 184 |
+
// setTimeout(resolve, 2000);
|
| 185 |
+
// });
|
| 186 |
+
// });
|
| 187 |
+
|
| 188 |
+
// // Wait for all frames to respond or timeout
|
| 189 |
+
// await Promise.all(framePromises);
|
| 190 |
+
|
| 191 |
+
// // Add frame contents in order
|
| 192 |
+
// for (let iframe of iframes) {
|
| 193 |
+
// const frameId = iframe.getAttribute('data-a0-frame-id');
|
| 194 |
+
// const frameContent = frameContents.get(frameId);
|
| 195 |
+
// if (frameContent) {
|
| 196 |
+
// content += `<!-- IFrame ${iframe.src || 'unnamed'} Content Start -->`;
|
| 197 |
+
// content += frameContent;
|
| 198 |
+
// content += `<!-- IFrame Content End -->`;
|
| 199 |
+
// }
|
| 200 |
+
// }
|
| 201 |
+
|
| 202 |
+
// return content;
|
| 203 |
+
// };
|
| 204 |
+
// })();
|
| 205 |
+
|
| 206 |
+
// // override iframe creation to inject our script into them
|
| 207 |
+
// (function() {
|
| 208 |
+
// // Store the original createElement to use for iframe creation
|
| 209 |
+
// const originalCreateElement = document.createElement;
|
| 210 |
+
|
| 211 |
+
// // Override createElement to catch iframe creation
|
| 212 |
+
// document.createElement = function(tagName, options) {
|
| 213 |
+
// const element = originalCreateElement.call(document, tagName, options);
|
| 214 |
+
// if (tagName.toLowerCase() === 'iframe') {
|
| 215 |
+
// // Override the src setter
|
| 216 |
+
// const originalSrcSetter = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'src').set;
|
| 217 |
+
// Object.defineProperty(element, 'src', {
|
| 218 |
+
// set: function(value) {
|
| 219 |
+
// // Call original setter
|
| 220 |
+
// originalSrcSetter.call(this, value);
|
| 221 |
+
|
| 222 |
+
// // Wait for load and inject our script
|
| 223 |
+
// this.addEventListener('load', () => {
|
| 224 |
+
// try {
|
| 225 |
+
// // Try to inject our script into the iframe
|
| 226 |
+
// const iframeDoc = this.contentWindow.document;
|
| 227 |
+
// const script = iframeDoc.createElement('script');
|
| 228 |
+
// script.textContent = `
|
| 229 |
+
// // Make iframe accessible
|
| 230 |
+
// document.domain = document.domain;
|
| 231 |
+
// // Disable security policies if possible
|
| 232 |
+
// if (window.SecurityPolicyViolationEvent) {
|
| 233 |
+
// window.SecurityPolicyViolationEvent = undefined;
|
| 234 |
+
// }
|
| 235 |
+
// `;
|
| 236 |
+
// iframeDoc.head.appendChild(script);
|
| 237 |
+
// } catch(e) {
|
| 238 |
+
// console.warn('Could not inject into iframe:', e);
|
| 239 |
+
// }
|
| 240 |
+
// }, { once: true });
|
| 241 |
+
// }
|
| 242 |
+
// });
|
| 243 |
+
// }
|
| 244 |
+
// return element;
|
| 245 |
+
// };
|
| 246 |
+
// })();
|
plugins/_browser_agent/extensions/python/_functions/agent/Agent/get_browser_model/start/_10_browser_agent.py
CHANGED
|
@@ -1,3 +1,7 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from helpers.extension import Extension
|
| 2 |
+
from plugins._browser_agent.helpers.browser_llm import build_browser_model_for_agent
|
| 3 |
+
|
| 4 |
+
class BrowserModelProvider(Extension):
|
| 5 |
+
def execute(self, data: dict = {}, **kwargs):
|
| 6 |
+
if self.agent:
|
| 7 |
+
data["result"] = build_browser_model_for_agent(self.agent)
|
plugins/_browser_agent/extensions/webui/get_message_handler/browser-agent-handler.js
CHANGED
|
@@ -1,3 +1,54 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
createActionButton,
|
| 3 |
+
copyToClipboard,
|
| 4 |
+
} from "/components/messages/action-buttons/simple-action-buttons.js";
|
| 5 |
+
import { store as stepDetailStore } from "/components/modals/process-step-detail/step-detail-store.js";
|
| 6 |
+
import { store as speechStore } from "/components/chat/speech/speech-store.js";
|
| 7 |
+
import {
|
| 8 |
+
buildDetailPayload,
|
| 9 |
+
cleanStepTitle,
|
| 10 |
+
drawProcessStep,
|
| 11 |
+
} from "/js/messages.js";
|
| 12 |
+
|
| 13 |
+
export default async function registerBrowserAgentHandler(extData) {
|
| 14 |
+
if (extData?.type === "browser") {
|
| 15 |
+
extData.handler = drawMessageBrowserAgent;
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function drawMessageBrowserAgent({
|
| 20 |
+
id,
|
| 21 |
+
type,
|
| 22 |
+
heading,
|
| 23 |
+
content,
|
| 24 |
+
kvps,
|
| 25 |
+
timestamp,
|
| 26 |
+
agentno = 0,
|
| 27 |
+
...additional
|
| 28 |
+
}) {
|
| 29 |
+
const title = cleanStepTitle(heading);
|
| 30 |
+
const displayKvps = { ...kvps };
|
| 31 |
+
const answerText = String(kvps?.answer ?? "");
|
| 32 |
+
const actionButtons = answerText.trim()
|
| 33 |
+
? [
|
| 34 |
+
createActionButton("detail", "", () =>
|
| 35 |
+
stepDetailStore.showStepDetail(
|
| 36 |
+
buildDetailPayload(arguments[0], { headerLabels: [] }),
|
| 37 |
+
),
|
| 38 |
+
),
|
| 39 |
+
createActionButton("speak", "", () => speechStore.speak(answerText)),
|
| 40 |
+
createActionButton("copy", "", () => copyToClipboard(answerText)),
|
| 41 |
+
].filter(Boolean)
|
| 42 |
+
: [];
|
| 43 |
+
|
| 44 |
+
return drawProcessStep({
|
| 45 |
+
id,
|
| 46 |
+
title,
|
| 47 |
+
code: "WWW",
|
| 48 |
+
classes: undefined,
|
| 49 |
+
kvps: displayKvps,
|
| 50 |
+
content,
|
| 51 |
+
actionButtons,
|
| 52 |
+
log: arguments[0],
|
| 53 |
+
});
|
| 54 |
+
}
|
plugins/_browser_agent/extensions/webui/get_tool_message_handler/browser-tool-handler.js
CHANGED
|
@@ -1,3 +1,15 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { drawMessageToolSimple } from "/js/messages.js";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Registers the browser_agent tool message handler to set the custom badge.
|
| 5 |
+
* @param {object} extData
|
| 6 |
+
*/
|
| 7 |
+
export default async function registerBrowserToolHandler(extData) {
|
| 8 |
+
if (extData?.tool_name === "browser_agent") {
|
| 9 |
+
extData.handler = drawBrowserTool;
|
| 10 |
+
}
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function drawBrowserTool(args) {
|
| 14 |
+
return drawMessageToolSimple({ ...args, code: "WWW" });
|
| 15 |
+
}
|
plugins/_browser_agent/helpers/__init__.py
CHANGED
|
@@ -1,3 +1 @@
|
|
| 1 |
-
|
| 2 |
-
oid sha256:676264791b96a553a80980172a7f12b7a47be5c9afb2563503ce3cfa00a983fb
|
| 3 |
-
size 34
|
|
|
|
| 1 |
+
# Built-in browser agent helpers.
|
|
|
|
|
|
plugins/_browser_agent/helpers/browser_llm.py
CHANGED
|
@@ -1,3 +1,161 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, List, Optional
|
| 2 |
+
import litellm
|
| 3 |
+
from litellm import acompletion
|
| 4 |
+
from langchain_core.callbacks.manager import CallbackManagerForLLMRun
|
| 5 |
+
from langchain_core.messages import BaseMessage
|
| 6 |
+
|
| 7 |
+
import models
|
| 8 |
+
from browser_use.llm import ChatGoogle, ChatOpenRouter
|
| 9 |
+
|
| 10 |
+
from plugins._browser_agent.helpers import browser_use_monkeypatch
|
| 11 |
+
from plugins._browser_agent.helpers import browser_use_openrouter_compat
|
| 12 |
+
from plugins._browser_agent.helpers import browser_use_output_sanitize
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
_BROWSER_USE_PATCHED = False
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def apply_browser_use_patches() -> None:
|
| 19 |
+
global _BROWSER_USE_PATCHED
|
| 20 |
+
if _BROWSER_USE_PATCHED:
|
| 21 |
+
return
|
| 22 |
+
|
| 23 |
+
browser_use_monkeypatch.apply()
|
| 24 |
+
litellm.modify_params = True
|
| 25 |
+
_BROWSER_USE_PATCHED = True
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class AsyncAIChatReplacement:
|
| 29 |
+
class _Completions:
|
| 30 |
+
def __init__(self, wrapper):
|
| 31 |
+
self._wrapper = wrapper
|
| 32 |
+
|
| 33 |
+
async def create(self, *args, **kwargs):
|
| 34 |
+
return await self._wrapper._acall(*args, **kwargs)
|
| 35 |
+
|
| 36 |
+
class _Chat:
|
| 37 |
+
def __init__(self, wrapper):
|
| 38 |
+
self.completions = AsyncAIChatReplacement._Completions(wrapper)
|
| 39 |
+
|
| 40 |
+
def __init__(self, wrapper, *args, **kwargs):
|
| 41 |
+
self._wrapper = wrapper
|
| 42 |
+
self.chat = AsyncAIChatReplacement._Chat(wrapper)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class BrowserCompatibleChatWrapper(ChatOpenRouter):
|
| 46 |
+
"""
|
| 47 |
+
A wrapper for browser agent that can filter/sanitize messages
|
| 48 |
+
before sending them to the LLM.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
def __init__(self, *args, **kwargs):
|
| 52 |
+
apply_browser_use_patches()
|
| 53 |
+
models.turn_off_logging()
|
| 54 |
+
self._wrapper = models.LiteLLMChatWrapper(*args, **kwargs)
|
| 55 |
+
self.model = self._wrapper.model_name
|
| 56 |
+
self.kwargs = self._wrapper.kwargs
|
| 57 |
+
|
| 58 |
+
@property
|
| 59 |
+
def model_name(self) -> str:
|
| 60 |
+
return self._wrapper.model_name
|
| 61 |
+
|
| 62 |
+
@property
|
| 63 |
+
def provider(self) -> str:
|
| 64 |
+
return self._wrapper.provider
|
| 65 |
+
|
| 66 |
+
def get_client(self, *args, **kwargs): # type: ignore
|
| 67 |
+
return AsyncAIChatReplacement(self, *args, **kwargs)
|
| 68 |
+
|
| 69 |
+
async def _acall(
|
| 70 |
+
self,
|
| 71 |
+
messages: List[BaseMessage],
|
| 72 |
+
stop: Optional[List[str]] = None,
|
| 73 |
+
run_manager: Optional[CallbackManagerForLLMRun] = None,
|
| 74 |
+
**kwargs: Any,
|
| 75 |
+
):
|
| 76 |
+
models.apply_rate_limiter_sync(self._wrapper.a0_model_conf, str(messages))
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
model = kwargs.pop("model", None)
|
| 80 |
+
effective_model = model or self._wrapper.model_name
|
| 81 |
+
kwrgs = {**self._wrapper.kwargs, **kwargs}
|
| 82 |
+
request_messages = messages
|
| 83 |
+
|
| 84 |
+
# hack from browser-use to fix json schema for gemini (additionalProperties, $defs, $ref)
|
| 85 |
+
if "response_format" in kwrgs and "json_schema" in kwrgs["response_format"] and effective_model and effective_model.startswith("gemini/"):
|
| 86 |
+
kwrgs["response_format"]["json_schema"] = ChatGoogle("")._fix_gemini_schema(kwrgs["response_format"]["json_schema"])
|
| 87 |
+
|
| 88 |
+
if browser_use_openrouter_compat.should_use_openrouter_prompt_schema_fallback(
|
| 89 |
+
provider=self.provider,
|
| 90 |
+
model_name=effective_model,
|
| 91 |
+
kwargs=kwrgs,
|
| 92 |
+
):
|
| 93 |
+
fallback_request = browser_use_openrouter_compat.build_json_object_fallback_request(
|
| 94 |
+
messages=messages,
|
| 95 |
+
kwargs=kwrgs,
|
| 96 |
+
)
|
| 97 |
+
if fallback_request is not None:
|
| 98 |
+
request_messages, kwrgs = fallback_request
|
| 99 |
+
|
| 100 |
+
resp = await acompletion(
|
| 101 |
+
model=self._wrapper.model_name,
|
| 102 |
+
messages=request_messages,
|
| 103 |
+
stop=stop,
|
| 104 |
+
**kwrgs,
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# Gemini: strip triple backticks and conform schema
|
| 108 |
+
try:
|
| 109 |
+
msg = resp.choices[0].message # type: ignore
|
| 110 |
+
if self.provider == "gemini" and isinstance(getattr(msg, "content", None), str):
|
| 111 |
+
cleaned = browser_use_monkeypatch.gemini_clean_and_conform(msg.content) # type: ignore
|
| 112 |
+
if cleaned:
|
| 113 |
+
msg.content = cleaned
|
| 114 |
+
except Exception:
|
| 115 |
+
pass
|
| 116 |
+
|
| 117 |
+
except Exception as e:
|
| 118 |
+
raise e
|
| 119 |
+
|
| 120 |
+
# Structured output: normalize keys/models reject (e.g. "" on action dicts) and repair partial JSON
|
| 121 |
+
try:
|
| 122 |
+
rf = kwrgs.get("response_format") or {}
|
| 123 |
+
if "json_schema" in rf or "json_object" in rf:
|
| 124 |
+
msg_obj = resp.choices[0].message
|
| 125 |
+
raw_content = getattr(msg_obj, "content", None)
|
| 126 |
+
fixed = browser_use_output_sanitize.sanitize_llm_message_content_for_browser_use(raw_content) # type: ignore[arg-type]
|
| 127 |
+
if fixed is not None:
|
| 128 |
+
msg_obj.content = fixed
|
| 129 |
+
except Exception:
|
| 130 |
+
pass
|
| 131 |
+
|
| 132 |
+
return resp
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def build_browser_model_from_config(
|
| 136 |
+
model_config: models.ModelConfig,
|
| 137 |
+
) -> BrowserCompatibleChatWrapper:
|
| 138 |
+
apply_browser_use_patches()
|
| 139 |
+
original_provider = model_config.provider.lower()
|
| 140 |
+
provider_name, kwargs = models._merge_provider_defaults( # type: ignore[attr-defined]
|
| 141 |
+
"chat", original_provider, model_config.build_kwargs()
|
| 142 |
+
)
|
| 143 |
+
return models._get_litellm_chat( # type: ignore[attr-defined]
|
| 144 |
+
BrowserCompatibleChatWrapper,
|
| 145 |
+
model_config.name,
|
| 146 |
+
provider_name,
|
| 147 |
+
model_config,
|
| 148 |
+
**kwargs,
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
def build_browser_model_for_agent(agent=None) -> BrowserCompatibleChatWrapper:
|
| 152 |
+
"""Build and return the browser-use adapter using chat model config."""
|
| 153 |
+
from plugins._model_config.helpers.model_config import (
|
| 154 |
+
get_chat_model_config,
|
| 155 |
+
build_model_config,
|
| 156 |
+
)
|
| 157 |
+
import models
|
| 158 |
+
|
| 159 |
+
cfg = get_chat_model_config(agent)
|
| 160 |
+
mc = build_model_config(cfg, models.ModelType.CHAT)
|
| 161 |
+
return build_browser_model_from_config(mc)
|
plugins/_browser_agent/helpers/browser_use.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
| 1 |
+
from helpers import dotenv
|
| 2 |
+
dotenv.save_dotenv_value("ANONYMIZED_TELEMETRY", "false")
|
| 3 |
+
import browser_use
|
| 4 |
+
import browser_use.utils
|
plugins/_browser_agent/helpers/browser_use_monkeypatch.py
CHANGED
|
@@ -1,3 +1,166 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
from browser_use.llm import ChatGoogle
|
| 3 |
+
from helpers import dirty_json
|
| 4 |
+
|
| 5 |
+
from plugins._browser_agent.helpers import browser_use_output_sanitize
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
# ------------------------------------------------------------------------------
|
| 9 |
+
# Gemini Helper for Output Conformance
|
| 10 |
+
# ------------------------------------------------------------------------------
|
| 11 |
+
# This function sanitizes and conforms the JSON output from Gemini to match
|
| 12 |
+
# the specific schema expectations of the browser-use library. It handles
|
| 13 |
+
# markdown fences, aliases actions (like 'complete_task' to 'done'), and
|
| 14 |
+
# intelligently constructs a valid 'data' object for the final action.
|
| 15 |
+
|
| 16 |
+
def gemini_clean_and_conform(text: str):
|
| 17 |
+
obj = None
|
| 18 |
+
try:
|
| 19 |
+
# dirty_json parser is robust enough to handle markdown fences
|
| 20 |
+
obj = dirty_json.parse(text)
|
| 21 |
+
except Exception:
|
| 22 |
+
return None # return None if parsing fails
|
| 23 |
+
|
| 24 |
+
if not isinstance(obj, dict):
|
| 25 |
+
return None
|
| 26 |
+
|
| 27 |
+
obj = browser_use_output_sanitize.normalize_parsed_browser_use_output(obj)
|
| 28 |
+
|
| 29 |
+
# Conform actions to browser-use expectations
|
| 30 |
+
if isinstance(obj.get("action"), list):
|
| 31 |
+
normalized_actions = []
|
| 32 |
+
for item in obj["action"]:
|
| 33 |
+
if not isinstance(item, dict):
|
| 34 |
+
continue # Skip non-dict items
|
| 35 |
+
|
| 36 |
+
action_key, action_value = next(iter(item.items()), (None, None))
|
| 37 |
+
if not action_key:
|
| 38 |
+
continue
|
| 39 |
+
|
| 40 |
+
# Alias 'complete_task' to 'done' to handle inconsistencies
|
| 41 |
+
if action_key == "complete_task":
|
| 42 |
+
action_key = "done"
|
| 43 |
+
|
| 44 |
+
# Create a mutable copy of the value
|
| 45 |
+
v = (action_value or {}).copy()
|
| 46 |
+
|
| 47 |
+
if action_key in ("scroll_down", "scroll_up", "scroll"):
|
| 48 |
+
is_down = action_key != "scroll_up"
|
| 49 |
+
v.setdefault("down", is_down)
|
| 50 |
+
v.setdefault("num_pages", 1.0)
|
| 51 |
+
normalized_actions.append({"scroll": v})
|
| 52 |
+
elif action_key == "go_to_url":
|
| 53 |
+
v.setdefault("new_tab", False)
|
| 54 |
+
normalized_actions.append({action_key: v})
|
| 55 |
+
elif action_key == "done":
|
| 56 |
+
# If `data` is missing, construct it from other keys
|
| 57 |
+
if "data" not in v:
|
| 58 |
+
# Pop fields from the top-level `done` object
|
| 59 |
+
response_text = v.pop("response", None)
|
| 60 |
+
summary_text = v.pop("page_summary", None)
|
| 61 |
+
title_text = v.pop("title", "Task Completed")
|
| 62 |
+
|
| 63 |
+
final_response = response_text or "Task completed successfully." # browser-use expects string
|
| 64 |
+
final_summary = summary_text or "No page summary available." # browser-use expects string
|
| 65 |
+
|
| 66 |
+
v["data"] = {
|
| 67 |
+
"title": title_text,
|
| 68 |
+
"response": final_response,
|
| 69 |
+
"page_summary": final_summary,
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
v.setdefault("success", True)
|
| 73 |
+
normalized_actions.append({action_key: v})
|
| 74 |
+
else:
|
| 75 |
+
normalized_actions.append(item)
|
| 76 |
+
obj["action"] = normalized_actions
|
| 77 |
+
|
| 78 |
+
return dirty_json.stringify(obj)
|
| 79 |
+
|
| 80 |
+
# ------------------------------------------------------------------------------
|
| 81 |
+
# Monkey-patch for browser-use Gemini schema issue
|
| 82 |
+
# ------------------------------------------------------------------------------
|
| 83 |
+
# The original _fix_gemini_schema in browser_use.llm.google.chat.ChatGoogle
|
| 84 |
+
# removes the 'title' property but fails to remove it from the 'required' list,
|
| 85 |
+
# causing a validation error with the Gemini API. This patch corrects that behavior.
|
| 86 |
+
|
| 87 |
+
def _patched_fix_gemini_schema(self, schema: dict[str, Any]) -> dict[str, Any]:
|
| 88 |
+
"""
|
| 89 |
+
Convert a Pydantic model to a Gemini-compatible schema.
|
| 90 |
+
|
| 91 |
+
This function removes unsupported properties like 'additionalProperties' and resolves
|
| 92 |
+
$ref references that Gemini doesn't support.
|
| 93 |
+
"""
|
| 94 |
+
|
| 95 |
+
# Handle $defs and $ref resolution
|
| 96 |
+
if '$defs' in schema:
|
| 97 |
+
defs = schema.pop('$defs')
|
| 98 |
+
|
| 99 |
+
def resolve_refs(obj: Any) -> Any:
|
| 100 |
+
if isinstance(obj, dict):
|
| 101 |
+
if '$ref' in obj:
|
| 102 |
+
ref = obj.pop('$ref')
|
| 103 |
+
ref_name = ref.split('/')[-1]
|
| 104 |
+
if ref_name in defs:
|
| 105 |
+
# Replace the reference with the actual definition
|
| 106 |
+
resolved = defs[ref_name].copy()
|
| 107 |
+
# Merge any additional properties from the reference
|
| 108 |
+
for key, value in obj.items():
|
| 109 |
+
if key != '$ref':
|
| 110 |
+
resolved[key] = value
|
| 111 |
+
return resolve_refs(resolved)
|
| 112 |
+
return obj
|
| 113 |
+
else:
|
| 114 |
+
# Recursively process all dictionary values
|
| 115 |
+
return {k: resolve_refs(v) for k, v in obj.items()}
|
| 116 |
+
elif isinstance(obj, list):
|
| 117 |
+
return [resolve_refs(item) for item in obj]
|
| 118 |
+
return obj
|
| 119 |
+
|
| 120 |
+
schema = resolve_refs(schema)
|
| 121 |
+
|
| 122 |
+
# Remove unsupported properties
|
| 123 |
+
def clean_schema(obj: Any) -> Any:
|
| 124 |
+
if isinstance(obj, dict):
|
| 125 |
+
# Remove unsupported properties
|
| 126 |
+
cleaned = {}
|
| 127 |
+
for key, value in obj.items():
|
| 128 |
+
if key not in ['additionalProperties', 'title', 'default']:
|
| 129 |
+
cleaned_value = clean_schema(value)
|
| 130 |
+
# Handle empty object properties - Gemini doesn't allow empty OBJECT types
|
| 131 |
+
if (
|
| 132 |
+
key == 'properties'
|
| 133 |
+
and isinstance(cleaned_value, dict)
|
| 134 |
+
and len(cleaned_value) == 0
|
| 135 |
+
and isinstance(obj.get('type', ''), str)
|
| 136 |
+
and obj.get('type', '').upper() == 'OBJECT'
|
| 137 |
+
):
|
| 138 |
+
# Convert empty object to have at least one property
|
| 139 |
+
cleaned['properties'] = {'_placeholder': {'type': 'string'}}
|
| 140 |
+
else:
|
| 141 |
+
cleaned[key] = cleaned_value
|
| 142 |
+
|
| 143 |
+
# If this is an object type with empty properties, add a placeholder
|
| 144 |
+
if (
|
| 145 |
+
isinstance(cleaned.get('type', ''), str)
|
| 146 |
+
and cleaned.get('type', '').upper() == 'OBJECT'
|
| 147 |
+
and 'properties' in cleaned
|
| 148 |
+
and isinstance(cleaned['properties'], dict)
|
| 149 |
+
and len(cleaned['properties']) == 0
|
| 150 |
+
):
|
| 151 |
+
cleaned['properties'] = {'_placeholder': {'type': 'string'}}
|
| 152 |
+
|
| 153 |
+
# PATCH: Also remove 'title' from the required list if it exists
|
| 154 |
+
if 'required' in cleaned and isinstance(cleaned.get('required'), list):
|
| 155 |
+
cleaned['required'] = [p for p in cleaned['required'] if p != 'title']
|
| 156 |
+
|
| 157 |
+
return cleaned
|
| 158 |
+
elif isinstance(obj, list):
|
| 159 |
+
return [clean_schema(item) for item in obj]
|
| 160 |
+
return obj
|
| 161 |
+
|
| 162 |
+
return clean_schema(schema)
|
| 163 |
+
|
| 164 |
+
def apply():
|
| 165 |
+
"""Applies the monkey-patch to ChatGoogle."""
|
| 166 |
+
ChatGoogle._fix_gemini_schema = _patched_fix_gemini_schema
|
plugins/_browser_agent/helpers/browser_use_openrouter_compat.py
CHANGED
|
@@ -1,3 +1,93 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import copy
|
| 4 |
+
import json
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
def is_openrouter_request(provider: str | None, model_name: str | None) -> bool:
|
| 8 |
+
provider_name = (provider or "").lower()
|
| 9 |
+
model = (model_name or "").lower()
|
| 10 |
+
return provider_name == "openrouter" or model.startswith("openrouter/")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def has_json_schema_response_format(kwargs: dict[str, Any]) -> bool:
|
| 14 |
+
response_format = kwargs.get("response_format")
|
| 15 |
+
return isinstance(response_format, dict) and (
|
| 16 |
+
response_format.get("type") == "json_schema" or "json_schema" in response_format
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def should_use_openrouter_prompt_schema_fallback(
|
| 21 |
+
provider: str | None, model_name: str | None, kwargs: dict[str, Any]
|
| 22 |
+
) -> bool:
|
| 23 |
+
"""
|
| 24 |
+
OpenRouter sometimes routes browser-use structured output through providers
|
| 25 |
+
that reject large compiled grammars. Avoid the hard error entirely by
|
| 26 |
+
downgrading to `json_object` before the first request.
|
| 27 |
+
"""
|
| 28 |
+
return is_openrouter_request(provider, model_name) and has_json_schema_response_format(kwargs)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def relax_strict_tool_schemas(tools: Any) -> Any:
|
| 32 |
+
"""
|
| 33 |
+
Disable strict tool grammar on fallback while keeping tool definitions intact.
|
| 34 |
+
"""
|
| 35 |
+
if not isinstance(tools, list):
|
| 36 |
+
return tools
|
| 37 |
+
|
| 38 |
+
relaxed = copy.deepcopy(tools)
|
| 39 |
+
for tool in relaxed:
|
| 40 |
+
if not isinstance(tool, dict):
|
| 41 |
+
continue
|
| 42 |
+
function_spec = tool.get("function")
|
| 43 |
+
if isinstance(function_spec, dict) and function_spec.get("strict") is True:
|
| 44 |
+
function_spec["strict"] = False
|
| 45 |
+
return relaxed
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _schema_hint_text(response_format: dict[str, Any]) -> str | None:
|
| 49 |
+
schema_payload = response_format.get("json_schema")
|
| 50 |
+
if not isinstance(schema_payload, dict):
|
| 51 |
+
return None
|
| 52 |
+
|
| 53 |
+
compact_schema = json.dumps(
|
| 54 |
+
schema_payload,
|
| 55 |
+
ensure_ascii=False,
|
| 56 |
+
separators=(",", ":"),
|
| 57 |
+
)
|
| 58 |
+
return (
|
| 59 |
+
"Return only a single JSON object with no markdown fences, prose, or extra text. "
|
| 60 |
+
"Follow this schema exactly: "
|
| 61 |
+
f"{compact_schema}"
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def prepend_schema_hint_to_messages(
|
| 66 |
+
messages: list[Any], response_format: dict[str, Any]
|
| 67 |
+
) -> list[Any]:
|
| 68 |
+
hint = _schema_hint_text(response_format)
|
| 69 |
+
if not hint:
|
| 70 |
+
return list(messages)
|
| 71 |
+
return [{"role": "system", "content": hint}, *list(messages)]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def build_json_object_fallback_request(
|
| 75 |
+
messages: list[Any],
|
| 76 |
+
kwargs: dict[str, Any],
|
| 77 |
+
) -> tuple[list[Any], dict[str, Any]] | None:
|
| 78 |
+
"""
|
| 79 |
+
Replace strict json_schema with json_object and move schema guidance into the prompt.
|
| 80 |
+
|
| 81 |
+
This keeps browser-use's local validation path while avoiding provider-side
|
| 82 |
+
grammar compilation limits on OpenRouter.
|
| 83 |
+
"""
|
| 84 |
+
response_format = kwargs.get("response_format")
|
| 85 |
+
if not isinstance(response_format, dict):
|
| 86 |
+
return None
|
| 87 |
+
|
| 88 |
+
updated_kwargs = copy.deepcopy(kwargs)
|
| 89 |
+
updated_kwargs["response_format"] = {"type": "json_object"}
|
| 90 |
+
if "tools" in updated_kwargs:
|
| 91 |
+
updated_kwargs["tools"] = relax_strict_tool_schemas(updated_kwargs["tools"])
|
| 92 |
+
updated_messages = prepend_schema_hint_to_messages(messages, response_format)
|
| 93 |
+
return updated_messages, updated_kwargs
|
plugins/_browser_agent/helpers/browser_use_output_sanitize.py
CHANGED
|
@@ -1,3 +1,79 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utilities to normalize LLM replies before browser-use parses them into AgentOutput.
|
| 3 |
+
|
| 4 |
+
Some models (e.g. via OpenRouter) emit extra JSON keys such as "" : "", which
|
| 5 |
+
Pydantic rejects as extra_forbidden on strict action union members.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from helpers import dirty_json
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def deep_strip_empty_string_keys(obj: Any) -> Any:
|
| 16 |
+
"""
|
| 17 |
+
Recursively remove dict entries whose key is the empty string.
|
| 18 |
+
|
| 19 |
+
Browser-use action objects must be discriminated unions with a single
|
| 20 |
+
action key; spurious "" keys break validation for every union variant.
|
| 21 |
+
"""
|
| 22 |
+
if isinstance(obj, dict):
|
| 23 |
+
return {
|
| 24 |
+
k: deep_strip_empty_string_keys(v)
|
| 25 |
+
for k, v in obj.items()
|
| 26 |
+
if k != ""
|
| 27 |
+
}
|
| 28 |
+
if isinstance(obj, list):
|
| 29 |
+
return [deep_strip_empty_string_keys(item) for item in obj]
|
| 30 |
+
return obj
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def normalize_parsed_browser_use_output(obj: dict) -> dict:
|
| 34 |
+
"""Apply all normalizations safe for a parsed AgentOutput-shaped dict."""
|
| 35 |
+
out = deep_strip_empty_string_keys(obj)
|
| 36 |
+
if not isinstance(out, dict):
|
| 37 |
+
return obj
|
| 38 |
+
return out
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def parse_and_sanitize_llm_json(text: str) -> str | None:
|
| 42 |
+
"""
|
| 43 |
+
Parse message content and return JSON text safe for AgentOutput parsing.
|
| 44 |
+
|
| 45 |
+
Returns None if the string is not a JSON object.
|
| 46 |
+
"""
|
| 47 |
+
try:
|
| 48 |
+
obj = dirty_json.parse(text)
|
| 49 |
+
except Exception:
|
| 50 |
+
return None
|
| 51 |
+
if not isinstance(obj, dict):
|
| 52 |
+
return None
|
| 53 |
+
return dirty_json.stringify(normalize_parsed_browser_use_output(obj))
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def sanitize_llm_message_content_for_browser_use(content: str | None) -> str | None:
|
| 57 |
+
"""
|
| 58 |
+
Best-effort sanitize assistant message content in place for browser-use.
|
| 59 |
+
|
| 60 |
+
- If content parses as a dict: strip bad keys and re-serialize.
|
| 61 |
+
- If content is non-JSON or trailing garbage: try dirty_json parse; if dict, sanitize.
|
| 62 |
+
- Otherwise return the original string.
|
| 63 |
+
"""
|
| 64 |
+
if content is None:
|
| 65 |
+
return None
|
| 66 |
+
stripped = content.strip()
|
| 67 |
+
if not stripped:
|
| 68 |
+
return content
|
| 69 |
+
sanitized = parse_and_sanitize_llm_json(stripped)
|
| 70 |
+
if sanitized is not None:
|
| 71 |
+
return sanitized
|
| 72 |
+
if not stripped.startswith("{"):
|
| 73 |
+
try:
|
| 74 |
+
obj = dirty_json.parse(stripped)
|
| 75 |
+
except Exception:
|
| 76 |
+
return content
|
| 77 |
+
if isinstance(obj, dict):
|
| 78 |
+
return dirty_json.stringify(normalize_parsed_browser_use_output(obj))
|
| 79 |
+
return content
|
plugins/_browser_agent/helpers/playwright.py
CHANGED
|
@@ -1,3 +1,38 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import subprocess
|
| 5 |
+
from helpers import files
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
# this helper ensures that playwright is installed in /lib/playwright
|
| 9 |
+
# should work for both docker and local installation
|
| 10 |
+
|
| 11 |
+
def get_playwright_binary():
|
| 12 |
+
pw_cache = Path(get_playwright_cache_dir())
|
| 13 |
+
for pattern in (
|
| 14 |
+
"chromium_headless_shell-*/chrome-*/headless_shell",
|
| 15 |
+
"chromium_headless_shell-*/chrome-*/headless_shell.exe",
|
| 16 |
+
):
|
| 17 |
+
binary = next(pw_cache.glob(pattern), None)
|
| 18 |
+
if binary:
|
| 19 |
+
return binary
|
| 20 |
+
return None
|
| 21 |
+
|
| 22 |
+
def get_playwright_cache_dir():
|
| 23 |
+
return files.get_abs_path("tmp/playwright")
|
| 24 |
+
|
| 25 |
+
def ensure_playwright_binary():
|
| 26 |
+
bin = get_playwright_binary()
|
| 27 |
+
if not bin:
|
| 28 |
+
cache = get_playwright_cache_dir()
|
| 29 |
+
env = os.environ.copy()
|
| 30 |
+
env["PLAYWRIGHT_BROWSERS_PATH"] = cache
|
| 31 |
+
subprocess.check_call(
|
| 32 |
+
["playwright", "install", "chromium", "--only-shell"],
|
| 33 |
+
env=env
|
| 34 |
+
)
|
| 35 |
+
bin = get_playwright_binary()
|
| 36 |
+
if not bin:
|
| 37 |
+
raise Exception("Playwright binary not found after installation")
|
| 38 |
+
return bin
|
plugins/_browser_agent/prompts/agent.system.tool.browser.md
CHANGED
|
@@ -1,3 +1,36 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
### browser_agent:
|
| 2 |
+
|
| 3 |
+
subordinate agent controls playwright browser
|
| 4 |
+
message argument talks to agent give clear instructions credentials task based
|
| 5 |
+
reset argument spawns new agent
|
| 6 |
+
do not reset if iterating
|
| 7 |
+
be precise descriptive like: open google login and end task, log in using ... and end task
|
| 8 |
+
when following up start: considering open pages
|
| 9 |
+
dont use phrase wait for instructions use end task
|
| 10 |
+
downloads default in /a0/tmp/downloads
|
| 11 |
+
pass secrets and variables in message when needed
|
| 12 |
+
|
| 13 |
+
usage:
|
| 14 |
+
```json
|
| 15 |
+
{
|
| 16 |
+
"thoughts": ["I need to log in to..."],
|
| 17 |
+
"headline": "Opening new browser session for login",
|
| 18 |
+
"tool_name": "browser_agent",
|
| 19 |
+
"tool_args": {
|
| 20 |
+
"message": "Open and log me into...",
|
| 21 |
+
"reset": "true"
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
```json
|
| 27 |
+
{
|
| 28 |
+
"thoughts": ["I need to log in to..."],
|
| 29 |
+
"headline": "Continuing with existing browser session",
|
| 30 |
+
"tool_name": "browser_agent",
|
| 31 |
+
"tool_args": {
|
| 32 |
+
"message": "Considering open pages, click...",
|
| 33 |
+
"reset": "false"
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
```
|
plugins/_browser_agent/prompts/browser_agent.system.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Operation instruction
|
| 2 |
+
Keep your tasks solution as simple and straight forward as possible
|
| 3 |
+
Follow instructions as closely as possible
|
| 4 |
+
When told go to website, open the website. If no other instructions: stop there
|
| 5 |
+
Do not interact with the website unless told to
|
| 6 |
+
Always accept all cookies if prompted on the website, NEVER go to browser cookie settings
|
| 7 |
+
If asked specific questions about a website, be as precise and close to the actual page content as possible
|
| 8 |
+
If you are waiting for instructions: you should end the task and mark as done
|
| 9 |
+
|
| 10 |
+
## Task Completion
|
| 11 |
+
When you have completed the assigned task OR are waiting for further instructions:
|
| 12 |
+
1. Use the "Complete task" action to mark the task as complete
|
| 13 |
+
2. Provide the required parameters: title, response, and page_summary
|
| 14 |
+
3. Do NOT continue taking actions after calling "Complete task"
|
| 15 |
+
|
| 16 |
+
## Important Notes
|
| 17 |
+
- Always call "Complete task" when your objective is achieved
|
| 18 |
+
- In page_summary respond with one paragraph of main content plus an overview of page elements
|
| 19 |
+
- Response field is used to answer to user's task or ask additional questions
|
| 20 |
+
- If you navigate to a website and no further actions are requested, call "Complete task" immediately
|
| 21 |
+
- If you complete any requested interaction (clicking, typing, etc.), call "Complete task"
|
| 22 |
+
- Never leave a task running indefinitely - always conclude with "Complete task"
|
plugins/_browser_agent/tools/browser_agent.py
CHANGED
|
@@ -1,3 +1,440 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import time
|
| 3 |
+
from typing import Optional, cast
|
| 4 |
+
from agent import Agent, InterventionException
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from helpers.tool import Tool, Response
|
| 8 |
+
from helpers import files, defer, persist_chat, strings
|
| 9 |
+
from plugins._browser_agent.helpers.browser_use import browser_use # type: ignore[attr-defined]
|
| 10 |
+
from helpers.print_style import PrintStyle
|
| 11 |
+
from plugins._browser_agent.helpers.playwright import ensure_playwright_binary
|
| 12 |
+
from helpers.secrets import get_secrets_manager
|
| 13 |
+
from extensions.python.message_loop_start._10_iteration_no import get_iter_no
|
| 14 |
+
from pydantic import BaseModel
|
| 15 |
+
import uuid
|
| 16 |
+
from helpers.dirty_json import DirtyJson
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
PLUGIN_DIR = Path(__file__).resolve().parents[1]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class State:
|
| 23 |
+
@staticmethod
|
| 24 |
+
async def create(agent: Agent):
|
| 25 |
+
state = State(agent)
|
| 26 |
+
return state
|
| 27 |
+
|
| 28 |
+
def __init__(self, agent: Agent):
|
| 29 |
+
self.agent = agent
|
| 30 |
+
self.browser_session: Optional[browser_use.BrowserSession] = None
|
| 31 |
+
self.task: Optional[defer.DeferredTask] = None
|
| 32 |
+
self.use_agent: Optional[browser_use.Agent] = None
|
| 33 |
+
self.secrets_dict: Optional[dict[str, str]] = None
|
| 34 |
+
self.iter_no = 0
|
| 35 |
+
|
| 36 |
+
def __del__(self):
|
| 37 |
+
self.kill_task()
|
| 38 |
+
files.delete_dir(self.get_user_data_dir()) # cleanup user data dir
|
| 39 |
+
|
| 40 |
+
def get_user_data_dir(self):
|
| 41 |
+
return str(
|
| 42 |
+
Path.home()
|
| 43 |
+
/ ".config"
|
| 44 |
+
/ "browseruse"
|
| 45 |
+
/ "profiles"
|
| 46 |
+
/ f"agent_{self.agent.context.id}"
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
def _get_browser_http_headers(self):
|
| 50 |
+
# ignored for now
|
| 51 |
+
return {}
|
| 52 |
+
|
| 53 |
+
def _get_browser_vision(self):
|
| 54 |
+
from plugins._model_config.helpers.model_config import get_chat_model_config
|
| 55 |
+
cfg = get_chat_model_config(self.agent)
|
| 56 |
+
return cfg.get("vision", False)
|
| 57 |
+
|
| 58 |
+
async def _initialize(self):
|
| 59 |
+
if self.browser_session:
|
| 60 |
+
return
|
| 61 |
+
|
| 62 |
+
# for some reason we need to provide exact path to headless shell, otherwise it looks for headed browser
|
| 63 |
+
pw_binary = ensure_playwright_binary()
|
| 64 |
+
|
| 65 |
+
self.browser_session = browser_use.BrowserSession(
|
| 66 |
+
browser_profile=browser_use.BrowserProfile(
|
| 67 |
+
headless=True,
|
| 68 |
+
disable_security=True,
|
| 69 |
+
chromium_sandbox=False,
|
| 70 |
+
accept_downloads=True,
|
| 71 |
+
downloads_path=files.get_abs_path("usr/downloads"),
|
| 72 |
+
allowed_domains=["*", "http://*", "https://*"],
|
| 73 |
+
executable_path=pw_binary,
|
| 74 |
+
keep_alive=True,
|
| 75 |
+
minimum_wait_page_load_time=1.0,
|
| 76 |
+
wait_for_network_idle_page_load_time=2.0,
|
| 77 |
+
maximum_wait_page_load_time=10.0,
|
| 78 |
+
window_size={"width": 1024, "height": 2048},
|
| 79 |
+
screen={"width": 1024, "height": 2048},
|
| 80 |
+
viewport={"width": 1024, "height": 2048},
|
| 81 |
+
no_viewport=False,
|
| 82 |
+
args=["--headless=new", "--no-sandbox"],
|
| 83 |
+
# Use a unique user data directory to avoid conflicts
|
| 84 |
+
user_data_dir=self.get_user_data_dir(),
|
| 85 |
+
extra_http_headers=self._get_browser_http_headers(),
|
| 86 |
+
)
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
await self.browser_session.start() if self.browser_session else None
|
| 90 |
+
# self.override_hooks()
|
| 91 |
+
|
| 92 |
+
# --------------------------------------------------------------------------
|
| 93 |
+
# Patch to enforce vertical viewport size
|
| 94 |
+
# --------------------------------------------------------------------------
|
| 95 |
+
# Browser-use auto-configuration overrides viewport settings, causing wrong
|
| 96 |
+
# aspect ratio. We fix this by directly setting viewport size after startup.
|
| 97 |
+
# --------------------------------------------------------------------------
|
| 98 |
+
|
| 99 |
+
if self.browser_session:
|
| 100 |
+
try:
|
| 101 |
+
page = await self.browser_session.get_current_page()
|
| 102 |
+
if page:
|
| 103 |
+
await page.set_viewport_size({"width": 1024, "height": 2048})
|
| 104 |
+
except Exception as e:
|
| 105 |
+
PrintStyle().warning(f"Could not force set viewport size: {e}")
|
| 106 |
+
|
| 107 |
+
# --------------------------------------------------------------------------
|
| 108 |
+
|
| 109 |
+
# Add init script to the browser session
|
| 110 |
+
if self.browser_session and self.browser_session.browser_context:
|
| 111 |
+
js_override = str(PLUGIN_DIR / "assets" / "init_override.js")
|
| 112 |
+
await self.browser_session.browser_context.add_init_script(path=js_override) if self.browser_session else None
|
| 113 |
+
|
| 114 |
+
def start_task(self, task: str):
|
| 115 |
+
if self.task and self.task.is_alive():
|
| 116 |
+
self.kill_task()
|
| 117 |
+
|
| 118 |
+
self.task = defer.DeferredTask(
|
| 119 |
+
thread_name="BrowserAgent" + self.agent.context.id
|
| 120 |
+
)
|
| 121 |
+
if self.agent.context.task:
|
| 122 |
+
self.agent.context.task.add_child_task(self.task, terminate_thread=True)
|
| 123 |
+
self.task.start_task(self._run_task, task) if self.task else None
|
| 124 |
+
return self.task
|
| 125 |
+
|
| 126 |
+
def kill_task(self):
|
| 127 |
+
if self.task:
|
| 128 |
+
self.task.kill(terminate_thread=True)
|
| 129 |
+
self.task = None
|
| 130 |
+
if self.browser_session:
|
| 131 |
+
try:
|
| 132 |
+
import asyncio
|
| 133 |
+
|
| 134 |
+
loop = asyncio.new_event_loop()
|
| 135 |
+
asyncio.set_event_loop(loop)
|
| 136 |
+
loop.run_until_complete(self.browser_session.close()) if self.browser_session else None
|
| 137 |
+
loop.close()
|
| 138 |
+
except Exception as e:
|
| 139 |
+
PrintStyle().error(f"Error closing browser session: {e}")
|
| 140 |
+
finally:
|
| 141 |
+
self.browser_session = None
|
| 142 |
+
self.use_agent = None
|
| 143 |
+
self.iter_no = 0
|
| 144 |
+
|
| 145 |
+
async def _run_task(self, task: str):
|
| 146 |
+
await self._initialize()
|
| 147 |
+
|
| 148 |
+
class DoneResult(BaseModel):
|
| 149 |
+
title: str
|
| 150 |
+
response: str
|
| 151 |
+
page_summary: str
|
| 152 |
+
|
| 153 |
+
# Initialize controller
|
| 154 |
+
controller = browser_use.Controller(output_model=DoneResult)
|
| 155 |
+
|
| 156 |
+
# Register custom completion action with proper ActionResult fields
|
| 157 |
+
@controller.registry.action("Complete task", param_model=DoneResult)
|
| 158 |
+
async def complete_task(params: DoneResult):
|
| 159 |
+
result = browser_use.ActionResult(
|
| 160 |
+
is_done=True, success=True, extracted_content=params.model_dump_json()
|
| 161 |
+
)
|
| 162 |
+
return result
|
| 163 |
+
|
| 164 |
+
model = self.agent.get_browser_model()
|
| 165 |
+
|
| 166 |
+
try:
|
| 167 |
+
|
| 168 |
+
secrets_manager = get_secrets_manager(self.agent.context)
|
| 169 |
+
secrets_dict = secrets_manager.load_secrets()
|
| 170 |
+
|
| 171 |
+
self.use_agent = browser_use.Agent(
|
| 172 |
+
task=task,
|
| 173 |
+
browser_session=self.browser_session,
|
| 174 |
+
llm=model,
|
| 175 |
+
use_vision=self._get_browser_vision(),
|
| 176 |
+
extend_system_message=self.agent.read_prompt(
|
| 177 |
+
"prompts/browser_agent.system.md"
|
| 178 |
+
),
|
| 179 |
+
controller=controller,
|
| 180 |
+
enable_memory=False, # Disable memory to avoid state conflicts
|
| 181 |
+
llm_timeout=3000, # TODO rem
|
| 182 |
+
sensitive_data=cast(dict[str, str | dict[str, str]] | None, secrets_dict or {}), # Pass secrets
|
| 183 |
+
)
|
| 184 |
+
except Exception as e:
|
| 185 |
+
raise Exception(
|
| 186 |
+
f"Browser agent initialization failed. This might be due to model compatibility issues. Error: {e}"
|
| 187 |
+
) from e
|
| 188 |
+
|
| 189 |
+
self.iter_no = get_iter_no(self.agent)
|
| 190 |
+
|
| 191 |
+
async def hook(agent: browser_use.Agent):
|
| 192 |
+
await self.agent.wait_if_paused()
|
| 193 |
+
if self.iter_no != get_iter_no(self.agent):
|
| 194 |
+
raise InterventionException("Task cancelled")
|
| 195 |
+
|
| 196 |
+
# try:
|
| 197 |
+
result = None
|
| 198 |
+
if self.use_agent:
|
| 199 |
+
result = await self.use_agent.run(
|
| 200 |
+
max_steps=50, on_step_start=hook, on_step_end=hook
|
| 201 |
+
)
|
| 202 |
+
return result
|
| 203 |
+
|
| 204 |
+
async def get_page(self):
|
| 205 |
+
if self.use_agent and self.browser_session:
|
| 206 |
+
try:
|
| 207 |
+
return await self.use_agent.browser_session.get_current_page() if self.use_agent.browser_session else None
|
| 208 |
+
except Exception:
|
| 209 |
+
# Browser session might be closed or invalid
|
| 210 |
+
return None
|
| 211 |
+
return None
|
| 212 |
+
|
| 213 |
+
async def get_selector_map(self):
|
| 214 |
+
"""Get the selector map for the current page state."""
|
| 215 |
+
if self.use_agent:
|
| 216 |
+
await self.use_agent.browser_session.get_state_summary(cache_clickable_elements_hashes=True) if self.use_agent.browser_session else None
|
| 217 |
+
return await self.use_agent.browser_session.get_selector_map() if self.use_agent.browser_session else None
|
| 218 |
+
await self.use_agent.browser_session.get_state_summary(
|
| 219 |
+
cache_clickable_elements_hashes=True
|
| 220 |
+
)
|
| 221 |
+
return await self.use_agent.browser_session.get_selector_map()
|
| 222 |
+
return {}
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
class BrowserAgent(Tool):
|
| 226 |
+
|
| 227 |
+
async def execute(self, message="", reset="", **kwargs):
|
| 228 |
+
self.guid = self.agent.context.generate_id() # short random id
|
| 229 |
+
reset = str(reset).lower().strip() == "true"
|
| 230 |
+
await self.prepare_state(reset=reset)
|
| 231 |
+
message = get_secrets_manager(self.agent.context).mask_values(message, placeholder="<secret>{key}</secret>") # mask any potential passwords passed from A0 to browser-use to browser-use format
|
| 232 |
+
task = self.state.start_task(message) if self.state else None
|
| 233 |
+
|
| 234 |
+
# wait for browser agent to finish and update progress with timeout
|
| 235 |
+
timeout_seconds = 300 # 5 minute timeout
|
| 236 |
+
start_time = time.time()
|
| 237 |
+
|
| 238 |
+
fail_counter = 0
|
| 239 |
+
while not task.is_ready() if task else False:
|
| 240 |
+
# Check for timeout to prevent infinite waiting
|
| 241 |
+
if time.time() - start_time > timeout_seconds:
|
| 242 |
+
PrintStyle().warning(
|
| 243 |
+
self._mask(f"Browser agent task timeout after {timeout_seconds} seconds, forcing completion")
|
| 244 |
+
)
|
| 245 |
+
break
|
| 246 |
+
|
| 247 |
+
await self.agent.handle_intervention()
|
| 248 |
+
await asyncio.sleep(1)
|
| 249 |
+
try:
|
| 250 |
+
if task and task.is_ready(): # otherwise get_update hangs
|
| 251 |
+
break
|
| 252 |
+
try:
|
| 253 |
+
update = await asyncio.wait_for(self.get_update(), timeout=10)
|
| 254 |
+
fail_counter = 0 # reset on success
|
| 255 |
+
except asyncio.TimeoutError:
|
| 256 |
+
fail_counter += 1
|
| 257 |
+
PrintStyle().warning(
|
| 258 |
+
self._mask(f"browser_agent.get_update timed out ({fail_counter}/3)")
|
| 259 |
+
)
|
| 260 |
+
if fail_counter >= 3:
|
| 261 |
+
PrintStyle().warning(
|
| 262 |
+
self._mask("3 consecutive browser_agent.get_update timeouts, breaking loop")
|
| 263 |
+
)
|
| 264 |
+
break
|
| 265 |
+
continue
|
| 266 |
+
update_log = update.get("log", get_use_agent_log(None))
|
| 267 |
+
self.update_progress("\n".join(update_log))
|
| 268 |
+
screenshot = update.get("screenshot", None)
|
| 269 |
+
if screenshot:
|
| 270 |
+
self.log.update(screenshot=screenshot)
|
| 271 |
+
except Exception as e:
|
| 272 |
+
PrintStyle().error(self._mask(f"Error getting update: {str(e)}"))
|
| 273 |
+
|
| 274 |
+
if task and not task.is_ready():
|
| 275 |
+
PrintStyle().warning(self._mask("browser_agent.get_update timed out, killing the task"))
|
| 276 |
+
self.state.kill_task() if self.state else None
|
| 277 |
+
return Response(
|
| 278 |
+
message=self._mask("Browser agent task timed out, not output provided."),
|
| 279 |
+
break_loop=False,
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
# final progress update
|
| 283 |
+
if self.state and self.state.use_agent:
|
| 284 |
+
log_final = get_use_agent_log(self.state.use_agent)
|
| 285 |
+
self.update_progress("\n".join(log_final))
|
| 286 |
+
|
| 287 |
+
# collect result with error handling
|
| 288 |
+
try:
|
| 289 |
+
result = await task.result() if task else None
|
| 290 |
+
except Exception as e:
|
| 291 |
+
PrintStyle().error(self._mask(f"Error getting browser agent task result: {str(e)}"))
|
| 292 |
+
# Return a timeout response if task.result() fails
|
| 293 |
+
answer_text = self._mask(f"Browser agent task failed to return result: {str(e)}")
|
| 294 |
+
self.log.update(answer=answer_text)
|
| 295 |
+
return Response(message=answer_text, break_loop=False)
|
| 296 |
+
# finally:
|
| 297 |
+
# # Stop any further browser access after task completion
|
| 298 |
+
# # self.state.kill_task()
|
| 299 |
+
# pass
|
| 300 |
+
|
| 301 |
+
# Check if task completed successfully
|
| 302 |
+
if result and result.is_done():
|
| 303 |
+
answer = result.final_result()
|
| 304 |
+
try:
|
| 305 |
+
if answer and isinstance(answer, str) and answer.strip():
|
| 306 |
+
answer_data = DirtyJson.parse_string(answer)
|
| 307 |
+
answer_text = strings.dict_to_text(answer_data) # type: ignore
|
| 308 |
+
else:
|
| 309 |
+
answer_text = (
|
| 310 |
+
str(answer) if answer else "Task completed successfully"
|
| 311 |
+
)
|
| 312 |
+
except Exception as e:
|
| 313 |
+
answer_text = (
|
| 314 |
+
str(answer)
|
| 315 |
+
if answer
|
| 316 |
+
else f"Task completed with parse error: {str(e)}"
|
| 317 |
+
)
|
| 318 |
+
else:
|
| 319 |
+
# Task hit max_steps without calling done()
|
| 320 |
+
urls = result.urls() if result else []
|
| 321 |
+
current_url = urls[-1] if urls else "unknown"
|
| 322 |
+
answer_text = (
|
| 323 |
+
f"Task reached step limit without completion. Last page: {current_url}. "
|
| 324 |
+
f"The browser agent may need clearer instructions on when to finish."
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
# Mask answer for logs and response
|
| 328 |
+
answer_text = self._mask(answer_text)
|
| 329 |
+
|
| 330 |
+
# update the log (without screenshot path here, user can click)
|
| 331 |
+
self.log.update(answer=answer_text)
|
| 332 |
+
|
| 333 |
+
# add screenshot to the answer if we have it
|
| 334 |
+
if (
|
| 335 |
+
self.log.kvps
|
| 336 |
+
and "screenshot" in self.log.kvps
|
| 337 |
+
and self.log.kvps["screenshot"]
|
| 338 |
+
):
|
| 339 |
+
path = self.log.kvps["screenshot"].split("//", 1)[-1].split("&", 1)[0]
|
| 340 |
+
answer_text += f"\n\nScreenshot: {path}"
|
| 341 |
+
|
| 342 |
+
# respond (with screenshot path)
|
| 343 |
+
return Response(message=answer_text, break_loop=False)
|
| 344 |
+
|
| 345 |
+
def get_log_object(self):
|
| 346 |
+
return self.agent.context.log.log(
|
| 347 |
+
type="browser",
|
| 348 |
+
heading=f"icon://captive_portal {self.agent.agent_name}: Calling Browser Agent",
|
| 349 |
+
content="",
|
| 350 |
+
kvps=self.args,
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
async def get_update(self):
|
| 354 |
+
await self.prepare_state()
|
| 355 |
+
|
| 356 |
+
result = {}
|
| 357 |
+
agent = self.agent
|
| 358 |
+
ua = self.state.use_agent if self.state else None
|
| 359 |
+
page = await self.state.get_page() if self.state else None
|
| 360 |
+
|
| 361 |
+
if ua and page:
|
| 362 |
+
try:
|
| 363 |
+
|
| 364 |
+
async def _get_update():
|
| 365 |
+
|
| 366 |
+
# await agent.wait_if_paused() # no need here
|
| 367 |
+
|
| 368 |
+
# Build short activity log
|
| 369 |
+
result["log"] = get_use_agent_log(ua)
|
| 370 |
+
|
| 371 |
+
path = files.get_abs_path(
|
| 372 |
+
persist_chat.get_chat_folder_path(agent.context.id),
|
| 373 |
+
"browser",
|
| 374 |
+
"screenshots",
|
| 375 |
+
f"{self.guid}.png",
|
| 376 |
+
)
|
| 377 |
+
files.make_dirs(path)
|
| 378 |
+
await page.screenshot(path=path, full_page=False, timeout=3000)
|
| 379 |
+
result["screenshot"] = f"img://{path}&t={str(time.time())}"
|
| 380 |
+
|
| 381 |
+
if self.state and self.state.task and not self.state.task.is_ready():
|
| 382 |
+
await self.state.task.execute_inside(_get_update)
|
| 383 |
+
|
| 384 |
+
except Exception:
|
| 385 |
+
pass
|
| 386 |
+
|
| 387 |
+
return result
|
| 388 |
+
|
| 389 |
+
async def prepare_state(self, reset=False):
|
| 390 |
+
self.state = self.agent.get_data("_browser_agent_state")
|
| 391 |
+
if reset and self.state:
|
| 392 |
+
self.state.kill_task()
|
| 393 |
+
if not self.state or reset:
|
| 394 |
+
self.state = await State.create(self.agent)
|
| 395 |
+
self.agent.set_data("_browser_agent_state", self.state)
|
| 396 |
+
|
| 397 |
+
def update_progress(self, text):
|
| 398 |
+
text = self._mask(text)
|
| 399 |
+
short = text.split("\n")[-1]
|
| 400 |
+
if len(short) > 50:
|
| 401 |
+
short = short[:50] + "..."
|
| 402 |
+
progress = f"Browser: {short}"
|
| 403 |
+
|
| 404 |
+
self.log.update(progress=text)
|
| 405 |
+
self.agent.context.log.set_progress(progress)
|
| 406 |
+
|
| 407 |
+
def _mask(self, text: str) -> str:
|
| 408 |
+
try:
|
| 409 |
+
return get_secrets_manager(self.agent.context).mask_values(text or "")
|
| 410 |
+
except Exception as e:
|
| 411 |
+
return text or ""
|
| 412 |
+
|
| 413 |
+
# def __del__(self):
|
| 414 |
+
# if self.state:
|
| 415 |
+
# self.state.kill_task()
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def get_use_agent_log(use_agent: browser_use.Agent | None):
|
| 419 |
+
result = ["🚦 Starting task"]
|
| 420 |
+
if use_agent:
|
| 421 |
+
action_results = use_agent.history.action_results() or []
|
| 422 |
+
short_log = []
|
| 423 |
+
for item in action_results:
|
| 424 |
+
# final results
|
| 425 |
+
if item.is_done:
|
| 426 |
+
if item.success:
|
| 427 |
+
short_log.append("✅ Done")
|
| 428 |
+
else:
|
| 429 |
+
short_log.append(
|
| 430 |
+
f"❌ Error: {item.error or item.extracted_content or 'Unknown error'}"
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
# progress messages
|
| 434 |
+
else:
|
| 435 |
+
text = item.extracted_content
|
| 436 |
+
if text:
|
| 437 |
+
first_line = text.split("\n", 1)[0][:200]
|
| 438 |
+
short_log.append(first_line)
|
| 439 |
+
result.extend(short_log)
|
| 440 |
+
return result
|
plugins/_browser_agent/webui/main.html
CHANGED
|
@@ -1,3 +1,204 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<html>
|
| 2 |
+
<head>
|
| 3 |
+
<title>Browser Agent</title>
|
| 4 |
+
<script type="module">
|
| 5 |
+
import { callJsonApi } from "/js/api.js";
|
| 6 |
+
import "/components/plugins/list/pluginListStore.js";
|
| 7 |
+
|
| 8 |
+
globalThis.browserAgentStatusApi = { callJsonApi };
|
| 9 |
+
</script>
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
<div
|
| 13 |
+
x-data="{
|
| 14 |
+
loading: true,
|
| 15 |
+
error: '',
|
| 16 |
+
status: null,
|
| 17 |
+
async init() {
|
| 18 |
+
try {
|
| 19 |
+
this.status = await browserAgentStatusApi.callJsonApi('/plugins/_browser_agent/status', {});
|
| 20 |
+
} catch (error) {
|
| 21 |
+
this.error = error instanceof Error ? error.message : String(error);
|
| 22 |
+
} finally {
|
| 23 |
+
this.loading = false;
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
}"
|
| 27 |
+
x-init="init()"
|
| 28 |
+
class="browser-agent-page"
|
| 29 |
+
>
|
| 30 |
+
<div class="section-title">Browser Agent</div>
|
| 31 |
+
<div class="section-description">
|
| 32 |
+
Built-in browser automation plugin backed by `browser-use` and Playwright.
|
| 33 |
+
Model selection stays in `_model_config`; the browser agent follows the effective Main Model.
|
| 34 |
+
</div>
|
| 35 |
+
|
| 36 |
+
<div class="browser-agent-card" x-show="loading">
|
| 37 |
+
<div class="status-row">
|
| 38 |
+
<span class="material-symbols-outlined spinning">progress_activity</span>
|
| 39 |
+
<span>Loading browser status...</span>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<div class="browser-agent-card error" x-show="!loading && error">
|
| 44 |
+
<div class="field-title">Status check failed</div>
|
| 45 |
+
<div class="field-description" x-text="error"></div>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<template x-if="!loading && status">
|
| 49 |
+
<div class="browser-agent-grid">
|
| 50 |
+
<div class="browser-agent-card">
|
| 51 |
+
<div class="field-title">Model Source</div>
|
| 52 |
+
<div class="field-description" x-text="status.model_source"></div>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div class="browser-agent-card">
|
| 56 |
+
<div class="field-title">Resolved Main Model</div>
|
| 57 |
+
<div class="status-row">
|
| 58 |
+
<span class="status-key">Provider</span>
|
| 59 |
+
<span class="status-value" x-text="status.model.provider || 'Not configured'"></span>
|
| 60 |
+
</div>
|
| 61 |
+
<div class="status-row">
|
| 62 |
+
<span class="status-key">Model</span>
|
| 63 |
+
<span class="status-value" x-text="status.model.name || 'Not configured'"></span>
|
| 64 |
+
</div>
|
| 65 |
+
<div class="status-row">
|
| 66 |
+
<span class="status-key">Vision</span>
|
| 67 |
+
<span class="status-badge" :class="status.model.vision ? 'ok' : 'warn'" x-text="status.model.vision ? 'Enabled' : 'Disabled'"></span>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<div class="browser-agent-card">
|
| 72 |
+
<div class="field-title">Playwright Runtime</div>
|
| 73 |
+
<div class="status-row">
|
| 74 |
+
<span class="status-key">Binary</span>
|
| 75 |
+
<span class="status-badge" :class="status.playwright.binary_found ? 'ok' : 'fail'" x-text="status.playwright.binary_found ? 'Found' : 'Missing'"></span>
|
| 76 |
+
</div>
|
| 77 |
+
<div class="status-row">
|
| 78 |
+
<span class="status-key">Cache</span>
|
| 79 |
+
<span class="status-value mono" x-text="status.playwright.cache_dir"></span>
|
| 80 |
+
</div>
|
| 81 |
+
<div class="status-row" x-show="status.playwright.binary_path">
|
| 82 |
+
<span class="status-key">Path</span>
|
| 83 |
+
<span class="status-value mono" x-text="status.playwright.binary_path"></span>
|
| 84 |
+
</div>
|
| 85 |
+
<div class="field-description" x-show="!status.playwright.binary_found">
|
| 86 |
+
Docker images ship the Playwright Chromium shell preinstalled. In local development, the first run installs it on demand via <span class="mono">ensure_playwright_binary()</span> if missing.
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<div class="browser-agent-card">
|
| 91 |
+
<div class="field-title">browser-use</div>
|
| 92 |
+
<div class="status-row">
|
| 93 |
+
<span class="status-key">Import</span>
|
| 94 |
+
<span class="status-badge" :class="status.browser_use.import_ok ? 'ok' : 'fail'" x-text="status.browser_use.import_ok ? 'Ready' : 'Error'"></span>
|
| 95 |
+
</div>
|
| 96 |
+
<div class="status-row" x-show="status.browser_use.version">
|
| 97 |
+
<span class="status-key">Version</span>
|
| 98 |
+
<span class="status-value" x-text="status.browser_use.version"></span>
|
| 99 |
+
</div>
|
| 100 |
+
<div class="field-description mono" x-show="status.browser_use.error" x-text="status.browser_use.error"></div>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
</template>
|
| 104 |
+
|
| 105 |
+
<div class="browser-agent-actions">
|
| 106 |
+
<button
|
| 107 |
+
class="btn btn-field"
|
| 108 |
+
@click="$store.pluginListStore.openPluginConfig({ name: '_model_config', has_config_screen: true })"
|
| 109 |
+
>
|
| 110 |
+
Open Model Settings
|
| 111 |
+
</button>
|
| 112 |
+
<button class="btn btn-field" @click="openModal('/plugins/_model_config/webui/main.html')">
|
| 113 |
+
Open Presets
|
| 114 |
+
</button>
|
| 115 |
+
<button class="btn btn-field" @click="openModal('/plugins/_model_config/webui/api-keys.html')">
|
| 116 |
+
Open API Keys
|
| 117 |
+
</button>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
<style>
|
| 122 |
+
.browser-agent-page {
|
| 123 |
+
display: flex;
|
| 124 |
+
flex-direction: column;
|
| 125 |
+
gap: 14px;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.browser-agent-grid {
|
| 129 |
+
display: grid;
|
| 130 |
+
gap: 12px;
|
| 131 |
+
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.browser-agent-card {
|
| 135 |
+
display: flex;
|
| 136 |
+
flex-direction: column;
|
| 137 |
+
gap: 10px;
|
| 138 |
+
padding: 14px;
|
| 139 |
+
background: var(--color-input);
|
| 140 |
+
border: 1px solid var(--color-border);
|
| 141 |
+
border-radius: 10px;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.browser-agent-card.error {
|
| 145 |
+
border-color: rgba(214, 40, 40, 0.35);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.browser-agent-actions {
|
| 149 |
+
display: flex;
|
| 150 |
+
gap: 8px;
|
| 151 |
+
flex-wrap: wrap;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.status-row {
|
| 155 |
+
display: flex;
|
| 156 |
+
align-items: flex-start;
|
| 157 |
+
justify-content: space-between;
|
| 158 |
+
gap: 12px;
|
| 159 |
+
font-size: 0.84rem;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.status-key {
|
| 163 |
+
opacity: 0.7;
|
| 164 |
+
min-width: 64px;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.status-value {
|
| 168 |
+
text-align: right;
|
| 169 |
+
word-break: break-word;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.status-badge {
|
| 173 |
+
padding: 2px 8px;
|
| 174 |
+
border-radius: 999px;
|
| 175 |
+
font-size: 0.76rem;
|
| 176 |
+
font-weight: 600;
|
| 177 |
+
border: 1px solid transparent;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
.status-badge.ok {
|
| 181 |
+
color: #1b5e20;
|
| 182 |
+
background: rgba(46, 125, 50, 0.14);
|
| 183 |
+
border-color: rgba(46, 125, 50, 0.24);
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.status-badge.warn {
|
| 187 |
+
color: #8a6100;
|
| 188 |
+
background: rgba(191, 144, 0, 0.14);
|
| 189 |
+
border-color: rgba(191, 144, 0, 0.24);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.status-badge.fail {
|
| 193 |
+
color: #9f1239;
|
| 194 |
+
background: rgba(190, 24, 93, 0.12);
|
| 195 |
+
border-color: rgba(190, 24, 93, 0.24);
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
.mono {
|
| 199 |
+
font-family: var(--font-mono);
|
| 200 |
+
font-size: 0.78rem;
|
| 201 |
+
}
|
| 202 |
+
</style>
|
| 203 |
+
</body>
|
| 204 |
+
</html>
|
plugins/_browser_agent/webui/thumbnail.jpg
CHANGED
|
|
Git LFS Details
|
|
|
plugins/_chat_branching/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Chat Branching
|
| 2 |
+
|
| 3 |
+
Create a new chat from any existing point in a conversation.
|
| 4 |
+
|
| 5 |
+
## What It Does
|
| 6 |
+
|
| 7 |
+
Adds a **Branch** button to every chat message. Clicking it clones the current chat up to that message, creating a new conversation you can continue independently.
|
| 8 |
+
|
| 9 |
+
## How It Works
|
| 10 |
+
|
| 11 |
+
1. **ID-based log ↔ history linking**
|
| 12 |
+
Every `LogItem` and `history.Message` share a UUID generated at creation time. The branch button is only shown on messages that carry this ID.
|
| 13 |
+
|
| 14 |
+
2. **Clone & trim**
|
| 15 |
+
- Serializes the source context → deserializes into a new context with a fresh ID.
|
| 16 |
+
- Walks log entries: keeps everything up to the selected `log_no`, discards the rest.
|
| 17 |
+
- Collects the IDs of kept entries and uses them to trim `history.messages` so log and history stay consistent.
|
| 18 |
+
|
| 19 |
+
3. **Persist & refresh**
|
| 20 |
+
- Saves the branched chat immediately.
|
| 21 |
+
- Marks UI state dirty so all connected tabs see the new branch.
|
| 22 |
+
|
| 23 |
+
## Entry Points
|
| 24 |
+
|
| 25 |
+
| Path | Purpose |
|
| 26 |
+
|---|---|
|
| 27 |
+
| `api/branch_chat.py` | API endpoint — clone, trim, persist |
|
| 28 |
+
| `extensions/webui/set_messages_after_loop/inject-branch-buttons.js` | Injects the Branch button into each message DOM element |
|
| 29 |
+
|
| 30 |
+
## Plugin Metadata
|
| 31 |
+
|
| 32 |
+
- **Name**: `_chat_branching`
|
| 33 |
+
- **Title**: `Chat Branching`
|
| 34 |
+
- **Description**: Branch a chat from any message, creating a new chat with history up to that point.
|
plugins/_chat_branching/api/branch_chat.py
CHANGED
|
@@ -1,3 +1,147 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
|
| 4 |
+
from helpers.api import ApiHandler, Input, Output, Request, Response
|
| 5 |
+
from helpers.persist_chat import (
|
| 6 |
+
_serialize_context,
|
| 7 |
+
_deserialize_context,
|
| 8 |
+
save_tmp_chat,
|
| 9 |
+
)
|
| 10 |
+
from agent import AgentContext
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _trim_history_json(history_json: str, kept_ids: set[str], after_cut_ids: set[str]) -> str:
|
| 14 |
+
"""Trim a serialized history JSON to keep only messages that appear
|
| 15 |
+
before the branch cut point.
|
| 16 |
+
|
| 17 |
+
*kept_ids*: IDs of log items up to and including the cut point.
|
| 18 |
+
*after_cut_ids*: IDs of log items after the cut point.
|
| 19 |
+
|
| 20 |
+
Walk messages in order. A message is kept while the running state
|
| 21 |
+
is "keep". The state flips to "drop" the first time we encounter
|
| 22 |
+
a message whose id is in *after_cut_ids*. Messages whose id does
|
| 23 |
+
not appear in any log set (unpaired) inherit the current state.
|
| 24 |
+
Summarized topics/bulks are always preserved.
|
| 25 |
+
"""
|
| 26 |
+
if not history_json:
|
| 27 |
+
return history_json
|
| 28 |
+
|
| 29 |
+
hist = json.loads(history_json)
|
| 30 |
+
keep = True # running state
|
| 31 |
+
|
| 32 |
+
def filter_messages(messages: list[dict]) -> list[dict]:
|
| 33 |
+
nonlocal keep
|
| 34 |
+
result = []
|
| 35 |
+
for msg in messages:
|
| 36 |
+
mid = msg.get("id", "")
|
| 37 |
+
if mid and mid in after_cut_ids:
|
| 38 |
+
keep = False
|
| 39 |
+
elif mid and mid in kept_ids:
|
| 40 |
+
keep = True
|
| 41 |
+
# else: unpaired – inherit current state
|
| 42 |
+
if keep:
|
| 43 |
+
result.append(msg)
|
| 44 |
+
return result
|
| 45 |
+
|
| 46 |
+
# Bulks are already summarized old history – always keep
|
| 47 |
+
# Topics: keep summarized ones; filter unsummarized
|
| 48 |
+
trimmed_topics = []
|
| 49 |
+
for topic in hist.get("topics", []):
|
| 50 |
+
if topic.get("summary"):
|
| 51 |
+
trimmed_topics.append(topic)
|
| 52 |
+
continue
|
| 53 |
+
msgs = filter_messages(topic.get("messages", []))
|
| 54 |
+
if msgs:
|
| 55 |
+
topic["messages"] = msgs
|
| 56 |
+
trimmed_topics.append(topic)
|
| 57 |
+
if not keep:
|
| 58 |
+
break
|
| 59 |
+
hist["topics"] = trimmed_topics
|
| 60 |
+
|
| 61 |
+
# Current topic
|
| 62 |
+
current = hist.get("current", {})
|
| 63 |
+
if not current.get("summary") and keep:
|
| 64 |
+
current["messages"] = filter_messages(current.get("messages", []))
|
| 65 |
+
elif not keep:
|
| 66 |
+
current["messages"] = []
|
| 67 |
+
hist["current"] = current
|
| 68 |
+
|
| 69 |
+
# Recount
|
| 70 |
+
total = sum(
|
| 71 |
+
len(t.get("messages", [])) for t in hist["topics"] if not t.get("summary")
|
| 72 |
+
) + len(hist.get("current", {}).get("messages", []))
|
| 73 |
+
hist["counter"] = total
|
| 74 |
+
|
| 75 |
+
return json.dumps(hist, ensure_ascii=False)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class BranchChat(ApiHandler):
|
| 79 |
+
"""Create a new chat branched from an existing chat at a specific log message."""
|
| 80 |
+
|
| 81 |
+
async def process(self, input: Input, request: Request) -> Output:
|
| 82 |
+
ctxid = input.get("context", "")
|
| 83 |
+
log_no = input.get("log_no") # LogItem.no from frontend
|
| 84 |
+
|
| 85 |
+
if not ctxid:
|
| 86 |
+
return Response("Missing context id", 400)
|
| 87 |
+
if log_no is None:
|
| 88 |
+
return Response("Missing log_no", 400)
|
| 89 |
+
|
| 90 |
+
context = AgentContext.get(ctxid)
|
| 91 |
+
if not context:
|
| 92 |
+
return Response("Context not found", 404)
|
| 93 |
+
|
| 94 |
+
# Serialize the source context
|
| 95 |
+
data = _serialize_context(context)
|
| 96 |
+
|
| 97 |
+
# Remove id so _deserialize_context generates a new one
|
| 98 |
+
del data["id"]
|
| 99 |
+
|
| 100 |
+
# Trim log entries: keep only items up to and including log_no.
|
| 101 |
+
src_logs = data["log"]["logs"]
|
| 102 |
+
cut_idx = None
|
| 103 |
+
for i, item in enumerate(src_logs):
|
| 104 |
+
if item["no"] == log_no:
|
| 105 |
+
cut_idx = i
|
| 106 |
+
break
|
| 107 |
+
|
| 108 |
+
if cut_idx is None:
|
| 109 |
+
if 0 <= log_no < len(src_logs):
|
| 110 |
+
cut_idx = log_no
|
| 111 |
+
else:
|
| 112 |
+
return Response("log_no not found in chat log", 400)
|
| 113 |
+
|
| 114 |
+
kept_logs = src_logs[: cut_idx + 1]
|
| 115 |
+
after_logs = src_logs[cut_idx + 1 :]
|
| 116 |
+
data["log"]["logs"] = kept_logs
|
| 117 |
+
|
| 118 |
+
# Build ID sets for history trimming
|
| 119 |
+
kept_ids = {item["id"] for item in kept_logs if item.get("id")}
|
| 120 |
+
after_cut_ids = {item["id"] for item in after_logs if item.get("id")}
|
| 121 |
+
|
| 122 |
+
# Trim each agent's history using ID matching
|
| 123 |
+
for ag in data.get("agents", []):
|
| 124 |
+
ag["history"] = _trim_history_json(
|
| 125 |
+
ag.get("history", ""), kept_ids, after_cut_ids
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# Give the branch a distinguishable name
|
| 129 |
+
src_name = data.get("name") or "Chat"
|
| 130 |
+
data["name"] = f"{src_name} (branch)"
|
| 131 |
+
data["created_at"] = datetime.now().isoformat()
|
| 132 |
+
|
| 133 |
+
# Deserialize into a brand-new context (new id, fresh agent config)
|
| 134 |
+
new_context = _deserialize_context(data)
|
| 135 |
+
|
| 136 |
+
# Persist immediately
|
| 137 |
+
save_tmp_chat(new_context)
|
| 138 |
+
|
| 139 |
+
# Notify all tabs
|
| 140 |
+
from helpers.state_monitor_integration import mark_dirty_all
|
| 141 |
+
mark_dirty_all(reason="plugins.chat_branching.BranchChat")
|
| 142 |
+
|
| 143 |
+
return {
|
| 144 |
+
"ok": True,
|
| 145 |
+
"ctxid": new_context.id,
|
| 146 |
+
"message": "Chat branched successfully.",
|
| 147 |
+
}
|
plugins/_chat_branching/extensions/webui/set_messages_after_loop/inject-branch-buttons.js
CHANGED
|
@@ -1,3 +1,32 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Chat Branching Plugin — injects a "branch" button into every message's action bar.
|
| 2 |
+
// Uses the unified handler output: context.results[] = { args: { no, … }, result: { element, … } }
|
| 3 |
+
|
| 4 |
+
import { createActionButton } from "/components/messages/action-buttons/simple-action-buttons.js";
|
| 5 |
+
import { callJsonApi } from "/js/api.js";
|
| 6 |
+
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
|
| 7 |
+
|
| 8 |
+
export default async function injectBranchButtons(context) {
|
| 9 |
+
if (!context?.results?.length) return;
|
| 10 |
+
|
| 11 |
+
for (const { args, result } of context.results) {
|
| 12 |
+
if (!result?.element || args.no == null || !args.id) continue;
|
| 13 |
+
|
| 14 |
+
const logNo = args.no;
|
| 15 |
+
for (const bar of result.element.querySelectorAll(".step-action-buttons")) {
|
| 16 |
+
if (bar.querySelector(".action-fork_right")) continue;
|
| 17 |
+
bar.appendChild(
|
| 18 |
+
createActionButton("fork_right", "Branch chat", async () => {
|
| 19 |
+
const ctxid = globalThis.getContext?.();
|
| 20 |
+
if (!ctxid) throw new Error("No active chat");
|
| 21 |
+
|
| 22 |
+
const res = await callJsonApi("/plugins/_chat_branching/branch_chat", {
|
| 23 |
+
context: ctxid,
|
| 24 |
+
log_no: logNo,
|
| 25 |
+
});
|
| 26 |
+
if (!res?.ok) throw new Error(res?.message || "Branch failed");
|
| 27 |
+
chatsStore.selectChat(res.ctxid);
|
| 28 |
+
}),
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
}
|
plugins/_chat_branching/webui/thumbnail.jpg
CHANGED
|
|
Git LFS Details
|
|
|
plugins/_chat_compaction/api/compact_chat.py
CHANGED
|
@@ -1,3 +1,68 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API handler for chat compaction."""
|
| 2 |
+
from helpers.api import ApiHandler, Input, Output, Request, Response
|
| 3 |
+
from agent import AgentContext
|
| 4 |
+
from plugins._chat_compaction.helpers.compactor import (
|
| 5 |
+
MIN_COMPACTION_TOKENS,
|
| 6 |
+
get_compaction_stats,
|
| 7 |
+
run_compaction,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class CompactChat(ApiHandler):
|
| 12 |
+
"""Compact the current chat history into a summarized message."""
|
| 13 |
+
|
| 14 |
+
async def process(self, input: Input, request: Request) -> Output:
|
| 15 |
+
ctxid = input.get("context", "")
|
| 16 |
+
action = input.get("action", "compact")
|
| 17 |
+
|
| 18 |
+
if not ctxid:
|
| 19 |
+
return Response("Missing context id", 400)
|
| 20 |
+
|
| 21 |
+
context = AgentContext.get(ctxid)
|
| 22 |
+
if not context:
|
| 23 |
+
return Response("Context not found", 404)
|
| 24 |
+
|
| 25 |
+
if context.is_running():
|
| 26 |
+
return Response("Cannot compact while agent is running", 409)
|
| 27 |
+
|
| 28 |
+
visible_count = len(context.log.logs)
|
| 29 |
+
if visible_count <= 1:
|
| 30 |
+
return Response("Not enough messages to compact", 400)
|
| 31 |
+
|
| 32 |
+
# Gate both stats and compact — no point opening the modal for tiny chats
|
| 33 |
+
stats = await get_compaction_stats(context)
|
| 34 |
+
if stats["token_count"] < MIN_COMPACTION_TOKENS:
|
| 35 |
+
return {
|
| 36 |
+
"ok": False,
|
| 37 |
+
"message": f"Not enough content to compact (minimum {MIN_COMPACTION_TOKENS:,} tokens)",
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
if action == "stats":
|
| 41 |
+
return {"ok": True, "stats": stats}
|
| 42 |
+
|
| 43 |
+
elif action == "compact":
|
| 44 |
+
use_chat_model = input.get("use_chat_model", True)
|
| 45 |
+
preset_name = input.get("preset_name") or None
|
| 46 |
+
|
| 47 |
+
context.run_task(
|
| 48 |
+
_run_compaction_task, context, use_chat_model, preset_name
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
return {"ok": True, "message": "Compaction started"}
|
| 52 |
+
|
| 53 |
+
else:
|
| 54 |
+
return Response(f"Unknown action: {action}", 400)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
async def _run_compaction_task(context, use_chat_model: bool, preset_name: str | None):
|
| 58 |
+
"""Wrapper to run compaction and handle errors."""
|
| 59 |
+
try:
|
| 60 |
+
await run_compaction(context, use_chat_model, preset_name)
|
| 61 |
+
except Exception as e:
|
| 62 |
+
context.log.log(
|
| 63 |
+
type="error",
|
| 64 |
+
heading="Compaction Failed",
|
| 65 |
+
content=str(e),
|
| 66 |
+
)
|
| 67 |
+
from helpers.state_monitor_integration import mark_dirty_all
|
| 68 |
+
mark_dirty_all(reason="plugins._chat_compaction.compact_chat_error")
|
plugins/_chat_compaction/extensions/webui/chat-input-bottom-actions-start/compact-button.html
CHANGED
|
@@ -1,3 +1,373 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<html>
|
| 2 |
+
<head>
|
| 3 |
+
<title>Compact Button Extension</title>
|
| 4 |
+
<script type="module" src="/plugins/_chat_compaction/webui/compact-store.js"></script>
|
| 5 |
+
</head>
|
| 6 |
+
<body>
|
| 7 |
+
<div x-data="{
|
| 8 |
+
get disabled() {
|
| 9 |
+
return !$store.chats?.selected || $store.compactStore?.compacting || $store.chatInput?.running;
|
| 10 |
+
},
|
| 11 |
+
get disabledReason() {
|
| 12 |
+
if (!$store.chats?.selected) return 'No active chat selected';
|
| 13 |
+
if ($store.compactStore?.compacting) return 'Compaction in progress';
|
| 14 |
+
if ($store.chatInput?.running) return 'Cannot compact while agent is running';
|
| 15 |
+
return 'Compact chat history into a single summary';
|
| 16 |
+
}
|
| 17 |
+
}">
|
| 18 |
+
<template x-if="$store.compactStore">
|
| 19 |
+
<button
|
| 20 |
+
class="text-button"
|
| 21 |
+
@click="$store.compactStore.fetchStats()"
|
| 22 |
+
:disabled="disabled"
|
| 23 |
+
:title="disabledReason"
|
| 24 |
+
>
|
| 25 |
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="14" height="14" aria-hidden="true">
|
| 26 |
+
<polyline points="4 14 10 14 10 20"/><line x1="3" y1="21" x2="10" y2="14"/>
|
| 27 |
+
<polyline points="20 10 14 10 14 4"/><line x1="21" y1="3" x2="14" y2="10"/>
|
| 28 |
+
</svg>
|
| 29 |
+
<p>Compact</p>
|
| 30 |
+
</button>
|
| 31 |
+
</template>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<!-- Modal with unique class names to avoid global CSS collision -->
|
| 35 |
+
<div x-data x-show="$store.compactStore?.showModal" style="display: none;">
|
| 36 |
+
<div class="cmpct-overlay" @click="$store.compactStore?.closeModal()">
|
| 37 |
+
<div class="cmpct-dialog" @click.stop>
|
| 38 |
+
<div class="cmpct-header">
|
| 39 |
+
<h3>Compact Chat History</h3>
|
| 40 |
+
<button class="cmpct-close" @click="$store.compactStore?.closeModal()">
|
| 41 |
+
<span class="material-symbols-outlined">close</span>
|
| 42 |
+
</button>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<div class="cmpct-body">
|
| 46 |
+
<template x-if="$store.compactStore?.stats">
|
| 47 |
+
<div>
|
| 48 |
+
<p class="cmpct-desc">
|
| 49 |
+
This will summarize your entire conversation into a single optimized message.
|
| 50 |
+
</p>
|
| 51 |
+
|
| 52 |
+
<div class="cmpct-stats">
|
| 53 |
+
<div class="cmpct-stat">
|
| 54 |
+
<span class="cmpct-stat-label">Messages</span>
|
| 55 |
+
<span class="cmpct-stat-value" x-text="$store.compactStore?.stats?.message_count"></span>
|
| 56 |
+
</div>
|
| 57 |
+
<div class="cmpct-stat">
|
| 58 |
+
<span class="cmpct-stat-label">Tokens</span>
|
| 59 |
+
<span class="cmpct-stat-value" x-text="$store.compactStore?.stats?.token_count?.toLocaleString()"></span>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
<!-- Model selection -->
|
| 64 |
+
<div class="cmpct-model-section">
|
| 65 |
+
<div class="cmpct-model-label">Model</div>
|
| 66 |
+
|
| 67 |
+
<div class="cmpct-model-controls">
|
| 68 |
+
<select class="cmpct-select"
|
| 69 |
+
x-model="$store.compactStore.selectedPresetName">
|
| 70 |
+
<option value="">Current</option>
|
| 71 |
+
<template x-for="preset in $store.compactStore.presets" :key="preset.name">
|
| 72 |
+
<option :value="preset.name" x-text="preset.name"></option>
|
| 73 |
+
</template>
|
| 74 |
+
</select>
|
| 75 |
+
|
| 76 |
+
<div class="cmpct-toggle">
|
| 77 |
+
<button :class="{ active: $store.compactStore.useChatModel }"
|
| 78 |
+
@click="$store.compactStore.useChatModel = true">Chat</button>
|
| 79 |
+
<button :class="{ active: !$store.compactStore.useChatModel }"
|
| 80 |
+
@click="$store.compactStore.useChatModel = false">Utility</button>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<div class="cmpct-model-name" x-text="$store.compactStore.selectedModelDisplay"></div>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
<div class="cmpct-info">
|
| 88 |
+
<span class="material-symbols-outlined">check_circle</span>
|
| 89 |
+
<span>The context will be replaced with a compacted summary. The original conversation will be backed up.</span>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
</template>
|
| 93 |
+
|
| 94 |
+
<template x-if="!$store.compactStore?.stats">
|
| 95 |
+
<div class="cmpct-loading">
|
| 96 |
+
<span class="cmpct-spinner"></span>
|
| 97 |
+
<span>Loading statistics...</span>
|
| 98 |
+
</div>
|
| 99 |
+
</template>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
<div class="cmpct-footer">
|
| 103 |
+
<button class="cmpct-btn cmpct-btn-cancel" @click="$store.compactStore?.closeModal()">
|
| 104 |
+
Cancel
|
| 105 |
+
</button>
|
| 106 |
+
<button
|
| 107 |
+
class="cmpct-btn cmpct-btn-danger"
|
| 108 |
+
@click="$store.compactStore?.compact()"
|
| 109 |
+
:disabled="$store.compactStore?.compacting || !$store.compactStore?.stats"
|
| 110 |
+
>
|
| 111 |
+
<template x-if="$store.compactStore?.compacting">
|
| 112 |
+
<span class="cmpct-spinner"></span>
|
| 113 |
+
</template>
|
| 114 |
+
<span>Compact</span>
|
| 115 |
+
</button>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
<style>
|
| 122 |
+
/* All classes prefixed with cmpct- to avoid global CSS collision */
|
| 123 |
+
.cmpct-overlay {
|
| 124 |
+
position: fixed;
|
| 125 |
+
top: 0; left: 0; right: 0; bottom: 0;
|
| 126 |
+
background: rgba(0, 0, 0, 0.5);
|
| 127 |
+
display: flex;
|
| 128 |
+
align-items: center;
|
| 129 |
+
justify-content: center;
|
| 130 |
+
z-index: 2002;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.cmpct-dialog {
|
| 134 |
+
background: var(--color-panel, #1a1a1a);
|
| 135 |
+
border-radius: 12px;
|
| 136 |
+
box-shadow: 0 4px 23px rgba(0, 0, 0, 0.3);
|
| 137 |
+
width: 90%;
|
| 138 |
+
max-width: 560px;
|
| 139 |
+
overflow: hidden;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.cmpct-header {
|
| 143 |
+
display: flex;
|
| 144 |
+
align-items: center;
|
| 145 |
+
justify-content: space-between;
|
| 146 |
+
padding: 16px 20px;
|
| 147 |
+
border-bottom: 1px solid var(--color-border, #333);
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.cmpct-header h3 {
|
| 151 |
+
margin: 0;
|
| 152 |
+
font-size: 1.1rem;
|
| 153 |
+
font-weight: 600;
|
| 154 |
+
color: var(--color-text, #e5e5e5);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.cmpct-close {
|
| 158 |
+
background: transparent;
|
| 159 |
+
border: none;
|
| 160 |
+
cursor: pointer;
|
| 161 |
+
padding: 4px;
|
| 162 |
+
border-radius: 4px;
|
| 163 |
+
color: var(--color-text-secondary, #999);
|
| 164 |
+
transition: color 0.2s;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.cmpct-close:hover {
|
| 168 |
+
color: var(--color-text, #e5e5e5);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.cmpct-body {
|
| 172 |
+
padding: 20px;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.cmpct-desc {
|
| 176 |
+
margin: 0 0 16px 0;
|
| 177 |
+
color: var(--color-text-secondary, #999);
|
| 178 |
+
font-size: 0.9rem;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.cmpct-stats {
|
| 182 |
+
display: grid;
|
| 183 |
+
grid-template-columns: repeat(2, 1fr);
|
| 184 |
+
gap: 12px;
|
| 185 |
+
margin-bottom: 16px;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.cmpct-stat {
|
| 189 |
+
display: flex;
|
| 190 |
+
flex-direction: column;
|
| 191 |
+
align-items: center;
|
| 192 |
+
padding: 12px 8px;
|
| 193 |
+
background: var(--color-background-muted, #252525);
|
| 194 |
+
border-radius: 8px;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.cmpct-stat-label {
|
| 198 |
+
font-size: 0.7rem;
|
| 199 |
+
color: var(--color-text-secondary, #999);
|
| 200 |
+
text-transform: uppercase;
|
| 201 |
+
letter-spacing: 0.05em;
|
| 202 |
+
margin-bottom: 4px;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.cmpct-stat-value {
|
| 206 |
+
font-size: 1.1rem;
|
| 207 |
+
font-weight: 600;
|
| 208 |
+
color: var(--color-text, #e5e5e5);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
/* Model selection section */
|
| 212 |
+
.cmpct-model-section {
|
| 213 |
+
margin-bottom: 16px;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.cmpct-model-label {
|
| 217 |
+
font-size: 0.7rem;
|
| 218 |
+
color: var(--color-text-secondary, #999);
|
| 219 |
+
text-transform: uppercase;
|
| 220 |
+
letter-spacing: 0.05em;
|
| 221 |
+
margin-bottom: 8px;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.cmpct-model-controls {
|
| 225 |
+
display: flex;
|
| 226 |
+
gap: 8px;
|
| 227 |
+
margin-bottom: 6px;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.cmpct-select {
|
| 231 |
+
flex: 1;
|
| 232 |
+
min-width: 0;
|
| 233 |
+
padding: 6px 10px;
|
| 234 |
+
border-radius: 6px;
|
| 235 |
+
border: 1px solid var(--color-border, #333);
|
| 236 |
+
background: var(--color-background-muted, #252525);
|
| 237 |
+
color: var(--color-text, #e5e5e5);
|
| 238 |
+
font-size: 0.85rem;
|
| 239 |
+
cursor: pointer;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
.cmpct-select:focus {
|
| 243 |
+
outline: none;
|
| 244 |
+
border-color: var(--color-primary, #3b82f6);
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
.cmpct-toggle {
|
| 248 |
+
display: flex;
|
| 249 |
+
border-radius: 6px;
|
| 250 |
+
border: 1px solid var(--color-border, #333);
|
| 251 |
+
overflow: hidden;
|
| 252 |
+
flex-shrink: 0;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.cmpct-toggle button {
|
| 256 |
+
padding: 6px 14px;
|
| 257 |
+
border: none;
|
| 258 |
+
background: var(--color-background-muted, #252525);
|
| 259 |
+
color: var(--color-text-secondary, #999);
|
| 260 |
+
font-size: 0.8rem;
|
| 261 |
+
font-weight: 500;
|
| 262 |
+
cursor: pointer;
|
| 263 |
+
transition: all 0.15s;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
.cmpct-toggle button:first-child {
|
| 267 |
+
border-right: 1px solid var(--color-border, #333);
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
.cmpct-toggle button.active {
|
| 271 |
+
background: var(--color-primary, #3b82f6);
|
| 272 |
+
color: white;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
.cmpct-toggle button:hover:not(.active) {
|
| 276 |
+
background: var(--color-background-hover, #444);
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.cmpct-model-name {
|
| 280 |
+
font-size: 0.85rem;
|
| 281 |
+
color: var(--color-text, #e5e5e5);
|
| 282 |
+
opacity: 0.7;
|
| 283 |
+
white-space: nowrap;
|
| 284 |
+
overflow: hidden;
|
| 285 |
+
text-overflow: ellipsis;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.cmpct-info {
|
| 289 |
+
display: flex;
|
| 290 |
+
align-items: flex-start;
|
| 291 |
+
gap: 8px;
|
| 292 |
+
padding: 10px 12px;
|
| 293 |
+
background: rgba(34, 197, 94, 0.1);
|
| 294 |
+
border: 1px solid rgba(34, 197, 94, 0.25);
|
| 295 |
+
border-radius: 8px;
|
| 296 |
+
color: #4ade80;
|
| 297 |
+
font-size: 0.8rem;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
.cmpct-info .material-symbols-outlined {
|
| 301 |
+
font-size: 1.1rem;
|
| 302 |
+
flex-shrink: 0;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.cmpct-loading {
|
| 306 |
+
display: flex;
|
| 307 |
+
align-items: center;
|
| 308 |
+
justify-content: center;
|
| 309 |
+
gap: 8px;
|
| 310 |
+
padding: 30px;
|
| 311 |
+
color: var(--color-text-secondary, #999);
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.cmpct-footer {
|
| 315 |
+
display: flex;
|
| 316 |
+
justify-content: flex-end;
|
| 317 |
+
gap: 8px;
|
| 318 |
+
padding: 12px 20px;
|
| 319 |
+
border-top: 1px solid var(--color-border, #333);
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
.cmpct-btn {
|
| 323 |
+
padding: 8px 16px;
|
| 324 |
+
border-radius: 6px;
|
| 325 |
+
font-size: 0.9rem;
|
| 326 |
+
font-weight: 500;
|
| 327 |
+
cursor: pointer;
|
| 328 |
+
transition: all 0.2s;
|
| 329 |
+
display: flex;
|
| 330 |
+
align-items: center;
|
| 331 |
+
gap: 6px;
|
| 332 |
+
border: none;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
.cmpct-btn:disabled {
|
| 336 |
+
opacity: 0.6;
|
| 337 |
+
cursor: not-allowed;
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
.cmpct-btn-cancel {
|
| 341 |
+
background: var(--color-background-muted, #333);
|
| 342 |
+
color: var(--color-text, #e5e5e5);
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
.cmpct-btn-cancel:hover:not(:disabled) {
|
| 346 |
+
background: var(--color-background-hover, #444);
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
.cmpct-btn-danger {
|
| 350 |
+
background: #dc2626;
|
| 351 |
+
color: white;
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
.cmpct-btn-danger:hover:not(:disabled) {
|
| 355 |
+
background: #b91c1c;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.cmpct-spinner {
|
| 359 |
+
width: 16px;
|
| 360 |
+
height: 16px;
|
| 361 |
+
border: 2px solid currentColor;
|
| 362 |
+
border-top-color: transparent;
|
| 363 |
+
border-radius: 50%;
|
| 364 |
+
animation: cmpct-spin 0.8s linear infinite;
|
| 365 |
+
display: inline-block;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
@keyframes cmpct-spin {
|
| 369 |
+
to { transform: rotate(360deg); }
|
| 370 |
+
}
|
| 371 |
+
</style>
|
| 372 |
+
</body>
|
| 373 |
+
</html>
|
plugins/_chat_compaction/helpers/compactor.py
CHANGED
|
@@ -1,3 +1,279 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core compaction logic for the compaction plugin."""
|
| 2 |
+
import os
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
import models as models_module
|
| 6 |
+
from agent import Agent
|
| 7 |
+
from helpers import tokens
|
| 8 |
+
from helpers.history import History, output_text
|
| 9 |
+
from helpers.persist_chat import (
|
| 10 |
+
export_json_chat,
|
| 11 |
+
get_chat_folder_path,
|
| 12 |
+
save_tmp_chat,
|
| 13 |
+
remove_msg_files,
|
| 14 |
+
)
|
| 15 |
+
from helpers.state_monitor_integration import mark_dirty_all
|
| 16 |
+
|
| 17 |
+
MIN_COMPACTION_TOKENS = 1000
|
| 18 |
+
from plugins._model_config.helpers.model_config import (
|
| 19 |
+
get_chat_model_config,
|
| 20 |
+
get_utility_model_config,
|
| 21 |
+
get_preset_by_name,
|
| 22 |
+
build_model_config,
|
| 23 |
+
build_chat_model,
|
| 24 |
+
build_utility_model,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _save_pre_compaction_backup(context, full_text: str) -> dict[str, str]:
|
| 29 |
+
"""Save the original chat as JSON and plain text before compaction.
|
| 30 |
+
|
| 31 |
+
Returns dict with 'json' and 'txt' absolute file paths.
|
| 32 |
+
"""
|
| 33 |
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
| 34 |
+
backup_dir = os.path.join(get_chat_folder_path(context.id), "backups")
|
| 35 |
+
os.makedirs(backup_dir, exist_ok=True)
|
| 36 |
+
|
| 37 |
+
json_path = os.path.join(backup_dir, f"pre-compact-{timestamp}.json")
|
| 38 |
+
txt_path = os.path.join(backup_dir, f"pre-compact-{timestamp}.txt")
|
| 39 |
+
|
| 40 |
+
json_content = export_json_chat(context)
|
| 41 |
+
with open(json_path, "w", encoding="utf-8") as f:
|
| 42 |
+
f.write(json_content)
|
| 43 |
+
|
| 44 |
+
with open(txt_path, "w", encoding="utf-8") as f:
|
| 45 |
+
f.write(full_text)
|
| 46 |
+
|
| 47 |
+
return {"json": json_path, "txt": txt_path}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _build_model(use_chat_model: bool, preset_name: str | None, agent):
|
| 51 |
+
"""Build the LLM model for compaction based on user selection.
|
| 52 |
+
|
| 53 |
+
If preset_name is given, builds from that preset's config.
|
| 54 |
+
Otherwise falls back to the agent's currently configured model.
|
| 55 |
+
"""
|
| 56 |
+
if preset_name:
|
| 57 |
+
preset = get_preset_by_name(preset_name)
|
| 58 |
+
if preset:
|
| 59 |
+
model_key = "chat" if use_chat_model else "utility"
|
| 60 |
+
cfg = preset.get(model_key, {})
|
| 61 |
+
if cfg.get("provider") or cfg.get("name"):
|
| 62 |
+
mc = build_model_config(cfg, models_module.ModelType.CHAT)
|
| 63 |
+
return cfg, models_module.get_chat_model(
|
| 64 |
+
mc.provider, mc.name, model_config=mc, **mc.build_kwargs()
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
if use_chat_model:
|
| 68 |
+
cfg = get_chat_model_config(agent)
|
| 69 |
+
return cfg, build_chat_model(agent)
|
| 70 |
+
else:
|
| 71 |
+
cfg = get_utility_model_config(agent)
|
| 72 |
+
return cfg, build_utility_model(agent)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
async def run_compaction(
|
| 76 |
+
context,
|
| 77 |
+
use_chat_model: bool = True,
|
| 78 |
+
preset_name: str | None = None,
|
| 79 |
+
) -> None:
|
| 80 |
+
"""
|
| 81 |
+
Compact the chat history into a single summarized message.
|
| 82 |
+
|
| 83 |
+
This function:
|
| 84 |
+
1. Extracts the full conversation text
|
| 85 |
+
2. Estimates token count and checks against model context window
|
| 86 |
+
3. If needed, splits history and summarizes iteratively
|
| 87 |
+
4. Calls the LLM to generate a comprehensive summary
|
| 88 |
+
5. Replaces the history with a single AI message containing the summary
|
| 89 |
+
6. Resets the log and creates a response log item
|
| 90 |
+
7. Persists the changes
|
| 91 |
+
|
| 92 |
+
The function streams progress to the frontend via the log system.
|
| 93 |
+
If any error occurs, the original history is preserved.
|
| 94 |
+
"""
|
| 95 |
+
agent = context.agent0
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
# Step 1: Extract full conversation text
|
| 99 |
+
history_output = agent.history.output()
|
| 100 |
+
full_text = output_text(history_output, ai_label="assistant", human_label="user")
|
| 101 |
+
|
| 102 |
+
if not full_text.strip():
|
| 103 |
+
raise ValueError("No conversation content to compact")
|
| 104 |
+
|
| 105 |
+
# Step 2: Estimate tokens, resolve model, and compute context budget
|
| 106 |
+
token_count = tokens.approximate_tokens(full_text)
|
| 107 |
+
|
| 108 |
+
resolved_cfg, model = _build_model(use_chat_model, preset_name, agent)
|
| 109 |
+
ctx_length = int(resolved_cfg.get("ctx_length", 128000)) if resolved_cfg else 128000
|
| 110 |
+
max_input_tokens = int(ctx_length * 0.7)
|
| 111 |
+
|
| 112 |
+
# Step 3: Create progress log item (count user-visible messages only)
|
| 113 |
+
visible_types = {"user", "response"}
|
| 114 |
+
visible_count = sum(1 for item in context.log.logs if item.type in visible_types)
|
| 115 |
+
log_item = context.log.log(
|
| 116 |
+
type="info",
|
| 117 |
+
heading="Compacting chat history...",
|
| 118 |
+
content=f"Analyzing {visible_count} messages (~{token_count} tokens)...",
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
# Step 4: Handle large histories by chunking if necessary
|
| 122 |
+
if token_count > max_input_tokens:
|
| 123 |
+
summary = await _compact_large_history(
|
| 124 |
+
agent, full_text, token_count, max_input_tokens, log_item, model
|
| 125 |
+
)
|
| 126 |
+
else:
|
| 127 |
+
summary = await _compact_single_pass(
|
| 128 |
+
agent, full_text, log_item, model
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
if not summary or not summary.strip():
|
| 132 |
+
raise ValueError("Compaction produced empty summary")
|
| 133 |
+
|
| 134 |
+
# Step 5: Save pre-compaction backup before destroying history
|
| 135 |
+
backup_paths = _save_pre_compaction_backup(context, full_text)
|
| 136 |
+
|
| 137 |
+
# Step 6: Replace history with compacted version
|
| 138 |
+
backup_note = (
|
| 139 |
+
f"\n\n---\n"
|
| 140 |
+
f"*Pre-compaction backup of the full original conversation:*\n"
|
| 141 |
+
f"- `{backup_paths['txt']}`"
|
| 142 |
+
)
|
| 143 |
+
compacted_content = f"## Context compacted\n\n{summary}{backup_note}"
|
| 144 |
+
|
| 145 |
+
agent.history = History(agent=agent)
|
| 146 |
+
agent.history.add_message(ai=True, content=compacted_content)
|
| 147 |
+
|
| 148 |
+
# Clear subordinate chain
|
| 149 |
+
agent.data.pop(Agent.DATA_NAME_SUBORDINATE, None)
|
| 150 |
+
context.streaming_agent = None
|
| 151 |
+
|
| 152 |
+
# Step 7: Reset log and create response
|
| 153 |
+
context.log.reset()
|
| 154 |
+
context.log.log(
|
| 155 |
+
type="response",
|
| 156 |
+
heading="Context compacted",
|
| 157 |
+
content=compacted_content,
|
| 158 |
+
update_progress="none",
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
# Step 8: Persist and notify
|
| 162 |
+
save_tmp_chat(context)
|
| 163 |
+
remove_msg_files(context.id)
|
| 164 |
+
|
| 165 |
+
# Step 9: Force progress bar to inactive state LAST
|
| 166 |
+
# This must happen after all log operations and persist
|
| 167 |
+
context.log.set_progress("Waiting for input", 0, False)
|
| 168 |
+
mark_dirty_all(reason="plugins.compaction.compact_chat")
|
| 169 |
+
|
| 170 |
+
except Exception as e:
|
| 171 |
+
# Log error but don't modify history
|
| 172 |
+
context.log.log(
|
| 173 |
+
type="error",
|
| 174 |
+
heading="Compaction Failed",
|
| 175 |
+
content=str(e),
|
| 176 |
+
)
|
| 177 |
+
mark_dirty_all(reason="plugins.compaction.compact_chat_error")
|
| 178 |
+
raise
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
async def _compact_single_pass(agent, full_text: str, log_item, model) -> str:
|
| 182 |
+
"""Compact history in a single LLM call using the provided model."""
|
| 183 |
+
system_prompt = agent.read_prompt("compact.sys.md")
|
| 184 |
+
user_prompt = agent.read_prompt("compact.msg.md", conversation=full_text)
|
| 185 |
+
|
| 186 |
+
async def stream_cb(chunk: str, total: str):
|
| 187 |
+
if chunk:
|
| 188 |
+
log_item.stream(content=chunk)
|
| 189 |
+
|
| 190 |
+
summary, _ = await model.unified_call(
|
| 191 |
+
system_message=system_prompt,
|
| 192 |
+
user_message=user_prompt,
|
| 193 |
+
response_callback=stream_cb,
|
| 194 |
+
)
|
| 195 |
+
return summary
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
async def _compact_large_history(
|
| 199 |
+
agent, full_text: str, token_count: int, max_input_tokens: int, log_item, model
|
| 200 |
+
) -> str:
|
| 201 |
+
"""Handle large histories by splitting into chunks and summarizing iteratively."""
|
| 202 |
+
log_item.update(
|
| 203 |
+
content=f"History is large (~{token_count} tokens). Splitting into chunks...",
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
lines = full_text.split('\n')
|
| 207 |
+
mid = len(lines) // 2
|
| 208 |
+
chunks = ['\n'.join(lines[:mid]), '\n'.join(lines[mid:])]
|
| 209 |
+
|
| 210 |
+
summaries = []
|
| 211 |
+
for i, chunk in enumerate(chunks, 1):
|
| 212 |
+
log_item.update(content=f"Summarizing part {i}/{len(chunks)}...")
|
| 213 |
+
|
| 214 |
+
system_prompt = agent.read_prompt("compact.sys.md")
|
| 215 |
+
user_prompt = agent.read_prompt("compact.msg.md", conversation=chunk)
|
| 216 |
+
|
| 217 |
+
chunk_summary, _ = await model.unified_call(
|
| 218 |
+
system_message=system_prompt,
|
| 219 |
+
user_message=user_prompt,
|
| 220 |
+
)
|
| 221 |
+
summaries.append(chunk_summary)
|
| 222 |
+
|
| 223 |
+
combined = "\n\n---\n\n".join(summaries)
|
| 224 |
+
log_item.update(content="Creating final summary from parts...")
|
| 225 |
+
|
| 226 |
+
final_prompt = agent.read_prompt("compact.sys.md")
|
| 227 |
+
final_user = agent.read_prompt(
|
| 228 |
+
"compact.msg.md",
|
| 229 |
+
conversation=f"This is a multi-part conversation. Here are summaries of each part:\n\n{combined}",
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
async def stream_cb(chunk: str, total: str):
|
| 233 |
+
if chunk:
|
| 234 |
+
log_item.stream(content=chunk)
|
| 235 |
+
|
| 236 |
+
final_summary, _ = await model.unified_call(
|
| 237 |
+
system_message=final_prompt,
|
| 238 |
+
user_message=final_user,
|
| 239 |
+
response_callback=stream_cb,
|
| 240 |
+
)
|
| 241 |
+
return final_summary
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
async def get_compaction_stats(context) -> dict:
|
| 245 |
+
"""
|
| 246 |
+
Get statistics about the current chat for the confirmation modal.
|
| 247 |
+
|
| 248 |
+
Returns:
|
| 249 |
+
dict with message_count, token_count, model_name
|
| 250 |
+
"""
|
| 251 |
+
agent = context.agent0
|
| 252 |
+
|
| 253 |
+
# Count user-visible conversation turns only
|
| 254 |
+
# 'user' = user sent a message, 'response' = agent final response
|
| 255 |
+
# Other types (agent, tool, code_exe, etc.) are intermediate processing steps
|
| 256 |
+
visible_types = {"user", "response"}
|
| 257 |
+
message_count = sum(
|
| 258 |
+
1 for item in context.log.logs
|
| 259 |
+
if item.type in visible_types
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
# Estimate tokens
|
| 263 |
+
history_output = agent.history.output()
|
| 264 |
+
full_text = output_text(history_output, ai_label="assistant", human_label="user")
|
| 265 |
+
token_count = tokens.approximate_tokens(full_text) if full_text else 0
|
| 266 |
+
|
| 267 |
+
# Get model names for both chat and utility
|
| 268 |
+
chat_cfg = get_chat_model_config(agent)
|
| 269 |
+
utility_cfg = get_utility_model_config(agent)
|
| 270 |
+
chat_model_name = chat_cfg.get("name", "Default") if chat_cfg else "Default"
|
| 271 |
+
utility_model_name = utility_cfg.get("name", "Default") if utility_cfg else "Default"
|
| 272 |
+
|
| 273 |
+
return {
|
| 274 |
+
"message_count": message_count,
|
| 275 |
+
"token_count": token_count,
|
| 276 |
+
"model_name": chat_model_name,
|
| 277 |
+
"chat_model_name": chat_model_name,
|
| 278 |
+
"utility_model_name": utility_model_name,
|
| 279 |
+
}
|
plugins/_chat_compaction/prompts/compact.msg.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
Compact this conversation to its essential facts. Be maximally concise.
|
| 2 |
+
|
| 3 |
+
---
|
| 4 |
+
{{conversation}}
|
| 5 |
+
---
|
plugins/_chat_compaction/prompts/compact.sys.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are a conversation compactor. Produce the most concise summary possible while preserving critical information.
|
| 2 |
+
|
| 3 |
+
Rules:
|
| 4 |
+
- Extract only: key decisions, final outcomes, actionable facts, unresolved items
|
| 5 |
+
- Discard: intermediate reasoning, failed attempts, redundant exchanges, pleasantries
|
| 6 |
+
- Use terse bullet points, not prose
|
| 7 |
+
- Collapse related items into single lines
|
| 8 |
+
- Keep exact values: file paths, config values, code identifiers, credentials, URLs
|
| 9 |
+
- Omit anything that can be re-derived from context
|
| 10 |
+
- Group by topic, not chronology
|
| 11 |
+
- No meta-commentary about the summarization
|
| 12 |
+
- Target: 10-20% of original length
|
| 13 |
+
|
| 14 |
+
Output format:
|
| 15 |
+
- Markdown with short section headers
|
| 16 |
+
- Bullet lists, no paragraphs
|
| 17 |
+
- Code/paths in backticks inline, not fenced blocks unless multi-line
|
plugins/_chat_compaction/webui/compact-modal.html
CHANGED
|
@@ -1,3 +1,390 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<html>
|
| 2 |
+
<head>
|
| 3 |
+
<title>Compact Chat</title>
|
| 4 |
+
</head>
|
| 5 |
+
<body>
|
| 6 |
+
<div x-data x-show="$store.compactStore?.showModal" style="display: none;">
|
| 7 |
+
<div class="modal-overlay" @click="$store.compactStore?.closeModal()">
|
| 8 |
+
<div class="modal-container" @click.stop>
|
| 9 |
+
<div class="modal-header">
|
| 10 |
+
<h3>Compact Chat History</h3>
|
| 11 |
+
<button class="modal-close" @click="$store.compactStore?.closeModal()">
|
| 12 |
+
<span class="material-symbols-outlined">close</span>
|
| 13 |
+
</button>
|
| 14 |
+
</div>
|
| 15 |
+
|
| 16 |
+
<div class="modal-content">
|
| 17 |
+
<template x-if="$store.compactStore?.stats">
|
| 18 |
+
<div class="stats-container">
|
| 19 |
+
<p class="stats-description">
|
| 20 |
+
This will summarize your entire conversation into a single optimized message.
|
| 21 |
+
</p>
|
| 22 |
+
|
| 23 |
+
<div class="stats-grid">
|
| 24 |
+
<div class="stat-item">
|
| 25 |
+
<span class="stat-label">Messages</span>
|
| 26 |
+
<span class="stat-value" x-text="$store.compactStore?.stats?.message_count"></span>
|
| 27 |
+
</div>
|
| 28 |
+
<div class="stat-item">
|
| 29 |
+
<span class="stat-label">Tokens</span>
|
| 30 |
+
<span class="stat-value" x-text="$store.compactStore?.stats?.token_count?.toLocaleString()"></span>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<!-- Model selection -->
|
| 35 |
+
<div class="model-section">
|
| 36 |
+
<div class="model-section-label">Model</div>
|
| 37 |
+
|
| 38 |
+
<div class="model-controls">
|
| 39 |
+
<!-- Preset dropdown -->
|
| 40 |
+
<select class="compact-select"
|
| 41 |
+
x-model="$store.compactStore.selectedPresetName">
|
| 42 |
+
<option value="">Current</option>
|
| 43 |
+
<template x-for="preset in $store.compactStore.presets" :key="preset.name">
|
| 44 |
+
<option :value="preset.name" x-text="preset.name"></option>
|
| 45 |
+
</template>
|
| 46 |
+
</select>
|
| 47 |
+
|
| 48 |
+
<!-- Chat / Utility toggle -->
|
| 49 |
+
<div class="model-type-toggle">
|
| 50 |
+
<button :class="{ active: $store.compactStore.useChatModel }"
|
| 51 |
+
@click="$store.compactStore.useChatModel = true">
|
| 52 |
+
Chat
|
| 53 |
+
</button>
|
| 54 |
+
<button :class="{ active: !$store.compactStore.useChatModel }"
|
| 55 |
+
@click="$store.compactStore.useChatModel = false">
|
| 56 |
+
Utility
|
| 57 |
+
</button>
|
| 58 |
+
</div>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<div class="model-name" x-text="$store.compactStore.selectedModelDisplay"></div>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<template x-if="!$store.compactStore?.canCompact">
|
| 65 |
+
<div class="stats-info">
|
| 66 |
+
<span class="material-symbols-outlined">info</span>
|
| 67 |
+
<span>Conversation is too short to compact (minimum 1,000 tokens).</span>
|
| 68 |
+
</div>
|
| 69 |
+
</template>
|
| 70 |
+
|
| 71 |
+
<div class="stats-warning">
|
| 72 |
+
<span class="material-symbols-outlined">warning</span>
|
| 73 |
+
<span>This action cannot be undone. The original conversation will be replaced with a summary.</span>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
</template>
|
| 77 |
+
|
| 78 |
+
<template x-if="!$store.compactStore?.stats">
|
| 79 |
+
<div class="stats-loading">
|
| 80 |
+
<span class="loading-spinner"></span>
|
| 81 |
+
<span>Loading statistics...</span>
|
| 82 |
+
</div>
|
| 83 |
+
</template>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<div class="modal-footer">
|
| 87 |
+
<button class="button secondary" @click="$store.compactStore?.closeModal()">
|
| 88 |
+
Cancel
|
| 89 |
+
</button>
|
| 90 |
+
<button
|
| 91 |
+
class="button primary danger"
|
| 92 |
+
@click="$store.compactStore?.compact()"
|
| 93 |
+
:disabled="$store.compactStore?.compacting || !$store.compactStore?.canCompact"
|
| 94 |
+
>
|
| 95 |
+
<template x-if="$store.compactStore?.compacting">
|
| 96 |
+
<span class="loading-spinner"></span>
|
| 97 |
+
</template>
|
| 98 |
+
<span>Compact</span>
|
| 99 |
+
</button>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
|
| 105 |
+
<style>
|
| 106 |
+
.modal-overlay {
|
| 107 |
+
position: fixed;
|
| 108 |
+
top: 0;
|
| 109 |
+
left: 0;
|
| 110 |
+
right: 0;
|
| 111 |
+
bottom: 0;
|
| 112 |
+
background: rgba(0, 0, 0, 0.5);
|
| 113 |
+
display: flex;
|
| 114 |
+
align-items: center;
|
| 115 |
+
justify-content: center;
|
| 116 |
+
z-index: 1000;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.modal-container {
|
| 120 |
+
background: var(--color-background-elevated);
|
| 121 |
+
border-radius: var(--border-radius-md);
|
| 122 |
+
box-shadow: var(--shadow-lg);
|
| 123 |
+
width: 90%;
|
| 124 |
+
max-width: 640px;
|
| 125 |
+
max-height: 90vh;
|
| 126 |
+
overflow-y: auto;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.modal-header {
|
| 130 |
+
display: flex;
|
| 131 |
+
align-items: center;
|
| 132 |
+
justify-content: space-between;
|
| 133 |
+
padding: var(--spacing-md) var(--spacing-lg);
|
| 134 |
+
border-bottom: 1px solid var(--color-border);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.modal-header h3 {
|
| 138 |
+
margin: 0;
|
| 139 |
+
font-size: 1.1rem;
|
| 140 |
+
font-weight: 600;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.modal-close {
|
| 144 |
+
background: transparent;
|
| 145 |
+
border: none;
|
| 146 |
+
cursor: pointer;
|
| 147 |
+
padding: var(--spacing-xs);
|
| 148 |
+
border-radius: var(--border-radius-sm);
|
| 149 |
+
color: var(--color-text-secondary);
|
| 150 |
+
transition: color 0.2s;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.modal-close:hover {
|
| 154 |
+
color: var(--color-text);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.modal-content {
|
| 158 |
+
padding: var(--spacing-lg);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.stats-description {
|
| 162 |
+
margin: 0 0 var(--spacing-md) 0;
|
| 163 |
+
color: var(--color-text-secondary);
|
| 164 |
+
font-size: 0.9rem;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.stats-grid {
|
| 168 |
+
display: grid;
|
| 169 |
+
grid-template-columns: repeat(2, 1fr);
|
| 170 |
+
gap: var(--spacing-md);
|
| 171 |
+
margin-bottom: var(--spacing-lg);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.stat-item {
|
| 175 |
+
display: flex;
|
| 176 |
+
flex-direction: column;
|
| 177 |
+
align-items: center;
|
| 178 |
+
padding: var(--spacing-md);
|
| 179 |
+
background: var(--color-background-muted);
|
| 180 |
+
border-radius: var(--border-radius-md);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.stat-label {
|
| 184 |
+
font-size: 0.75rem;
|
| 185 |
+
color: var(--color-text-secondary);
|
| 186 |
+
text-transform: uppercase;
|
| 187 |
+
letter-spacing: 0.05em;
|
| 188 |
+
margin-bottom: var(--spacing-xs);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.stat-value {
|
| 192 |
+
font-size: 1.25rem;
|
| 193 |
+
font-weight: 600;
|
| 194 |
+
color: var(--color-text);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/* ── Model selection ── */
|
| 198 |
+
.model-section {
|
| 199 |
+
margin-bottom: var(--spacing-lg);
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
.model-section-label {
|
| 203 |
+
font-size: 0.75rem;
|
| 204 |
+
color: var(--color-text-secondary);
|
| 205 |
+
text-transform: uppercase;
|
| 206 |
+
letter-spacing: 0.05em;
|
| 207 |
+
margin-bottom: var(--spacing-sm);
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.model-controls {
|
| 211 |
+
display: flex;
|
| 212 |
+
gap: var(--spacing-sm);
|
| 213 |
+
margin-bottom: var(--spacing-sm);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.compact-select {
|
| 217 |
+
flex: 1;
|
| 218 |
+
min-width: 0;
|
| 219 |
+
padding: 6px 10px;
|
| 220 |
+
border-radius: var(--border-radius-md);
|
| 221 |
+
border: 1px solid var(--color-border);
|
| 222 |
+
background: var(--color-background-muted);
|
| 223 |
+
color: var(--color-text);
|
| 224 |
+
font-size: 0.85rem;
|
| 225 |
+
cursor: pointer;
|
| 226 |
+
appearance: auto;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.compact-select:focus {
|
| 230 |
+
outline: none;
|
| 231 |
+
border-color: var(--color-primary, #3b82f6);
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
.model-type-toggle {
|
| 235 |
+
display: flex;
|
| 236 |
+
border-radius: var(--border-radius-md);
|
| 237 |
+
border: 1px solid var(--color-border);
|
| 238 |
+
overflow: hidden;
|
| 239 |
+
flex-shrink: 0;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
.model-type-toggle button {
|
| 243 |
+
padding: 6px 14px;
|
| 244 |
+
border: none;
|
| 245 |
+
background: var(--color-background-muted);
|
| 246 |
+
color: var(--color-text-secondary);
|
| 247 |
+
font-size: 0.8rem;
|
| 248 |
+
font-weight: 500;
|
| 249 |
+
cursor: pointer;
|
| 250 |
+
transition: all 0.15s;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.model-type-toggle button:not(:last-child) {
|
| 254 |
+
border-right: 1px solid var(--color-border);
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
.model-type-toggle button.active {
|
| 258 |
+
background: var(--color-primary, #3b82f6);
|
| 259 |
+
color: white;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.model-type-toggle button:hover:not(.active) {
|
| 263 |
+
background: var(--color-background-hover);
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
.model-name {
|
| 267 |
+
font-size: 0.85rem;
|
| 268 |
+
color: var(--color-text);
|
| 269 |
+
opacity: 0.7;
|
| 270 |
+
white-space: nowrap;
|
| 271 |
+
overflow: hidden;
|
| 272 |
+
text-overflow: ellipsis;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
.stats-info {
|
| 276 |
+
display: flex;
|
| 277 |
+
align-items: flex-start;
|
| 278 |
+
gap: var(--spacing-sm);
|
| 279 |
+
padding: var(--spacing-md);
|
| 280 |
+
margin-bottom: var(--spacing-md);
|
| 281 |
+
background: var(--color-info-bg, rgba(59, 130, 246, 0.1));
|
| 282 |
+
border: 1px solid var(--color-info-border, rgba(59, 130, 246, 0.3));
|
| 283 |
+
border-radius: var(--border-radius-md);
|
| 284 |
+
color: var(--color-info-text, #1e40af);
|
| 285 |
+
font-size: 0.85rem;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.stats-info .material-symbols-outlined {
|
| 289 |
+
font-size: 1.2rem;
|
| 290 |
+
flex-shrink: 0;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.stats-warning {
|
| 294 |
+
display: flex;
|
| 295 |
+
align-items: flex-start;
|
| 296 |
+
gap: var(--spacing-sm);
|
| 297 |
+
padding: var(--spacing-md);
|
| 298 |
+
background: var(--color-warning-bg, rgba(245, 158, 11, 0.1));
|
| 299 |
+
border: 1px solid var(--color-warning-border, rgba(245, 158, 11, 0.3));
|
| 300 |
+
border-radius: var(--border-radius-md);
|
| 301 |
+
color: var(--color-warning-text, #92400e);
|
| 302 |
+
font-size: 0.85rem;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.stats-warning .material-symbols-outlined {
|
| 306 |
+
font-size: 1.2rem;
|
| 307 |
+
flex-shrink: 0;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
.stats-loading {
|
| 311 |
+
display: flex;
|
| 312 |
+
align-items: center;
|
| 313 |
+
justify-content: center;
|
| 314 |
+
gap: var(--spacing-sm);
|
| 315 |
+
padding: var(--spacing-xl);
|
| 316 |
+
color: var(--color-text-secondary);
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
.modal-footer {
|
| 320 |
+
display: flex;
|
| 321 |
+
justify-content: flex-end;
|
| 322 |
+
gap: var(--spacing-sm);
|
| 323 |
+
padding: var(--spacing-md) var(--spacing-lg);
|
| 324 |
+
border-top: 1px solid var(--color-border);
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
.button {
|
| 328 |
+
padding: var(--spacing-sm) var(--spacing-md);
|
| 329 |
+
border-radius: var(--border-radius-md);
|
| 330 |
+
font-size: 0.9rem;
|
| 331 |
+
font-weight: 500;
|
| 332 |
+
cursor: pointer;
|
| 333 |
+
transition: all 0.2s;
|
| 334 |
+
display: flex;
|
| 335 |
+
align-items: center;
|
| 336 |
+
gap: var(--spacing-xs);
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
.button:disabled {
|
| 340 |
+
opacity: 0.6;
|
| 341 |
+
cursor: not-allowed;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
.button.secondary {
|
| 345 |
+
background: var(--color-background-muted);
|
| 346 |
+
border: 1px solid var(--color-border);
|
| 347 |
+
color: var(--color-text);
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.button.secondary:hover:not(:disabled) {
|
| 351 |
+
background: var(--color-background-hover);
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
.button.primary {
|
| 355 |
+
background: var(--color-primary);
|
| 356 |
+
border: none;
|
| 357 |
+
color: white;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
.button.primary:hover:not(:disabled) {
|
| 361 |
+
background: var(--color-primary-hover);
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
.button.danger {
|
| 365 |
+
background: var(--color-danger, #dc2626);
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
.button.danger:hover:not(:disabled) {
|
| 369 |
+
background: var(--color-danger-hover, #b91c1c);
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
.loading-spinner {
|
| 373 |
+
width: 16px;
|
| 374 |
+
height: 16px;
|
| 375 |
+
border: 2px solid currentColor;
|
| 376 |
+
border-top-color: transparent;
|
| 377 |
+
border-radius: 50%;
|
| 378 |
+
animation: spin 0.8s linear infinite;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
@keyframes spin {
|
| 382 |
+
to { transform: rotate(360deg); }
|
| 383 |
+
}
|
| 384 |
+
</style>
|
| 385 |
+
|
| 386 |
+
<script type="module">
|
| 387 |
+
import { store } from "/plugins/_chat_compaction/webui/compact-store.js";
|
| 388 |
+
</script>
|
| 389 |
+
</body>
|
| 390 |
+
</html>
|
plugins/_chat_compaction/webui/compact-store.js
CHANGED
|
@@ -1,3 +1,102 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createStore } from "/js/AlpineStore.js";
|
| 2 |
+
import { callJsonApi } from "/js/api.js";
|
| 3 |
+
import {
|
| 4 |
+
toastFrontendSuccess,
|
| 5 |
+
toastFrontendError,
|
| 6 |
+
} from "/components/notifications/notification-store.js";
|
| 7 |
+
|
| 8 |
+
export const store = createStore("compactStore", {
|
| 9 |
+
compacting: false,
|
| 10 |
+
stats: null,
|
| 11 |
+
showModal: false,
|
| 12 |
+
presets: [],
|
| 13 |
+
selectedPresetName: "",
|
| 14 |
+
useChatModel: true,
|
| 15 |
+
|
| 16 |
+
minTokens: 1000,
|
| 17 |
+
|
| 18 |
+
get canCompact() {
|
| 19 |
+
return this.stats && this.stats.token_count >= this.minTokens;
|
| 20 |
+
},
|
| 21 |
+
|
| 22 |
+
get selectedModelDisplay() {
|
| 23 |
+
if (this.selectedPresetName) {
|
| 24 |
+
const preset = this.presets.find(
|
| 25 |
+
(p) => p.name === this.selectedPresetName
|
| 26 |
+
);
|
| 27 |
+
if (preset) {
|
| 28 |
+
const cfg = this.useChatModel ? preset.chat : preset.utility;
|
| 29 |
+
if (cfg?.name) return cfg.name;
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
return this.useChatModel
|
| 33 |
+
? this.stats?.chat_model_name || "Chat Model"
|
| 34 |
+
: this.stats?.utility_model_name || "Utility Model";
|
| 35 |
+
},
|
| 36 |
+
|
| 37 |
+
async fetchStats() {
|
| 38 |
+
try {
|
| 39 |
+
const ctxid = globalThis.getContext?.();
|
| 40 |
+
if (!ctxid) {
|
| 41 |
+
toastFrontendError("No active chat", "Compaction");
|
| 42 |
+
return;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const [statsRes, presetsRes] = await Promise.all([
|
| 46 |
+
callJsonApi("/plugins/_chat_compaction/compact_chat", {
|
| 47 |
+
context: ctxid,
|
| 48 |
+
action: "stats",
|
| 49 |
+
}),
|
| 50 |
+
callJsonApi("/plugins/_model_config/model_presets", {
|
| 51 |
+
action: "get",
|
| 52 |
+
}),
|
| 53 |
+
]);
|
| 54 |
+
|
| 55 |
+
if (!statsRes?.ok) {
|
| 56 |
+
throw new Error(statsRes?.message || "Failed to fetch stats");
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
this.stats = statsRes.stats;
|
| 60 |
+
this.presets = presetsRes?.ok ? presetsRes.presets || [] : [];
|
| 61 |
+
this.showModal = true;
|
| 62 |
+
} catch (e) {
|
| 63 |
+
toastFrontendError(e.message, "Compaction");
|
| 64 |
+
}
|
| 65 |
+
},
|
| 66 |
+
|
| 67 |
+
async compact() {
|
| 68 |
+
this.compacting = true;
|
| 69 |
+
try {
|
| 70 |
+
const ctxid = globalThis.getContext?.();
|
| 71 |
+
if (!ctxid) {
|
| 72 |
+
throw new Error("No active chat");
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
const res = await callJsonApi("/plugins/_chat_compaction/compact_chat", {
|
| 76 |
+
context: ctxid,
|
| 77 |
+
action: "compact",
|
| 78 |
+
use_chat_model: this.useChatModel,
|
| 79 |
+
preset_name: this.selectedPresetName || null,
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
if (!res?.ok) {
|
| 83 |
+
throw new Error(res?.message || "Compaction failed");
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
toastFrontendSuccess("Compaction started", "Compaction");
|
| 87 |
+
this.showModal = false;
|
| 88 |
+
} catch (e) {
|
| 89 |
+
toastFrontendError(e.message, "Compaction");
|
| 90 |
+
} finally {
|
| 91 |
+
this.compacting = false;
|
| 92 |
+
}
|
| 93 |
+
},
|
| 94 |
+
|
| 95 |
+
closeModal() {
|
| 96 |
+
this.showModal = false;
|
| 97 |
+
this.stats = null;
|
| 98 |
+
this.presets = [];
|
| 99 |
+
this.selectedPresetName = "";
|
| 100 |
+
this.useChatModel = true;
|
| 101 |
+
},
|
| 102 |
+
});
|
plugins/_chat_compaction/webui/thumbnail.png
CHANGED
|
|
Git LFS Details
|
|
|
plugins/_code_execution/README.md
CHANGED
|
@@ -1,3 +1,52 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Code Execution
|
| 2 |
+
|
| 3 |
+
Run terminal commands and execute Python or Node.js code through Agent Zero using persistent shell sessions.
|
| 4 |
+
|
| 5 |
+
## What It Does
|
| 6 |
+
|
| 7 |
+
This plugin provides the code execution tool used by agents for development tasks. It supports:
|
| 8 |
+
|
| 9 |
+
- **Terminal commands** in interactive shell sessions
|
| 10 |
+
- **Python execution** through `ipython -c`
|
| 11 |
+
- **Node.js execution** through `node /exe/node_eval.js`
|
| 12 |
+
- **Persistent sessions** keyed by session number
|
| 13 |
+
- **Session reset and output retrieval**
|
| 14 |
+
- **Local or SSH-backed execution** depending on plugin configuration
|
| 15 |
+
|
| 16 |
+
## Main Behavior
|
| 17 |
+
|
| 18 |
+
- **Persistent shells**
|
| 19 |
+
- Maintains per-session shell state in agent data so subsequent calls can reuse the same terminal session.
|
| 20 |
+
- **Multiple runtimes**
|
| 21 |
+
- Dispatches requests based on `runtime`: `terminal`, `python`, `nodejs`, `output`, or `reset`.
|
| 22 |
+
- **Remote execution support**
|
| 23 |
+
- Can open SSH interactive sessions instead of local shells when configured.
|
| 24 |
+
- **Streaming output**
|
| 25 |
+
- Continuously reads shell output, updates the current log item, and detects progress while commands are running.
|
| 26 |
+
- **Safety around running sessions**
|
| 27 |
+
- Tracks whether a shell is currently busy and can prevent overlapping commands unless explicitly allowed.
|
| 28 |
+
|
| 29 |
+
## Key Files
|
| 30 |
+
|
| 31 |
+
- **Tool**
|
| 32 |
+
- `tools/code_execution_tool.py` contains runtime dispatch, session lifecycle, and streaming output logic.
|
| 33 |
+
- **Helpers**
|
| 34 |
+
- `helpers/shell_local.py` provides the local interactive shell implementation.
|
| 35 |
+
- `helpers/shell_ssh.py` provides the SSH-backed interactive shell implementation.
|
| 36 |
+
- **Configuration**
|
| 37 |
+
- `default_config.yaml` defines execution, prompt, and timeout settings.
|
| 38 |
+
- **Prompts**
|
| 39 |
+
- `prompts/` contains the response templates shown to the agent.
|
| 40 |
+
|
| 41 |
+
## Configuration Scope
|
| 42 |
+
|
| 43 |
+
- **Settings section**: `agent`
|
| 44 |
+
- **Per-project config**: `true`
|
| 45 |
+
- **Per-agent config**: `true`
|
| 46 |
+
- **Always enabled**: `false`
|
| 47 |
+
|
| 48 |
+
## Plugin Metadata
|
| 49 |
+
|
| 50 |
+
- **Name**: `_code_execution`
|
| 51 |
+
- **Title**: `Code Execution`
|
| 52 |
+
- **Description**: Code execution tool supporting terminal, Python, and Node.js runtimes via local TTY or SSH.
|
plugins/_code_execution/extensions/webui/get_message_handler/code-exe-handler.js
CHANGED
|
@@ -1,3 +1,83 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
createActionButton,
|
| 3 |
+
copyToClipboard,
|
| 4 |
+
} from "/components/messages/action-buttons/simple-action-buttons.js";
|
| 5 |
+
import { store as stepDetailStore } from "/components/modals/process-step-detail/step-detail-store.js";
|
| 6 |
+
import {
|
| 7 |
+
buildDetailPayload,
|
| 8 |
+
cleanStepTitle,
|
| 9 |
+
drawProcessStep,
|
| 10 |
+
} from "/js/messages.js";
|
| 11 |
+
|
| 12 |
+
export default async function registerCodeExeHandler(extData) {
|
| 13 |
+
if (extData?.type === "code_exe") {
|
| 14 |
+
extData.handler = drawMessageCodeExe;
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function drawMessageCodeExe({
|
| 19 |
+
id,
|
| 20 |
+
type,
|
| 21 |
+
heading,
|
| 22 |
+
test,
|
| 23 |
+
content,
|
| 24 |
+
kvps,
|
| 25 |
+
timestamp,
|
| 26 |
+
agentno = 0,
|
| 27 |
+
...additional
|
| 28 |
+
}) {
|
| 29 |
+
let title = "Code Execution";
|
| 30 |
+
// show command at the start and end
|
| 31 |
+
if (kvps?.code && /done_all|code_execution_tool/.test(heading || "")) {
|
| 32 |
+
const s = kvps.session;
|
| 33 |
+
title = `${s != null ? `[${s}] ` : ""}${kvps.runtime || "bash"}> ${kvps.code.trim()}`;
|
| 34 |
+
} else {
|
| 35 |
+
// during execution show the original heading (current step)
|
| 36 |
+
title = cleanStepTitle(heading);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const displayKvps = {};
|
| 40 |
+
|
| 41 |
+
const headerLabels = [
|
| 42 |
+
kvps?.runtime && { label: kvps.runtime, class: "tool-name-badge" },
|
| 43 |
+
kvps?.session != null && {
|
| 44 |
+
label: `Session ${kvps.session}`,
|
| 45 |
+
class: "header-label",
|
| 46 |
+
},
|
| 47 |
+
].filter(Boolean);
|
| 48 |
+
|
| 49 |
+
const commandText = String(kvps?.code ?? "");
|
| 50 |
+
const outputText = String(content ?? "");
|
| 51 |
+
|
| 52 |
+
const actionButtons = [];
|
| 53 |
+
actionButtons.push(
|
| 54 |
+
createActionButton("detail", "", () =>
|
| 55 |
+
stepDetailStore.showStepDetail(
|
| 56 |
+
buildDetailPayload(arguments[0], { headerLabels }),
|
| 57 |
+
),
|
| 58 |
+
),
|
| 59 |
+
);
|
| 60 |
+
if (commandText.trim()) {
|
| 61 |
+
actionButtons.push(
|
| 62 |
+
createActionButton("copy", "Command", () => copyToClipboard(commandText)),
|
| 63 |
+
);
|
| 64 |
+
}
|
| 65 |
+
if (outputText.trim()) {
|
| 66 |
+
actionButtons.push(
|
| 67 |
+
createActionButton("copy", "Output", () => copyToClipboard(outputText)),
|
| 68 |
+
);
|
| 69 |
+
}
|
| 70 |
+
const stepData = drawProcessStep({
|
| 71 |
+
id,
|
| 72 |
+
title,
|
| 73 |
+
code: "EXE",
|
| 74 |
+
classes: undefined,
|
| 75 |
+
kvps: displayKvps,
|
| 76 |
+
content,
|
| 77 |
+
contentClasses: ["terminal-output"],
|
| 78 |
+
actionButtons,
|
| 79 |
+
log: arguments[0],
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
return stepData;
|
| 83 |
+
}
|
plugins/_code_execution/helpers/shell_local.py
CHANGED
|
@@ -1,3 +1,50 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import platform
|
| 2 |
+
import select
|
| 3 |
+
import subprocess
|
| 4 |
+
import time
|
| 5 |
+
import sys
|
| 6 |
+
from typing import Optional, Tuple
|
| 7 |
+
from helpers import runtime
|
| 8 |
+
from plugins._code_execution.helpers import tty_session
|
| 9 |
+
from plugins._code_execution.helpers.shell_ssh import clean_string
|
| 10 |
+
|
| 11 |
+
class LocalInteractiveSession:
|
| 12 |
+
def __init__(self, cwd: str|None = None):
|
| 13 |
+
self.session: tty_session.TTYSession|None = None
|
| 14 |
+
self.full_output = ''
|
| 15 |
+
self.cwd = cwd
|
| 16 |
+
|
| 17 |
+
async def connect(self):
|
| 18 |
+
self.session = tty_session.TTYSession(runtime.get_terminal_executable(), cwd=self.cwd)
|
| 19 |
+
await self.session.start()
|
| 20 |
+
await self.session.read_full_until_idle(idle_timeout=1, total_timeout=1)
|
| 21 |
+
|
| 22 |
+
async def close(self):
|
| 23 |
+
if self.session:
|
| 24 |
+
self.session.kill()
|
| 25 |
+
# self.session.wait()
|
| 26 |
+
|
| 27 |
+
async def send_command(self, command: str):
|
| 28 |
+
if not self.session:
|
| 29 |
+
raise Exception("Shell not connected")
|
| 30 |
+
self.full_output = ""
|
| 31 |
+
await self.session.sendline(command)
|
| 32 |
+
|
| 33 |
+
async def read_output(self, timeout: float = 0, reset_full_output: bool = False) -> Tuple[str, Optional[str]]:
|
| 34 |
+
if not self.session:
|
| 35 |
+
raise Exception("Shell not connected")
|
| 36 |
+
|
| 37 |
+
if reset_full_output:
|
| 38 |
+
self.full_output = ""
|
| 39 |
+
|
| 40 |
+
# get output from terminal
|
| 41 |
+
partial_output = await self.session.read_full_until_idle(idle_timeout=0.01, total_timeout=timeout)
|
| 42 |
+
self.full_output += partial_output
|
| 43 |
+
|
| 44 |
+
# clean output
|
| 45 |
+
partial_output = clean_string(partial_output)
|
| 46 |
+
clean_full_output = clean_string(self.full_output)
|
| 47 |
+
|
| 48 |
+
if not partial_output:
|
| 49 |
+
return clean_full_output, None
|
| 50 |
+
return clean_full_output, partial_output
|
plugins/_code_execution/helpers/shell_ssh.py
CHANGED
|
@@ -1,3 +1,245 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import paramiko
|
| 3 |
+
import time
|
| 4 |
+
import re
|
| 5 |
+
from typing import Tuple
|
| 6 |
+
from helpers.log import Log
|
| 7 |
+
from helpers.print_style import PrintStyle
|
| 8 |
+
# from helpers.strings import calculate_valid_match_lengths
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class SSHInteractiveSession:
|
| 12 |
+
|
| 13 |
+
# end_comment = "# @@==>> SSHInteractiveSession End-of-Command <<==@@"
|
| 14 |
+
# ps1_label = "SSHInteractiveSession CLI>"
|
| 15 |
+
|
| 16 |
+
def __init__(
|
| 17 |
+
self, logger: Log, hostname: str, port: int, username: str, password: str, cwd: str|None = None
|
| 18 |
+
):
|
| 19 |
+
self.logger = logger
|
| 20 |
+
self.hostname = hostname
|
| 21 |
+
self.port = port
|
| 22 |
+
self.username = username
|
| 23 |
+
self.password = password
|
| 24 |
+
self.client = paramiko.SSHClient()
|
| 25 |
+
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
| 26 |
+
self.shell = None
|
| 27 |
+
self.full_output = b""
|
| 28 |
+
self.last_command = b""
|
| 29 |
+
self.trimmed_command_length = 0 # Initialize trimmed_command_length
|
| 30 |
+
self.cwd = cwd
|
| 31 |
+
|
| 32 |
+
async def connect(self, keepalive_interval: int = 5):
|
| 33 |
+
"""
|
| 34 |
+
Establish the SSH connection and start an interactive shell.
|
| 35 |
+
|
| 36 |
+
Parameters
|
| 37 |
+
----------
|
| 38 |
+
keepalive_interval : int
|
| 39 |
+
Interval in **seconds** between keep-alive packets sent by Paramiko.
|
| 40 |
+
A value ≤ 0 disables Paramiko’s keep-alive feature.
|
| 41 |
+
"""
|
| 42 |
+
errors = 0
|
| 43 |
+
while True:
|
| 44 |
+
try:
|
| 45 |
+
# --- establish TCP/SSH session ---------------------------------
|
| 46 |
+
self.client.connect(
|
| 47 |
+
self.hostname,
|
| 48 |
+
self.port,
|
| 49 |
+
self.username,
|
| 50 |
+
self.password,
|
| 51 |
+
allow_agent=False,
|
| 52 |
+
look_for_keys=False,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# --------- NEW: enable transport-level keep-alives -------------
|
| 56 |
+
transport = self.client.get_transport()
|
| 57 |
+
if transport and keepalive_interval > 0:
|
| 58 |
+
# sends an SSH_MSG_IGNORE every <keepalive_interval> seconds
|
| 59 |
+
transport.set_keepalive(keepalive_interval)
|
| 60 |
+
# ----------------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
# invoke interactive shell
|
| 63 |
+
self.shell = self.client.invoke_shell(width=100, height=50)
|
| 64 |
+
|
| 65 |
+
# disable systemd/OSC prompt metadata and disable local echo
|
| 66 |
+
initial_command = "unset PROMPT_COMMAND PS0; stty -echo"
|
| 67 |
+
if self.cwd:
|
| 68 |
+
initial_command = f"cd {self.cwd}; {initial_command}"
|
| 69 |
+
self.shell.send(f"{initial_command}\n".encode())
|
| 70 |
+
|
| 71 |
+
# wait for initial prompt/output to settle
|
| 72 |
+
while True:
|
| 73 |
+
full, part = await self.read_output()
|
| 74 |
+
if full and not part:
|
| 75 |
+
return
|
| 76 |
+
time.sleep(0.1)
|
| 77 |
+
|
| 78 |
+
except Exception as e:
|
| 79 |
+
errors += 1
|
| 80 |
+
if errors < 3:
|
| 81 |
+
PrintStyle.standard(f"SSH Connection attempt {errors}...")
|
| 82 |
+
self.logger.log(
|
| 83 |
+
type="info",
|
| 84 |
+
content=f"SSH Connection attempt {errors}...",
|
| 85 |
+
)
|
| 86 |
+
time.sleep(5)
|
| 87 |
+
else:
|
| 88 |
+
raise e
|
| 89 |
+
|
| 90 |
+
async def close(self):
|
| 91 |
+
if self.shell:
|
| 92 |
+
self.shell.close()
|
| 93 |
+
if self.client:
|
| 94 |
+
self.client.close()
|
| 95 |
+
|
| 96 |
+
async def send_command(self, command: str):
|
| 97 |
+
if not self.shell:
|
| 98 |
+
raise Exception("Shell not connected")
|
| 99 |
+
self.full_output = b""
|
| 100 |
+
# if len(command) > 10: # if command is long, add end_comment to split output
|
| 101 |
+
# command = (command + " \\\n" +SSHInteractiveSession.end_comment + "\n")
|
| 102 |
+
# else:
|
| 103 |
+
command = command + "\n"
|
| 104 |
+
self.last_command = command.encode()
|
| 105 |
+
self.trimmed_command_length = 0
|
| 106 |
+
self.shell.send(self.last_command)
|
| 107 |
+
|
| 108 |
+
async def read_output(
|
| 109 |
+
self, timeout: float = 0, reset_full_output: bool = False
|
| 110 |
+
) -> Tuple[str, str]:
|
| 111 |
+
if not self.shell:
|
| 112 |
+
raise Exception("Shell not connected")
|
| 113 |
+
|
| 114 |
+
if reset_full_output:
|
| 115 |
+
self.full_output = b""
|
| 116 |
+
partial_output = b""
|
| 117 |
+
leftover = b""
|
| 118 |
+
start_time = time.time()
|
| 119 |
+
|
| 120 |
+
while self.shell.recv_ready() and (
|
| 121 |
+
timeout <= 0 or time.time() - start_time < timeout
|
| 122 |
+
):
|
| 123 |
+
|
| 124 |
+
# data = self.shell.recv(1024)
|
| 125 |
+
data = self.receive_bytes()
|
| 126 |
+
|
| 127 |
+
# # Trim own command from output
|
| 128 |
+
# if (
|
| 129 |
+
# self.last_command
|
| 130 |
+
# and len(self.last_command) > self.trimmed_command_length
|
| 131 |
+
# ):
|
| 132 |
+
# command_to_trim = self.last_command[self.trimmed_command_length :]
|
| 133 |
+
# data_to_trim = leftover + data
|
| 134 |
+
|
| 135 |
+
# trim_com, trim_out = calculate_valid_match_lengths(
|
| 136 |
+
# command_to_trim,
|
| 137 |
+
# data_to_trim,
|
| 138 |
+
# deviation_threshold=8,
|
| 139 |
+
# deviation_reset=2,
|
| 140 |
+
# ignore_patterns=[
|
| 141 |
+
# rb"\x1b\[\?\d{4}[a-zA-Z](?:> )?", # ANSI escape sequences
|
| 142 |
+
# rb"\r", # Carriage return
|
| 143 |
+
# rb">\s", # Greater-than symbol
|
| 144 |
+
# ],
|
| 145 |
+
# debug=False,
|
| 146 |
+
# )
|
| 147 |
+
|
| 148 |
+
# leftover = b""
|
| 149 |
+
# if trim_com > 0 and trim_out > 0:
|
| 150 |
+
# data = data_to_trim[trim_out:]
|
| 151 |
+
# leftover = data
|
| 152 |
+
# self.trimmed_command_length += trim_com
|
| 153 |
+
|
| 154 |
+
partial_output += data
|
| 155 |
+
self.full_output += data
|
| 156 |
+
await asyncio.sleep(0.1) # Prevent busy waiting
|
| 157 |
+
|
| 158 |
+
# Decode once at the end
|
| 159 |
+
decoded_partial_output = partial_output.decode("utf-8", errors="replace")
|
| 160 |
+
decoded_full_output = self.full_output.decode("utf-8", errors="replace")
|
| 161 |
+
|
| 162 |
+
decoded_partial_output = clean_string(decoded_partial_output)
|
| 163 |
+
decoded_full_output = clean_string(decoded_full_output)
|
| 164 |
+
|
| 165 |
+
return decoded_full_output, decoded_partial_output
|
| 166 |
+
|
| 167 |
+
def receive_bytes(self, num_bytes=1024):
|
| 168 |
+
if not self.shell:
|
| 169 |
+
raise Exception("Shell not connected")
|
| 170 |
+
# Receive initial chunk of data
|
| 171 |
+
shell = self.shell
|
| 172 |
+
data = self.shell.recv(num_bytes)
|
| 173 |
+
|
| 174 |
+
# Helper function to ensure that we receive exactly `num_bytes`
|
| 175 |
+
def recv_all(num_bytes):
|
| 176 |
+
data = b""
|
| 177 |
+
while len(data) < num_bytes:
|
| 178 |
+
chunk = shell.recv(num_bytes - len(data))
|
| 179 |
+
if not chunk:
|
| 180 |
+
break # Connection might be closed or no more data
|
| 181 |
+
data += chunk
|
| 182 |
+
return data
|
| 183 |
+
|
| 184 |
+
# Check if the last byte(s) form an incomplete multi-byte UTF-8 sequence
|
| 185 |
+
if len(data) > 0:
|
| 186 |
+
last_byte = data[-1]
|
| 187 |
+
|
| 188 |
+
# Check if the last byte is part of a multi-byte UTF-8 sequence (continuation byte)
|
| 189 |
+
if (last_byte & 0b11000000) == 0b10000000: # It's a continuation byte
|
| 190 |
+
# Now, find the start of this sequence by checking earlier bytes
|
| 191 |
+
for i in range(
|
| 192 |
+
2, 5
|
| 193 |
+
): # Look back up to 4 bytes (since UTF-8 is up to 4 bytes long)
|
| 194 |
+
if len(data) - i < 0:
|
| 195 |
+
break
|
| 196 |
+
byte = data[-i]
|
| 197 |
+
|
| 198 |
+
# Detect the leading byte of a multi-byte sequence
|
| 199 |
+
if (byte & 0b11100000) == 0b11000000: # 2-byte sequence (110xxxxx)
|
| 200 |
+
data += recv_all(1) # Need 1 more byte to complete
|
| 201 |
+
break
|
| 202 |
+
elif (
|
| 203 |
+
byte & 0b11110000
|
| 204 |
+
) == 0b11100000: # 3-byte sequence (1110xxxx)
|
| 205 |
+
data += recv_all(2) # Need 2 more bytes to complete
|
| 206 |
+
break
|
| 207 |
+
elif (
|
| 208 |
+
byte & 0b11111000
|
| 209 |
+
) == 0b11110000: # 4-byte sequence (11110xxx)
|
| 210 |
+
data += recv_all(3) # Need 3 more bytes to complete
|
| 211 |
+
break
|
| 212 |
+
|
| 213 |
+
return data
|
| 214 |
+
|
| 215 |
+
def clean_string(input_string):
|
| 216 |
+
# Remove ANSI escape codes
|
| 217 |
+
ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
| 218 |
+
cleaned = ansi_escape.sub("", input_string)
|
| 219 |
+
|
| 220 |
+
# remove null bytes
|
| 221 |
+
cleaned = cleaned.replace("\x00", "")
|
| 222 |
+
|
| 223 |
+
# remove ipython \r\r\n> sequences from the start
|
| 224 |
+
cleaned = re.sub(r'^[ \r]*(?:\r*\n>[ \r]*)*', '', cleaned)
|
| 225 |
+
# also remove any amount of '> ' sequences from the start
|
| 226 |
+
cleaned = re.sub(r'^(>\s*)+', '', cleaned)
|
| 227 |
+
|
| 228 |
+
# Replace '\r\n' with '\n'
|
| 229 |
+
cleaned = cleaned.replace("\r\n", "\n")
|
| 230 |
+
|
| 231 |
+
# remove leading \r and spaces
|
| 232 |
+
cleaned = cleaned.lstrip("\r ")
|
| 233 |
+
|
| 234 |
+
# Split the string by newline characters to process each segment separately
|
| 235 |
+
lines = cleaned.split("\n")
|
| 236 |
+
|
| 237 |
+
for i in range(len(lines)):
|
| 238 |
+
# Handle carriage returns '\r' by splitting and taking the last part
|
| 239 |
+
parts = [part for part in lines[i].split("\r") if part.strip()]
|
| 240 |
+
if parts:
|
| 241 |
+
lines[i] = parts[
|
| 242 |
+
-1
|
| 243 |
+
].rstrip() # Overwrite with the last part after the last '\r'
|
| 244 |
+
|
| 245 |
+
return "\n".join(lines)
|
plugins/_code_execution/helpers/tty_session.py
CHANGED
|
@@ -1,3 +1,327 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio, os, sys, platform, errno
|
| 2 |
+
|
| 3 |
+
_IS_WIN = platform.system() == "Windows"
|
| 4 |
+
if _IS_WIN:
|
| 5 |
+
import winpty # pip install pywinpty # type: ignore
|
| 6 |
+
import msvcrt
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
# Make stdin / stdout tolerant to broken UTF-8 so input() never aborts
|
| 10 |
+
sys.stdin.reconfigure(errors="replace") # type: ignore
|
| 11 |
+
sys.stdout.reconfigure(errors="replace") # type: ignore
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# ──────────────────────────── PUBLIC CLASS ────────────────────────────
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class TTYSession:
|
| 18 |
+
def __init__(self, cmd, *, cwd=None, env=None, encoding="utf-8", echo=False):
|
| 19 |
+
self.cmd = cmd if isinstance(cmd, str) else " ".join(cmd)
|
| 20 |
+
self.cwd = cwd
|
| 21 |
+
self.env = env or os.environ.copy()
|
| 22 |
+
self.encoding = encoding
|
| 23 |
+
self.echo = echo # ← store preference
|
| 24 |
+
self._proc = None
|
| 25 |
+
self._buf: asyncio.Queue = None # type: ignore
|
| 26 |
+
|
| 27 |
+
def __del__(self):
|
| 28 |
+
# Simple cleanup on object destruction
|
| 29 |
+
import nest_asyncio
|
| 30 |
+
|
| 31 |
+
nest_asyncio.apply()
|
| 32 |
+
if hasattr(self, "close"):
|
| 33 |
+
try:
|
| 34 |
+
asyncio.run(self.close())
|
| 35 |
+
except Exception:
|
| 36 |
+
pass
|
| 37 |
+
|
| 38 |
+
# ── user-facing coroutines ────────────────────────────────────────
|
| 39 |
+
async def start(self):
|
| 40 |
+
self._buf = asyncio.Queue()
|
| 41 |
+
if _IS_WIN:
|
| 42 |
+
self._proc = await _spawn_winpty(
|
| 43 |
+
self.cmd, self.cwd, self.env, self.echo
|
| 44 |
+
) # ← pass echo
|
| 45 |
+
else:
|
| 46 |
+
self._proc = await _spawn_posix_pty(
|
| 47 |
+
self.cmd, self.cwd, self.env, self.echo
|
| 48 |
+
) # ← pass echo
|
| 49 |
+
self._pump_task = asyncio.create_task(self._pump_stdout())
|
| 50 |
+
|
| 51 |
+
async def close(self):
|
| 52 |
+
# Cancel the pump task if it exists
|
| 53 |
+
if hasattr(self, "_pump_task") and self._pump_task:
|
| 54 |
+
self._pump_task.cancel()
|
| 55 |
+
try:
|
| 56 |
+
await self._pump_task
|
| 57 |
+
except asyncio.CancelledError:
|
| 58 |
+
pass
|
| 59 |
+
# Terminate the process if it exists
|
| 60 |
+
if self._proc:
|
| 61 |
+
self._proc.terminate()
|
| 62 |
+
await self._proc.wait()
|
| 63 |
+
self._proc = None
|
| 64 |
+
self._pump_task = None
|
| 65 |
+
|
| 66 |
+
async def send(self, data: str | bytes):
|
| 67 |
+
if self._proc is None:
|
| 68 |
+
raise RuntimeError("TTYSpawn is not started")
|
| 69 |
+
if isinstance(data, str):
|
| 70 |
+
data = data.encode(self.encoding)
|
| 71 |
+
self._proc.stdin.write(data) # type: ignore
|
| 72 |
+
await self._proc.stdin.drain() # type: ignore
|
| 73 |
+
|
| 74 |
+
async def sendline(self, line: str):
|
| 75 |
+
await self.send(line + "\n")
|
| 76 |
+
|
| 77 |
+
async def wait(self):
|
| 78 |
+
if self._proc is None:
|
| 79 |
+
raise RuntimeError("TTYSpawn is not started")
|
| 80 |
+
return await self._proc.wait()
|
| 81 |
+
|
| 82 |
+
def kill(self):
|
| 83 |
+
"""Force-kill the running child process.
|
| 84 |
+
|
| 85 |
+
This is best-effort: if the process has already terminated (which can
|
| 86 |
+
happen if *close()* was called elsewhere or the child exited by
|
| 87 |
+
itself) we silently ignore the *ProcessLookupError* raised by
|
| 88 |
+
*asyncio.subprocess.Process.kill()*. This prevents race conditions
|
| 89 |
+
where multiple coroutines attempt to close the same session.
|
| 90 |
+
"""
|
| 91 |
+
if self._proc is None:
|
| 92 |
+
# Already closed or never started – nothing to do
|
| 93 |
+
return
|
| 94 |
+
|
| 95 |
+
# Only attempt to kill if the process is still running
|
| 96 |
+
if getattr(self._proc, "returncode", None) is None:
|
| 97 |
+
try:
|
| 98 |
+
self._proc.kill()
|
| 99 |
+
except ProcessLookupError:
|
| 100 |
+
# Child already gone – treat as successfully killed
|
| 101 |
+
pass
|
| 102 |
+
|
| 103 |
+
async def read(self, timeout=None):
|
| 104 |
+
# Return any decoded text the child produced, or None on timeout
|
| 105 |
+
try:
|
| 106 |
+
return await asyncio.wait_for(self._buf.get(), timeout)
|
| 107 |
+
except asyncio.TimeoutError:
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
# backward-compat alias:
|
| 111 |
+
readline = read
|
| 112 |
+
|
| 113 |
+
async def read_full_until_idle(self, idle_timeout, total_timeout):
|
| 114 |
+
# Collect child output using iter_until_idle to avoid duplicate logic
|
| 115 |
+
return "".join(
|
| 116 |
+
[
|
| 117 |
+
chunk
|
| 118 |
+
async for chunk in self.read_chunks_until_idle(
|
| 119 |
+
idle_timeout, total_timeout
|
| 120 |
+
)
|
| 121 |
+
]
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
async def read_chunks_until_idle(self, idle_timeout, total_timeout):
|
| 125 |
+
# Yield each chunk as soon as it arrives until idle or total timeout
|
| 126 |
+
import time
|
| 127 |
+
|
| 128 |
+
start = time.monotonic()
|
| 129 |
+
while True:
|
| 130 |
+
if time.monotonic() - start > total_timeout:
|
| 131 |
+
break
|
| 132 |
+
chunk = await self.read(timeout=idle_timeout)
|
| 133 |
+
if chunk is None:
|
| 134 |
+
break
|
| 135 |
+
yield chunk
|
| 136 |
+
|
| 137 |
+
# ��─ internal: stream raw output into the queue ────────────────────
|
| 138 |
+
async def _pump_stdout(self):
|
| 139 |
+
if self._proc is None:
|
| 140 |
+
raise RuntimeError("TTYSpawn is not started")
|
| 141 |
+
reader = self._proc.stdout
|
| 142 |
+
while True:
|
| 143 |
+
chunk = await reader.read(4096) # grab whatever is ready # type: ignore
|
| 144 |
+
if not chunk:
|
| 145 |
+
break
|
| 146 |
+
self._buf.put_nowait(chunk.decode(self.encoding, "replace"))
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# ──────────────────────────── POSIX IMPLEMENTATION ────────────────────
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
async def _spawn_posix_pty(cmd, cwd, env, echo):
|
| 153 |
+
import pty, asyncio, os, termios
|
| 154 |
+
|
| 155 |
+
master, slave = pty.openpty()
|
| 156 |
+
|
| 157 |
+
# ── Disable ECHO on the slave side if requested ──
|
| 158 |
+
if not echo:
|
| 159 |
+
attrs = termios.tcgetattr(slave)
|
| 160 |
+
attrs[3] &= ~termios.ECHO # lflag
|
| 161 |
+
termios.tcsetattr(slave, termios.TCSANOW, attrs)
|
| 162 |
+
|
| 163 |
+
proc = await asyncio.create_subprocess_shell(
|
| 164 |
+
cmd,
|
| 165 |
+
stdin=slave,
|
| 166 |
+
stdout=slave,
|
| 167 |
+
stderr=slave,
|
| 168 |
+
cwd=cwd,
|
| 169 |
+
env=env,
|
| 170 |
+
close_fds=True,
|
| 171 |
+
)
|
| 172 |
+
os.close(slave)
|
| 173 |
+
|
| 174 |
+
loop = asyncio.get_running_loop()
|
| 175 |
+
reader = asyncio.StreamReader()
|
| 176 |
+
|
| 177 |
+
def _on_data():
|
| 178 |
+
try:
|
| 179 |
+
data = os.read(master, 1 << 16)
|
| 180 |
+
except OSError as e:
|
| 181 |
+
if e.errno != errno.EIO: # EIO == EOF on some systems
|
| 182 |
+
raise
|
| 183 |
+
data = b""
|
| 184 |
+
if data:
|
| 185 |
+
reader.feed_data(data)
|
| 186 |
+
else:
|
| 187 |
+
reader.feed_eof()
|
| 188 |
+
loop.remove_reader(master)
|
| 189 |
+
|
| 190 |
+
loop.add_reader(master, _on_data)
|
| 191 |
+
|
| 192 |
+
class _Stdin:
|
| 193 |
+
def write(self, d):
|
| 194 |
+
os.write(master, d)
|
| 195 |
+
|
| 196 |
+
async def drain(self):
|
| 197 |
+
await asyncio.sleep(0)
|
| 198 |
+
|
| 199 |
+
proc.stdin = _Stdin() # type: ignore
|
| 200 |
+
proc.stdout = reader
|
| 201 |
+
return proc
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# ──────────────────────────── WINDOWS IMPLEMENTATION ──────────────────
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
async def _spawn_winpty(cmd, cwd, env, echo):
|
| 208 |
+
# Clean PowerShell startup: no logo, no profile, bypass execution policy for deterministic behavior
|
| 209 |
+
if cmd.strip().lower().startswith("powershell"):
|
| 210 |
+
if "-nolog" not in cmd.lower():
|
| 211 |
+
cmd = cmd.replace("powershell.exe", "powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass", 1)
|
| 212 |
+
|
| 213 |
+
cols, rows = 80, 25
|
| 214 |
+
child = winpty.PtyProcess.spawn(cmd, dimensions=(rows, cols), cwd=cwd or os.getcwd(), env=env) # type: ignore
|
| 215 |
+
|
| 216 |
+
loop = asyncio.get_running_loop()
|
| 217 |
+
reader = asyncio.StreamReader()
|
| 218 |
+
|
| 219 |
+
async def _on_data():
|
| 220 |
+
while child.isalive():
|
| 221 |
+
try:
|
| 222 |
+
# Run blocking read in executor to not block event loop
|
| 223 |
+
data = await loop.run_in_executor(None, child.read, 1 << 16)
|
| 224 |
+
if data:
|
| 225 |
+
reader.feed_data(data.encode('utf-8') if isinstance(data, str) else data)
|
| 226 |
+
except EOFError:
|
| 227 |
+
break
|
| 228 |
+
except Exception:
|
| 229 |
+
await asyncio.sleep(0.01)
|
| 230 |
+
reader.feed_eof()
|
| 231 |
+
|
| 232 |
+
# Start pumping output in background
|
| 233 |
+
asyncio.create_task(_on_data())
|
| 234 |
+
|
| 235 |
+
class _Stdin:
|
| 236 |
+
def write(self, d):
|
| 237 |
+
# Use winpty's write method, not os.write
|
| 238 |
+
if isinstance(d, bytes):
|
| 239 |
+
d = d.decode('utf-8', errors='replace')
|
| 240 |
+
# Windows needs \r\n for proper line endings
|
| 241 |
+
if _IS_WIN:
|
| 242 |
+
d = d.replace('\n', '\r\n')
|
| 243 |
+
child.write(d)
|
| 244 |
+
|
| 245 |
+
async def drain(self):
|
| 246 |
+
await asyncio.sleep(0.01) # Give write time to complete
|
| 247 |
+
|
| 248 |
+
class _Proc:
|
| 249 |
+
def __init__(self):
|
| 250 |
+
self.stdin = _Stdin() # type: ignore
|
| 251 |
+
self.stdout = reader
|
| 252 |
+
self.pid = child.pid
|
| 253 |
+
self.returncode = None
|
| 254 |
+
|
| 255 |
+
async def wait(self):
|
| 256 |
+
while child.isalive():
|
| 257 |
+
await asyncio.sleep(0.2)
|
| 258 |
+
self.returncode = 0
|
| 259 |
+
return 0
|
| 260 |
+
|
| 261 |
+
def terminate(self):
|
| 262 |
+
if child.isalive():
|
| 263 |
+
child.terminate()
|
| 264 |
+
|
| 265 |
+
def kill(self):
|
| 266 |
+
if child.isalive():
|
| 267 |
+
child.kill()
|
| 268 |
+
|
| 269 |
+
return _Proc()
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
# ───────────────────────── INTERACTIVE DRIVER ─────────────────────────
|
| 273 |
+
if __name__ == "__main__":
|
| 274 |
+
|
| 275 |
+
async def interactive_shell():
|
| 276 |
+
shell_cmd, prompt_hint = ("powershell.exe", ">") if _IS_WIN else ("/bin/bash", "$")
|
| 277 |
+
|
| 278 |
+
# echo=False → suppress the shell’s own echo of commands
|
| 279 |
+
term = TTYSession(shell_cmd)
|
| 280 |
+
await term.start()
|
| 281 |
+
|
| 282 |
+
timeout = 1.0
|
| 283 |
+
|
| 284 |
+
print(f"Connected to {shell_cmd}.")
|
| 285 |
+
print("Type commands for the shell.")
|
| 286 |
+
print("• /t=<seconds> → change idle timeout")
|
| 287 |
+
print("• /exit → quit helper\n")
|
| 288 |
+
|
| 289 |
+
await term.sendline(" ")
|
| 290 |
+
print(await term.read_full_until_idle(timeout, timeout), end="", flush=True)
|
| 291 |
+
|
| 292 |
+
while True:
|
| 293 |
+
try:
|
| 294 |
+
user = input(f"(timeout={timeout}) {prompt_hint} ")
|
| 295 |
+
except (EOFError, KeyboardInterrupt):
|
| 296 |
+
print("\nLeaving…")
|
| 297 |
+
break
|
| 298 |
+
|
| 299 |
+
if user.lower() == "/exit":
|
| 300 |
+
break
|
| 301 |
+
if user.startswith("/t="):
|
| 302 |
+
try:
|
| 303 |
+
timeout = float(user.split("=", 1)[1])
|
| 304 |
+
print(f"[helper] idle timeout set to {timeout}s")
|
| 305 |
+
except ValueError:
|
| 306 |
+
print("[helper] invalid number")
|
| 307 |
+
continue
|
| 308 |
+
|
| 309 |
+
idle_timeout = timeout
|
| 310 |
+
total_timeout = 10 * idle_timeout
|
| 311 |
+
if user == "":
|
| 312 |
+
# Just read output, do not send empty line
|
| 313 |
+
async for chunk in term.read_chunks_until_idle(
|
| 314 |
+
idle_timeout, total_timeout
|
| 315 |
+
):
|
| 316 |
+
print(chunk, end="", flush=True)
|
| 317 |
+
else:
|
| 318 |
+
await term.sendline(user)
|
| 319 |
+
async for chunk in term.read_chunks_until_idle(
|
| 320 |
+
idle_timeout, total_timeout
|
| 321 |
+
):
|
| 322 |
+
print(chunk, end="", flush=True)
|
| 323 |
+
|
| 324 |
+
await term.sendline("exit")
|
| 325 |
+
await term.wait()
|
| 326 |
+
|
| 327 |
+
asyncio.run(interactive_shell())
|
plugins/_code_execution/prompts/agent.system.tool.code_exe.md
CHANGED
|
@@ -1,3 +1,90 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
### code_execution_tool
|
| 2 |
+
|
| 3 |
+
execute terminal commands python nodejs code for computation or software tasks
|
| 4 |
+
place code in "code" arg; escape carefully and indent properly
|
| 5 |
+
select "runtime" arg: "terminal" "python" "nodejs" "output"
|
| 6 |
+
select "session" number, 0 default, others for multitasking
|
| 7 |
+
if code runs long, use runtime "output" to wait
|
| 8 |
+
use argument reset true on next call to kill previous process when stuck default false
|
| 9 |
+
use "pip" "npm" "apt-get" in "terminal" to install package
|
| 10 |
+
to output, use print() or console.log()
|
| 11 |
+
if tool outputs error, adjust code before retrying;
|
| 12 |
+
important: check code for placeholders or demo data; replace with real variables; don't reuse snippets
|
| 13 |
+
don't use with other tools except thoughts; wait for response before using others
|
| 14 |
+
check dependencies before running code
|
| 15 |
+
output may end with [SYSTEM: ...] information comming from framework, not terminal
|
| 16 |
+
usage:
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
1 execute terminal command
|
| 20 |
+
~~~json
|
| 21 |
+
{
|
| 22 |
+
"thoughts": [
|
| 23 |
+
"Need to do...",
|
| 24 |
+
"Need to install...",
|
| 25 |
+
],
|
| 26 |
+
"headline": "Installing zip package via terminal",
|
| 27 |
+
"tool_name": "code_execution_tool",
|
| 28 |
+
"tool_args": {
|
| 29 |
+
"runtime": "terminal",
|
| 30 |
+
"session": 0,
|
| 31 |
+
"reset": false,
|
| 32 |
+
"code": "apt-get install zip",
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
~~~
|
| 36 |
+
|
| 37 |
+
2 execute python code
|
| 38 |
+
|
| 39 |
+
~~~json
|
| 40 |
+
{
|
| 41 |
+
"thoughts": [
|
| 42 |
+
"Need to do...",
|
| 43 |
+
"I can use...",
|
| 44 |
+
"Then I can...",
|
| 45 |
+
],
|
| 46 |
+
"headline": "Executing Python code to check current directory",
|
| 47 |
+
"tool_name": "code_execution_tool",
|
| 48 |
+
"tool_args": {
|
| 49 |
+
"runtime": "python",
|
| 50 |
+
"session": 0,
|
| 51 |
+
"reset": false,
|
| 52 |
+
"code": "import os\nprint(os.getcwd())",
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
~~~
|
| 56 |
+
|
| 57 |
+
3 execute nodejs code
|
| 58 |
+
|
| 59 |
+
~~~json
|
| 60 |
+
{
|
| 61 |
+
"thoughts": [
|
| 62 |
+
"Need to do...",
|
| 63 |
+
"I can use...",
|
| 64 |
+
"Then I can...",
|
| 65 |
+
],
|
| 66 |
+
"headline": "Executing Javascript code to check current directory",
|
| 67 |
+
"tool_name": "code_execution_tool",
|
| 68 |
+
"tool_args": {
|
| 69 |
+
"runtime": "nodejs",
|
| 70 |
+
"session": 0,
|
| 71 |
+
"reset": false,
|
| 72 |
+
"code": "console.log(process.cwd());",
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
~~~
|
| 76 |
+
|
| 77 |
+
4 wait for output with long-running scripts
|
| 78 |
+
~~~json
|
| 79 |
+
{
|
| 80 |
+
"thoughts": [
|
| 81 |
+
"Waiting for program to finish...",
|
| 82 |
+
],
|
| 83 |
+
"headline": "Waiting for long-running program to complete",
|
| 84 |
+
"tool_name": "code_execution_tool",
|
| 85 |
+
"tool_args": {
|
| 86 |
+
"runtime": "output",
|
| 87 |
+
"session": 0,
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
~~~
|
plugins/_code_execution/prompts/agent.system.tool.input.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
### input:
|
| 2 |
+
use keyboard arg for terminal program input
|
| 3 |
+
use session arg for terminal session number
|
| 4 |
+
answer dialogues enter passwords etc
|
| 5 |
+
not for browser
|
| 6 |
+
usage:
|
| 7 |
+
~~~json
|
| 8 |
+
{
|
| 9 |
+
"thoughts": [
|
| 10 |
+
"The program asks for Y/N...",
|
| 11 |
+
],
|
| 12 |
+
"headline": "Responding to terminal program prompt",
|
| 13 |
+
"tool_name": "input",
|
| 14 |
+
"tool_args": {
|
| 15 |
+
"keyboard": "Y",
|
| 16 |
+
"session": 0
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
~~~
|
plugins/_code_execution/prompts/fw.code.info.md
CHANGED
|
@@ -1,3 +1 @@
|
|
| 1 |
-
|
| 2 |
-
oid sha256:86cb4510f6e254650e620035019d818c1683a62ab7be829f478c9719f41f270a
|
| 3 |
-
size 19
|
|
|
|
| 1 |
+
[SYSTEM: {{info}}]
|
|
|
|
|
|
plugins/_code_execution/prompts/fw.code.max_time.md
CHANGED
|
@@ -1,3 +1 @@
|
|
| 1 |
-
|
| 2 |
-
oid sha256:5edd5cf7c1532bc68375b0af2b8da3d989e7634a3b6505313efed50faaea85b5
|
| 3 |
-
size 197
|
|
|
|
| 1 |
+
Returning control to agent after {{timeout}} seconds of execution. Process might be still running. Check previous outputs and decide whether to reset and continue or wait for more output is needed.
|
|
|
|
|
|
plugins/_code_execution/prompts/fw.code.no_out_time.md
CHANGED
|
@@ -1,3 +1 @@
|
|
| 1 |
-
|
| 2 |
-
oid sha256:126186f7a0ad8c32b61fd21e3d349f44afc96ee7882c9e3f72e69a5ad73ea367
|
| 3 |
-
size 199
|
|
|
|
| 1 |
+
Returning control to agent after {{timeout}} seconds with no output. Process might be still running. Check previous outputs and decide whether to reset and continue or wait for more output is needed.
|
|
|
|
|
|
plugins/_code_execution/prompts/fw.code.no_output.md
CHANGED
|
@@ -1,3 +1 @@
|
|
| 1 |
-
|
| 2 |
-
oid sha256:f3a7c967352c802ac7ecbdbf7a28e351edf7fc55af9c79a42c6e45058ff50db5
|
| 3 |
-
size 77
|
|
|
|
| 1 |
+
No output returned. Consider resetting the terminal or using another session.
|
|
|
|
|
|
plugins/_code_execution/prompts/fw.code.pause_dialog.md
CHANGED
|
@@ -1,3 +1 @@
|
|
| 1 |
-
|
| 2 |
-
oid sha256:d0ccd1b01ea8a38a6cc6bc13b368be83dce8474c531def8c7838b8448c682239
|
| 3 |
-
size 243
|
|
|
|
| 1 |
+
Potential dialog detected in output. Returning control to agent after {{timeout}} seconds since last output update. Decide whether dialog actually occurred and needs to be addressed, or if it was just a false positive and wait for more output.
|
|
|
|
|
|
plugins/_code_execution/prompts/fw.code.pause_time.md
CHANGED
|
@@ -1,3 +1 @@
|
|
| 1 |
-
|
| 2 |
-
oid sha256:d8897c73603b1e84a815ae0669016c5dfb2b330782f60f904004a98a3b572ed9
|
| 3 |
-
size 209
|
|
|
|
| 1 |
+
Returning control to agent after {{timeout}} seconds since last output update. Process might be still running. Check previous outputs and decide whether to reset and continue or wait for more output is needed.
|
|
|
|
|
|
plugins/_code_execution/prompts/fw.code.reset.md
CHANGED
|
@@ -1,3 +1 @@
|
|
| 1 |
-
|
| 2 |
-
oid sha256:f54cb95ab01cb3b50a13af927ea7875d7add5f0590a3f95ced1430531c435271
|
| 3 |
-
size 32
|
|
|
|
| 1 |
+
Terminal session has been reset.
|
|
|
|
|
|
plugins/_code_execution/prompts/fw.code.running.md
CHANGED
|
@@ -1,3 +1 @@
|
|
| 1 |
-
|
| 2 |
-
oid sha256:a70c57139bc315622a2b8d86b0a3f9e8316d15bf944e7a5c5c516cd7d6faef45
|
| 3 |
-
size 151
|
|
|
|
| 1 |
+
Terminal session {{session}} might be still running. Check previous outputs and decide whether to reset and continue or wait for more output is needed.
|
|
|
|
|
|
plugins/_code_execution/prompts/fw.code.runtime_wrong.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
~~~json
|
| 2 |
+
{
|
| 3 |
+
"system_warning": "The runtime '{{runtime}}' is not supported, available options are 'terminal', 'python', 'nodejs' and 'output'."
|
| 4 |
+
}
|
| 5 |
+
~~~
|
plugins/_code_execution/tools/code_execution_tool.py
CHANGED
|
@@ -1,3 +1,529 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
import re
|
| 4 |
+
import shlex
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
from helpers.tool import Tool, Response
|
| 8 |
+
from helpers import files, rfc_exchange, projects, runtime, secrets, settings
|
| 9 |
+
from helpers.print_style import PrintStyle
|
| 10 |
+
from helpers.strings import truncate_text as truncate_text_string
|
| 11 |
+
from helpers.messages import truncate_text as truncate_text_agent
|
| 12 |
+
from helpers import plugins
|
| 13 |
+
|
| 14 |
+
from plugins._code_execution.helpers.shell_local import LocalInteractiveSession
|
| 15 |
+
from plugins._code_execution.helpers.shell_ssh import SSHInteractiveSession
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class ShellWrap:
|
| 20 |
+
id: int
|
| 21 |
+
session: LocalInteractiveSession | SSHInteractiveSession
|
| 22 |
+
running: bool
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class State:
|
| 27 |
+
ssh_enabled: bool
|
| 28 |
+
shells: dict[int, ShellWrap]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class CodeExecution(Tool):
|
| 32 |
+
|
| 33 |
+
async def execute(self, **kwargs) -> Response:
|
| 34 |
+
|
| 35 |
+
await self.agent.handle_intervention() # wait for intervention and handle it, if paused
|
| 36 |
+
|
| 37 |
+
runtime_arg = self.args.get("runtime", "").lower().strip()
|
| 38 |
+
session = int(self.args.get("session", 0))
|
| 39 |
+
self.allow_running = bool(self.args.get("allow_running", False))
|
| 40 |
+
reset = bool(self.args.get("reset", False) or runtime_arg == "reset")
|
| 41 |
+
|
| 42 |
+
cfg = _get_config(self.agent)
|
| 43 |
+
|
| 44 |
+
if runtime_arg == "python":
|
| 45 |
+
response = await self.execute_python_code(
|
| 46 |
+
cfg, code=self.args["code"], session=session, reset=reset
|
| 47 |
+
)
|
| 48 |
+
elif runtime_arg == "nodejs":
|
| 49 |
+
response = await self.execute_nodejs_code(
|
| 50 |
+
cfg, code=self.args["code"], session=session, reset=reset
|
| 51 |
+
)
|
| 52 |
+
elif runtime_arg == "terminal":
|
| 53 |
+
response = await self.execute_terminal_command(
|
| 54 |
+
cfg, command=self.args["code"], session=session, reset=reset
|
| 55 |
+
)
|
| 56 |
+
elif runtime_arg == "output":
|
| 57 |
+
response = await self.get_terminal_output(
|
| 58 |
+
cfg, session=session, timeouts=cfg["output_timeouts"]
|
| 59 |
+
)
|
| 60 |
+
elif runtime_arg == "reset":
|
| 61 |
+
response = await self.reset_terminal(cfg, session=session)
|
| 62 |
+
else:
|
| 63 |
+
response = self.agent.read_prompt(
|
| 64 |
+
"fw.code.runtime_wrong.md", runtime=runtime_arg
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
if not response:
|
| 68 |
+
response = self.agent.read_prompt(
|
| 69 |
+
"fw.code.info.md", info=self.agent.read_prompt("fw.code.no_output.md")
|
| 70 |
+
)
|
| 71 |
+
return Response(message=response, break_loop=False)
|
| 72 |
+
|
| 73 |
+
def get_log_object(self):
|
| 74 |
+
import uuid
|
| 75 |
+
return self.agent.context.log.log(
|
| 76 |
+
type="code_exe",
|
| 77 |
+
heading=self.get_heading(),
|
| 78 |
+
content="",
|
| 79 |
+
kvps=self.args,
|
| 80 |
+
id=str(uuid.uuid4()),
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
def get_heading(self, text: str = ""):
|
| 84 |
+
if not text:
|
| 85 |
+
text = f"{self.name} - {self.args['runtime'] if 'runtime' in self.args else 'unknown'}"
|
| 86 |
+
session = self.args.get("session", None)
|
| 87 |
+
session_text = f"[{session}] " if session or session == 0 else ""
|
| 88 |
+
return f"icon://terminal {session_text}{truncate_text_string(text, 200)}"
|
| 89 |
+
|
| 90 |
+
async def after_execution(self, response, **kwargs):
|
| 91 |
+
self.agent.hist_add_tool_result(self.name, response.message, id=self.log.id if self.log else "", **(response.additional or {}))
|
| 92 |
+
|
| 93 |
+
async def prepare_state(self, cfg: dict, reset=False, session: int | None = None):
|
| 94 |
+
self.state: State | None = self.agent.get_data("_cet_state")
|
| 95 |
+
ssh_enabled = cfg["ssh_enabled"]
|
| 96 |
+
|
| 97 |
+
# always reset state when ssh_enabled changes
|
| 98 |
+
if not self.state or self.state.ssh_enabled != ssh_enabled:
|
| 99 |
+
shells: dict[int, ShellWrap] = {}
|
| 100 |
+
else:
|
| 101 |
+
shells = self.state.shells.copy()
|
| 102 |
+
|
| 103 |
+
# Only reset the specified session if provided
|
| 104 |
+
if reset and session is not None and session in shells:
|
| 105 |
+
await shells[session].session.close()
|
| 106 |
+
del shells[session]
|
| 107 |
+
elif reset and not session:
|
| 108 |
+
# Close all sessions if full reset requested
|
| 109 |
+
for s in list(shells.keys()):
|
| 110 |
+
await shells[s].session.close()
|
| 111 |
+
shells = {}
|
| 112 |
+
|
| 113 |
+
# initialize local or remote interactive shell interface for session if needed
|
| 114 |
+
if session is not None and session not in shells:
|
| 115 |
+
cwd = await self.ensure_cwd()
|
| 116 |
+
if ssh_enabled:
|
| 117 |
+
ssh_pass = await _resolve_ssh_pass(cfg["ssh_pass"])
|
| 118 |
+
shell = SSHInteractiveSession(
|
| 119 |
+
self.agent.context.log,
|
| 120 |
+
cfg["ssh_addr"],
|
| 121 |
+
cfg["ssh_port"],
|
| 122 |
+
cfg["ssh_user"],
|
| 123 |
+
ssh_pass,
|
| 124 |
+
cwd=cwd,
|
| 125 |
+
)
|
| 126 |
+
else:
|
| 127 |
+
shell = LocalInteractiveSession(cwd=cwd)
|
| 128 |
+
|
| 129 |
+
shells[session] = ShellWrap(id=session, session=shell, running=False)
|
| 130 |
+
await shell.connect()
|
| 131 |
+
|
| 132 |
+
self.state = State(shells=shells, ssh_enabled=ssh_enabled)
|
| 133 |
+
self.agent.set_data("_cet_state", self.state)
|
| 134 |
+
return self.state
|
| 135 |
+
|
| 136 |
+
async def execute_python_code(self, cfg: dict, session: int, code: str, reset: bool = False):
|
| 137 |
+
escaped_code = shlex.quote(code)
|
| 138 |
+
command = f"ipython -c {escaped_code}"
|
| 139 |
+
prefix = "python> " + self.format_command_for_output(code) + "\n\n"
|
| 140 |
+
return await self.terminal_session(cfg, session, command, reset, prefix)
|
| 141 |
+
|
| 142 |
+
async def execute_nodejs_code(self, cfg: dict, session: int, code: str, reset: bool = False):
|
| 143 |
+
escaped_code = shlex.quote(code)
|
| 144 |
+
command = f"node /exe/node_eval.js {escaped_code}"
|
| 145 |
+
prefix = "node> " + self.format_command_for_output(code) + "\n\n"
|
| 146 |
+
return await self.terminal_session(cfg, session, command, reset, prefix)
|
| 147 |
+
|
| 148 |
+
async def execute_terminal_command(
|
| 149 |
+
self, cfg: dict, session: int, command: str, reset: bool = False
|
| 150 |
+
):
|
| 151 |
+
prefix = (
|
| 152 |
+
("bash>" if not runtime.is_windows() or cfg["ssh_enabled"] else "PS>")
|
| 153 |
+
+ self.format_command_for_output(command)
|
| 154 |
+
+ "\n\n"
|
| 155 |
+
)
|
| 156 |
+
return await self.terminal_session(cfg, session, command, reset, prefix)
|
| 157 |
+
|
| 158 |
+
async def terminal_session(
|
| 159 |
+
self, cfg: dict, session: int, command: str, reset: bool = False, prefix: str = "", timeouts: dict | None = None
|
| 160 |
+
):
|
| 161 |
+
self.state = await self.prepare_state(cfg, reset=reset, session=session)
|
| 162 |
+
|
| 163 |
+
await self.agent.handle_intervention() # wait for intervention and handle it, if paused
|
| 164 |
+
|
| 165 |
+
# Check if session is running and handle it
|
| 166 |
+
if not self.allow_running:
|
| 167 |
+
if response := await self.handle_running_session(cfg, session):
|
| 168 |
+
return response
|
| 169 |
+
|
| 170 |
+
# try again on lost connection
|
| 171 |
+
for i in range(2):
|
| 172 |
+
try:
|
| 173 |
+
self.state.shells[session].running = True
|
| 174 |
+
await self.state.shells[session].session.send_command(command)
|
| 175 |
+
|
| 176 |
+
locl = (
|
| 177 |
+
" (local)"
|
| 178 |
+
if isinstance(self.state.shells[session].session, LocalInteractiveSession)
|
| 179 |
+
else (
|
| 180 |
+
" (remote)"
|
| 181 |
+
if isinstance(self.state.shells[session].session, SSHInteractiveSession)
|
| 182 |
+
else " (unknown)"
|
| 183 |
+
)
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
PrintStyle(
|
| 187 |
+
background_color="white", font_color="#1B4F72", bold=True
|
| 188 |
+
).print(f"{self.agent.agent_name} code execution output{locl}")
|
| 189 |
+
return await self.get_terminal_output(
|
| 190 |
+
cfg,
|
| 191 |
+
session=session,
|
| 192 |
+
prefix=prefix,
|
| 193 |
+
timeouts=(timeouts or cfg["code_exec_timeouts"]),
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
except Exception as e:
|
| 197 |
+
if i == 1:
|
| 198 |
+
PrintStyle.error(str(e))
|
| 199 |
+
await self.prepare_state(cfg, reset=True, session=session)
|
| 200 |
+
continue
|
| 201 |
+
else:
|
| 202 |
+
raise e
|
| 203 |
+
|
| 204 |
+
def format_command_for_output(self, command: str):
|
| 205 |
+
short_cmd = command[:250]
|
| 206 |
+
short_cmd = " ".join(short_cmd.split())
|
| 207 |
+
short_cmd = secrets.get_secrets_manager(self.agent.context).mask_values(short_cmd)
|
| 208 |
+
short_cmd = truncate_text_string(short_cmd, 100)
|
| 209 |
+
return f"{short_cmd}"
|
| 210 |
+
|
| 211 |
+
async def get_terminal_output(
|
| 212 |
+
self,
|
| 213 |
+
cfg: dict,
|
| 214 |
+
session=0,
|
| 215 |
+
reset_full_output=True,
|
| 216 |
+
first_output_timeout=30,
|
| 217 |
+
between_output_timeout=15,
|
| 218 |
+
dialog_timeout=5,
|
| 219 |
+
max_exec_timeout=180,
|
| 220 |
+
sleep_time=0.5,
|
| 221 |
+
prefix="",
|
| 222 |
+
timeouts: dict | None = None,
|
| 223 |
+
):
|
| 224 |
+
self.state = await self.prepare_state(cfg, session=session)
|
| 225 |
+
|
| 226 |
+
# Override timeouts if a dict is provided
|
| 227 |
+
if timeouts:
|
| 228 |
+
first_output_timeout = timeouts.get("first_output_timeout", first_output_timeout)
|
| 229 |
+
between_output_timeout = timeouts.get("between_output_timeout", between_output_timeout)
|
| 230 |
+
dialog_timeout = timeouts.get("dialog_timeout", dialog_timeout)
|
| 231 |
+
max_exec_timeout = timeouts.get("max_exec_timeout", max_exec_timeout)
|
| 232 |
+
|
| 233 |
+
prompt_patterns = cfg["prompt_patterns"]
|
| 234 |
+
dialog_patterns = cfg["dialog_patterns"]
|
| 235 |
+
|
| 236 |
+
start_time = time.time()
|
| 237 |
+
last_output_time = start_time
|
| 238 |
+
full_output = ""
|
| 239 |
+
truncated_output = ""
|
| 240 |
+
got_output = False
|
| 241 |
+
|
| 242 |
+
# if prefix, log right away
|
| 243 |
+
if prefix:
|
| 244 |
+
self.log.update(content=prefix)
|
| 245 |
+
|
| 246 |
+
while True:
|
| 247 |
+
await asyncio.sleep(sleep_time)
|
| 248 |
+
full_output, partial_output = await self.state.shells[session].session.read_output(
|
| 249 |
+
timeout=1, reset_full_output=reset_full_output
|
| 250 |
+
)
|
| 251 |
+
reset_full_output = False # only reset once
|
| 252 |
+
|
| 253 |
+
await self.agent.handle_intervention()
|
| 254 |
+
|
| 255 |
+
now = time.time()
|
| 256 |
+
if partial_output:
|
| 257 |
+
PrintStyle(font_color="#85C1E9").stream(partial_output)
|
| 258 |
+
truncated_output = self.fix_full_output(full_output)
|
| 259 |
+
self.set_progress(truncated_output)
|
| 260 |
+
heading = self.get_heading_from_output(truncated_output, 0)
|
| 261 |
+
self.log.update(content=prefix + truncated_output, heading=heading)
|
| 262 |
+
last_output_time = now
|
| 263 |
+
got_output = True
|
| 264 |
+
|
| 265 |
+
# Check for shell prompt at the end of output
|
| 266 |
+
last_lines = (
|
| 267 |
+
truncated_output.splitlines()[-3:] if truncated_output else []
|
| 268 |
+
)
|
| 269 |
+
last_lines.reverse()
|
| 270 |
+
for idx, line in enumerate(last_lines):
|
| 271 |
+
line = line.strip()
|
| 272 |
+
line = line if len(line) <= 500 else line[:250] + line[-250:] # only check start and end on long lines
|
| 273 |
+
for pat in prompt_patterns:
|
| 274 |
+
if pat.search(line):
|
| 275 |
+
PrintStyle.info(
|
| 276 |
+
"Detected shell prompt, returning output early."
|
| 277 |
+
)
|
| 278 |
+
last_lines.reverse()
|
| 279 |
+
heading = self.get_heading_from_output(
|
| 280 |
+
"\n".join(last_lines), idx + 1, True
|
| 281 |
+
)
|
| 282 |
+
self.log.update(heading=heading)
|
| 283 |
+
self.mark_session_idle(session)
|
| 284 |
+
return truncated_output
|
| 285 |
+
|
| 286 |
+
# Check for max execution time
|
| 287 |
+
if now - start_time > max_exec_timeout:
|
| 288 |
+
sysinfo = self.agent.read_prompt(
|
| 289 |
+
"fw.code.max_time.md", timeout=max_exec_timeout
|
| 290 |
+
)
|
| 291 |
+
response = self.agent.read_prompt("fw.code.info.md", info=sysinfo)
|
| 292 |
+
if truncated_output:
|
| 293 |
+
response = truncated_output + "\n\n" + response
|
| 294 |
+
PrintStyle.warning(sysinfo)
|
| 295 |
+
heading = self.get_heading_from_output(truncated_output, 0)
|
| 296 |
+
self.log.update(content=prefix + response, heading=heading)
|
| 297 |
+
return response
|
| 298 |
+
|
| 299 |
+
# Waiting for first output
|
| 300 |
+
if not got_output:
|
| 301 |
+
if now - start_time > first_output_timeout:
|
| 302 |
+
sysinfo = self.agent.read_prompt(
|
| 303 |
+
"fw.code.no_out_time.md", timeout=first_output_timeout
|
| 304 |
+
)
|
| 305 |
+
response = self.agent.read_prompt("fw.code.info.md", info=sysinfo)
|
| 306 |
+
PrintStyle.warning(sysinfo)
|
| 307 |
+
self.log.update(content=prefix + response)
|
| 308 |
+
return response
|
| 309 |
+
else:
|
| 310 |
+
# Waiting for more output after first output
|
| 311 |
+
if now - last_output_time > between_output_timeout:
|
| 312 |
+
sysinfo = self.agent.read_prompt(
|
| 313 |
+
"fw.code.pause_time.md", timeout=between_output_timeout
|
| 314 |
+
)
|
| 315 |
+
response = self.agent.read_prompt("fw.code.info.md", info=sysinfo)
|
| 316 |
+
if truncated_output:
|
| 317 |
+
response = truncated_output + "\n\n" + response
|
| 318 |
+
PrintStyle.warning(sysinfo)
|
| 319 |
+
heading = self.get_heading_from_output(truncated_output, 0)
|
| 320 |
+
self.log.update(content=prefix + response, heading=heading)
|
| 321 |
+
return response
|
| 322 |
+
|
| 323 |
+
# potential dialog detection
|
| 324 |
+
if now - last_output_time > dialog_timeout:
|
| 325 |
+
last_lines = (
|
| 326 |
+
truncated_output.splitlines()[-2:] if truncated_output else []
|
| 327 |
+
)
|
| 328 |
+
for line in last_lines:
|
| 329 |
+
for pat in dialog_patterns:
|
| 330 |
+
if pat.search(line.strip()):
|
| 331 |
+
PrintStyle.info(
|
| 332 |
+
"Detected dialog prompt, returning output early."
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
sysinfo = self.agent.read_prompt(
|
| 336 |
+
"fw.code.pause_dialog.md", timeout=dialog_timeout
|
| 337 |
+
)
|
| 338 |
+
response = self.agent.read_prompt(
|
| 339 |
+
"fw.code.info.md", info=sysinfo
|
| 340 |
+
)
|
| 341 |
+
if truncated_output:
|
| 342 |
+
response = truncated_output + "\n\n" + response
|
| 343 |
+
PrintStyle.warning(sysinfo)
|
| 344 |
+
heading = self.get_heading_from_output(
|
| 345 |
+
truncated_output, 0
|
| 346 |
+
)
|
| 347 |
+
self.log.update(
|
| 348 |
+
content=prefix + response, heading=heading
|
| 349 |
+
)
|
| 350 |
+
return response
|
| 351 |
+
|
| 352 |
+
async def handle_running_session(
|
| 353 |
+
self,
|
| 354 |
+
cfg: dict,
|
| 355 |
+
session=0,
|
| 356 |
+
reset_full_output=True,
|
| 357 |
+
prefix=""
|
| 358 |
+
):
|
| 359 |
+
if not self.state or session not in self.state.shells:
|
| 360 |
+
return None
|
| 361 |
+
if not self.state.shells[session].running:
|
| 362 |
+
return None
|
| 363 |
+
|
| 364 |
+
prompt_patterns = cfg["prompt_patterns"]
|
| 365 |
+
dialog_patterns = cfg["dialog_patterns"]
|
| 366 |
+
|
| 367 |
+
full_output, _ = await self.state.shells[session].session.read_output(
|
| 368 |
+
timeout=1, reset_full_output=reset_full_output
|
| 369 |
+
)
|
| 370 |
+
truncated_output = self.fix_full_output(full_output)
|
| 371 |
+
self.set_progress(truncated_output)
|
| 372 |
+
heading = self.get_heading_from_output(truncated_output, 0)
|
| 373 |
+
|
| 374 |
+
last_lines = (
|
| 375 |
+
truncated_output.splitlines()[-3:] if truncated_output else []
|
| 376 |
+
)
|
| 377 |
+
last_lines.reverse()
|
| 378 |
+
for line in last_lines:
|
| 379 |
+
for pat in prompt_patterns:
|
| 380 |
+
if pat.search(line.strip()):
|
| 381 |
+
PrintStyle.info(
|
| 382 |
+
"Detected shell prompt, returning output early."
|
| 383 |
+
)
|
| 384 |
+
self.mark_session_idle(session)
|
| 385 |
+
return None
|
| 386 |
+
|
| 387 |
+
has_dialog = False
|
| 388 |
+
for line in last_lines:
|
| 389 |
+
for pat in dialog_patterns:
|
| 390 |
+
if pat.search(line.strip()):
|
| 391 |
+
has_dialog = True
|
| 392 |
+
break
|
| 393 |
+
if has_dialog:
|
| 394 |
+
break
|
| 395 |
+
|
| 396 |
+
if has_dialog:
|
| 397 |
+
sys_info = self.agent.read_prompt("fw.code.pause_dialog.md", timeout=1)
|
| 398 |
+
else:
|
| 399 |
+
sys_info = self.agent.read_prompt("fw.code.running.md", session=session)
|
| 400 |
+
|
| 401 |
+
response = self.agent.read_prompt("fw.code.info.md", info=sys_info)
|
| 402 |
+
if truncated_output:
|
| 403 |
+
response = truncated_output + "\n\n" + response
|
| 404 |
+
PrintStyle(font_color="#FFA500", bold=True).print(response)
|
| 405 |
+
self.log.update(content=prefix + response, heading=heading)
|
| 406 |
+
return response
|
| 407 |
+
|
| 408 |
+
def mark_session_idle(self, session: int = 0):
|
| 409 |
+
if self.state and session in self.state.shells:
|
| 410 |
+
self.state.shells[session].running = False
|
| 411 |
+
|
| 412 |
+
async def reset_terminal(self, cfg: dict, session=0, reason: str | None = None):
|
| 413 |
+
if reason:
|
| 414 |
+
PrintStyle(font_color="#FFA500", bold=True).print(
|
| 415 |
+
f"Resetting terminal session {session}... Reason: {reason}"
|
| 416 |
+
)
|
| 417 |
+
else:
|
| 418 |
+
PrintStyle(font_color="#FFA500", bold=True).print(
|
| 419 |
+
f"Resetting terminal session {session}..."
|
| 420 |
+
)
|
| 421 |
+
|
| 422 |
+
await self.prepare_state(cfg, reset=True, session=session)
|
| 423 |
+
response = self.agent.read_prompt(
|
| 424 |
+
"fw.code.info.md", info=self.agent.read_prompt("fw.code.reset.md")
|
| 425 |
+
)
|
| 426 |
+
self.log.update(content=response)
|
| 427 |
+
return response
|
| 428 |
+
|
| 429 |
+
def get_heading_from_output(self, output: str, skip_lines=0, done=False):
|
| 430 |
+
done_icon = " icon://done_all" if done else ""
|
| 431 |
+
|
| 432 |
+
if not output:
|
| 433 |
+
return self.get_heading() + done_icon
|
| 434 |
+
|
| 435 |
+
lines = output.splitlines()
|
| 436 |
+
for i in range(len(lines) - skip_lines - 1, -1, -1):
|
| 437 |
+
line = lines[i].strip()
|
| 438 |
+
if not line:
|
| 439 |
+
continue
|
| 440 |
+
return self.get_heading(line) + done_icon
|
| 441 |
+
|
| 442 |
+
return self.get_heading() + done_icon
|
| 443 |
+
|
| 444 |
+
def fix_full_output(self, output: str):
|
| 445 |
+
output = re.sub(r"(?<!\\)\\x[0-9A-Fa-f]{2}", "", output)
|
| 446 |
+
output = truncate_text_agent(agent=self.agent, output=output, threshold=1000000)
|
| 447 |
+
return output
|
| 448 |
+
|
| 449 |
+
async def ensure_cwd(self) -> str | None:
|
| 450 |
+
project_name = projects.get_context_project_name(self.agent.context)
|
| 451 |
+
if project_name:
|
| 452 |
+
path = projects.get_project_folder(project_name)
|
| 453 |
+
else:
|
| 454 |
+
set = settings.get_settings()
|
| 455 |
+
path = set.get("workdir_path")
|
| 456 |
+
|
| 457 |
+
if not path:
|
| 458 |
+
return None
|
| 459 |
+
|
| 460 |
+
normalized = files.normalize_a0_path(path)
|
| 461 |
+
await runtime.call_development_function(make_dir, normalized)
|
| 462 |
+
return normalized
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
# ------------------------------------------------------------------
|
| 466 |
+
# Internal
|
| 467 |
+
# ------------------------------------------------------------------
|
| 468 |
+
|
| 469 |
+
def _resolve_ssh_enabled(raw_value) -> bool:
|
| 470 |
+
val = str(raw_value).strip().lower()
|
| 471 |
+
if val == "auto":
|
| 472 |
+
return not runtime.is_dockerized()
|
| 473 |
+
return val in ("true", "1", "yes", "on")
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def _resolve_ssh_addr(cfg_addr: str) -> str:
|
| 477 |
+
if cfg_addr:
|
| 478 |
+
return cfg_addr
|
| 479 |
+
set = settings.get_settings()
|
| 480 |
+
host = set.get("rfc_url", "localhost")
|
| 481 |
+
if "//" in host:
|
| 482 |
+
host = host.split("//")[1]
|
| 483 |
+
if ":" in host:
|
| 484 |
+
host = host.split(":")[0]
|
| 485 |
+
if host.endswith("/"):
|
| 486 |
+
host = host.rstrip("/")
|
| 487 |
+
return host or "localhost"
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
async def _resolve_ssh_pass(cfg_pass: str) -> str:
|
| 491 |
+
if cfg_pass:
|
| 492 |
+
return cfg_pass
|
| 493 |
+
return await rfc_exchange.get_root_password()
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
def _parse_patterns(raw, flags=0) -> list[re.Pattern]:
|
| 497 |
+
lines = [str(p) for p in raw] if isinstance(raw, list) else str(raw).splitlines()
|
| 498 |
+
return [re.compile(p.strip(), flags) for p in lines if p.strip()]
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
_TIMEOUT_KEYS = ("first_output_timeout", "between_output_timeout", "max_exec_timeout", "dialog_timeout")
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
def _parse_timeouts(cfg: dict, prefix: str, defaults: tuple[int, ...]) -> dict:
|
| 505 |
+
return {
|
| 506 |
+
key: int(cfg.get(f"{prefix}_{key}", default))
|
| 507 |
+
for key, default in zip(_TIMEOUT_KEYS, defaults)
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
def _get_config(agent) -> dict:
|
| 512 |
+
cfg = plugins.get_plugin_config("_code_execution", agent=agent) or {}
|
| 513 |
+
|
| 514 |
+
return {
|
| 515 |
+
"ssh_enabled": _resolve_ssh_enabled(cfg.get("ssh_enabled", "auto")),
|
| 516 |
+
"ssh_addr": _resolve_ssh_addr(str(cfg.get("ssh_addr", ""))),
|
| 517 |
+
"ssh_port": int(cfg.get("ssh_port", 55022)),
|
| 518 |
+
"ssh_user": str(cfg.get("ssh_user", "root")),
|
| 519 |
+
"ssh_pass": str(cfg.get("ssh_pass", "")),
|
| 520 |
+
"code_exec_timeouts": _parse_timeouts(cfg, "code_exec", (30, 15, 180, 5)),
|
| 521 |
+
"output_timeouts": _parse_timeouts(cfg, "output", (90, 45, 300, 5)),
|
| 522 |
+
"prompt_patterns": _parse_patterns(cfg.get("prompt_patterns", "")),
|
| 523 |
+
"dialog_patterns": _parse_patterns(cfg.get("dialog_patterns", ""), re.IGNORECASE),
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
|
| 527 |
+
def make_dir(path: str):
|
| 528 |
+
import os
|
| 529 |
+
os.makedirs(path, exist_ok=True)
|
plugins/_code_execution/tools/input.py
CHANGED
|
@@ -1,3 +1,26 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from helpers.tool import Tool, Response
|
| 2 |
+
from plugins._code_execution.tools.code_execution_tool import CodeExecution
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class Input(Tool):
|
| 6 |
+
|
| 7 |
+
async def execute(self, keyboard="", **kwargs):
|
| 8 |
+
# normalize keyboard input
|
| 9 |
+
keyboard = keyboard.rstrip()
|
| 10 |
+
# keyboard += "\n" # no need to, code_exec does that
|
| 11 |
+
|
| 12 |
+
# terminal session number
|
| 13 |
+
session = int(self.args.get("session", 0))
|
| 14 |
+
|
| 15 |
+
# forward keyboard input to code execution tool
|
| 16 |
+
args = {"runtime": "terminal", "code": keyboard, "session": session, "allow_running": True}
|
| 17 |
+
cet = CodeExecution(self.agent, "code_execution_tool", "", args, self.message, self.loop_data)
|
| 18 |
+
cet.log = self.log
|
| 19 |
+
return await cet.execute(**args)
|
| 20 |
+
|
| 21 |
+
def get_log_object(self):
|
| 22 |
+
import uuid
|
| 23 |
+
return self.agent.context.log.log(type="code_exe", heading=f"icon://keyboard {self.agent.agent_name}: Using tool '{self.name}'", content="", kvps=self.args, id=str(uuid.uuid4()))
|
| 24 |
+
|
| 25 |
+
async def after_execution(self, response, **kwargs):
|
| 26 |
+
self.agent.hist_add_tool_result(self.name, response.message, id=self.log.id if self.log else "", **(response.additional or {}))
|
plugins/_code_execution/webui/config.html
CHANGED
|
@@ -1,3 +1,204 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<html>
|
| 2 |
+
<head>
|
| 3 |
+
<title>Code Execution</title>
|
| 4 |
+
</head>
|
| 5 |
+
|
| 6 |
+
<body>
|
| 7 |
+
<div x-data>
|
| 8 |
+
<template x-if="config">
|
| 9 |
+
<div>
|
| 10 |
+
<div class="section-title">Code Execution</div>
|
| 11 |
+
<div class="section-description">
|
| 12 |
+
Configuration for the code execution tool. Controls the shell interface, SSH credentials, execution timeouts, and prompt/dialog detection patterns.
|
| 13 |
+
</div>
|
| 14 |
+
|
| 15 |
+
<!-- Shell Interface -->
|
| 16 |
+
<div class="field">
|
| 17 |
+
<div class="field-label">
|
| 18 |
+
<div class="field-title">Shell Interface</div>
|
| 19 |
+
<div class="field-description">
|
| 20 |
+
<b>Auto</b>: SSH when running outside Docker, local TTY when dockerized.
|
| 21 |
+
<b>SSH</b>: always connect via SSH. <b>Local</b>: always use local PTY.
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
<div class="field-control">
|
| 25 |
+
<select x-model="config.ssh_enabled">
|
| 26 |
+
<option value="auto">Auto</option>
|
| 27 |
+
<option value="true">SSH</option>
|
| 28 |
+
<option value="false">Local TTY</option>
|
| 29 |
+
</select>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
<!-- SSH credentials (shown when SSH or Auto) -->
|
| 34 |
+
<template x-if="config.ssh_enabled !== 'false'">
|
| 35 |
+
<div>
|
| 36 |
+
<div class="field">
|
| 37 |
+
<div class="field-label">
|
| 38 |
+
<div class="field-title">SSH Address</div>
|
| 39 |
+
<div class="field-description">Hostname or IP of the SSH target. Leave empty to use the RFC URL from settings.</div>
|
| 40 |
+
</div>
|
| 41 |
+
<div class="field-control">
|
| 42 |
+
<input type="text" x-model="config.ssh_addr" placeholder="(from RFC URL)" />
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<div class="field">
|
| 47 |
+
<div class="field-label">
|
| 48 |
+
<div class="field-title">SSH Port</div>
|
| 49 |
+
<div class="field-description">SSH port on the target host (default: 55022 for Docker RFC).</div>
|
| 50 |
+
</div>
|
| 51 |
+
<div class="field-control">
|
| 52 |
+
<input type="number" min="1" max="65535" x-model.number="config.ssh_port" />
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<div class="field">
|
| 57 |
+
<div class="field-label">
|
| 58 |
+
<div class="field-title">SSH User</div>
|
| 59 |
+
<div class="field-description">Username for SSH login.</div>
|
| 60 |
+
</div>
|
| 61 |
+
<div class="field-control">
|
| 62 |
+
<input type="text" x-model="config.ssh_user" placeholder="root" />
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
<div class="field">
|
| 67 |
+
<div class="field-label">
|
| 68 |
+
<div class="field-title">SSH Password</div>
|
| 69 |
+
<div class="field-description">Password for SSH login. Leave empty to use the dev root password (via RFC).</div>
|
| 70 |
+
</div>
|
| 71 |
+
<div class="field-control">
|
| 72 |
+
<input type="password" autocomplete="off" x-model="config.ssh_pass" placeholder="(from RFC)" />
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
</template>
|
| 77 |
+
|
| 78 |
+
<!-- Execution timeouts -->
|
| 79 |
+
<div class="section-title">Execution Timeouts</div>
|
| 80 |
+
<div class="section-description">
|
| 81 |
+
Timeouts (in seconds) for python, nodejs, and terminal runtimes.
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<div class="field">
|
| 85 |
+
<div class="field-label">
|
| 86 |
+
<div class="field-title">First output timeout</div>
|
| 87 |
+
<div class="field-description">Seconds to wait for the first output before returning control to the agent.</div>
|
| 88 |
+
</div>
|
| 89 |
+
<div class="field-control">
|
| 90 |
+
<input type="number" min="1" x-model.number="config.code_exec_first_output_timeout" />
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<div class="field">
|
| 95 |
+
<div class="field-label">
|
| 96 |
+
<div class="field-title">Between output timeout</div>
|
| 97 |
+
<div class="field-description">Seconds to wait between output chunks before returning control.</div>
|
| 98 |
+
</div>
|
| 99 |
+
<div class="field-control">
|
| 100 |
+
<input type="number" min="1" x-model.number="config.code_exec_between_output_timeout" />
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
<div class="field">
|
| 105 |
+
<div class="field-label">
|
| 106 |
+
<div class="field-title">Max execution timeout</div>
|
| 107 |
+
<div class="field-description">Hard cap on total execution time in seconds.</div>
|
| 108 |
+
</div>
|
| 109 |
+
<div class="field-control">
|
| 110 |
+
<input type="number" min="1" x-model.number="config.code_exec_max_exec_timeout" />
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
<div class="field">
|
| 115 |
+
<div class="field-label">
|
| 116 |
+
<div class="field-title">Dialog detection timeout</div>
|
| 117 |
+
<div class="field-description">Seconds of idle output before checking for dialog prompts.</div>
|
| 118 |
+
</div>
|
| 119 |
+
<div class="field-control">
|
| 120 |
+
<input type="number" min="1" x-model.number="config.code_exec_dialog_timeout" />
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
<!-- Output runtime timeouts -->
|
| 125 |
+
<div class="section-title">Output Runtime Timeouts</div>
|
| 126 |
+
<div class="section-description">
|
| 127 |
+
Timeouts (in seconds) for the "output" runtime (waiting for long-running processes).
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
+
<div class="field">
|
| 131 |
+
<div class="field-label">
|
| 132 |
+
<div class="field-title">First output timeout</div>
|
| 133 |
+
<div class="field-description">Seconds to wait for the first output.</div>
|
| 134 |
+
</div>
|
| 135 |
+
<div class="field-control">
|
| 136 |
+
<input type="number" min="1" x-model.number="config.output_first_output_timeout" />
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
|
| 140 |
+
<div class="field">
|
| 141 |
+
<div class="field-label">
|
| 142 |
+
<div class="field-title">Between output timeout</div>
|
| 143 |
+
<div class="field-description">Seconds to wait between output chunks.</div>
|
| 144 |
+
</div>
|
| 145 |
+
<div class="field-control">
|
| 146 |
+
<input type="number" min="1" x-model.number="config.output_between_output_timeout" />
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
<div class="field">
|
| 151 |
+
<div class="field-label">
|
| 152 |
+
<div class="field-title">Max execution timeout</div>
|
| 153 |
+
<div class="field-description">Hard cap on total wait time in seconds.</div>
|
| 154 |
+
</div>
|
| 155 |
+
<div class="field-control">
|
| 156 |
+
<input type="number" min="1" x-model.number="config.output_max_exec_timeout" />
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
|
| 160 |
+
<div class="field">
|
| 161 |
+
<div class="field-label">
|
| 162 |
+
<div class="field-title">Dialog detection timeout</div>
|
| 163 |
+
<div class="field-description">Seconds of idle output before checking for dialog prompts.</div>
|
| 164 |
+
</div>
|
| 165 |
+
<div class="field-control">
|
| 166 |
+
<input type="number" min="1" x-model.number="config.output_dialog_timeout" />
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
|
| 170 |
+
<!-- Patterns -->
|
| 171 |
+
<div class="section-title">Detection Patterns</div>
|
| 172 |
+
<div class="section-description">
|
| 173 |
+
Regular expressions used to detect shell prompts and interactive dialogs. One pattern per line.
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
<div class="field">
|
| 177 |
+
<div class="field-label">
|
| 178 |
+
<div class="field-title">Prompt patterns</div>
|
| 179 |
+
<div class="field-description">
|
| 180 |
+
When any pattern matches the last output lines, execution is considered complete and output is returned immediately.
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
<div class="field-control">
|
| 184 |
+
<textarea rows="5" x-model="config.prompt_patterns"></textarea>
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
|
| 188 |
+
<div class="field">
|
| 189 |
+
<div class="field-label">
|
| 190 |
+
<div class="field-title">Dialog patterns</div>
|
| 191 |
+
<div class="field-description">
|
| 192 |
+
When any pattern matches after the dialog timeout, control is returned to the agent to handle the interactive prompt.
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
<div class="field-control">
|
| 196 |
+
<textarea rows="4" x-model="config.dialog_patterns"></textarea>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
</div>
|
| 200 |
+
</template>
|
| 201 |
+
</div>
|
| 202 |
+
</body>
|
| 203 |
+
|
| 204 |
+
</html>
|
plugins/_code_execution/webui/thumbnail.jpg
CHANGED
|
|
Git LFS Details
|
|
|