GraziePrego commited on
Commit
7d4338a
·
verified ·
1 Parent(s): 3dfb537

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. plugins/README.md +100 -3
  2. plugins/_browser_agent/api/status.py +45 -3
  3. plugins/_browser_agent/assets/init_override.js +246 -3
  4. plugins/_browser_agent/extensions/python/_functions/agent/Agent/get_browser_model/start/_10_browser_agent.py +7 -3
  5. plugins/_browser_agent/extensions/webui/get_message_handler/browser-agent-handler.js +54 -3
  6. plugins/_browser_agent/extensions/webui/get_tool_message_handler/browser-tool-handler.js +15 -3
  7. plugins/_browser_agent/helpers/__init__.py +1 -3
  8. plugins/_browser_agent/helpers/browser_llm.py +161 -3
  9. plugins/_browser_agent/helpers/browser_use.py +4 -3
  10. plugins/_browser_agent/helpers/browser_use_monkeypatch.py +166 -3
  11. plugins/_browser_agent/helpers/browser_use_openrouter_compat.py +93 -3
  12. plugins/_browser_agent/helpers/browser_use_output_sanitize.py +79 -3
  13. plugins/_browser_agent/helpers/playwright.py +38 -3
  14. plugins/_browser_agent/prompts/agent.system.tool.browser.md +36 -3
  15. plugins/_browser_agent/prompts/browser_agent.system.md +22 -3
  16. plugins/_browser_agent/tools/browser_agent.py +440 -3
  17. plugins/_browser_agent/webui/main.html +204 -3
  18. plugins/_browser_agent/webui/thumbnail.jpg +0 -0
  19. plugins/_chat_branching/README.md +34 -3
  20. plugins/_chat_branching/api/branch_chat.py +147 -3
  21. plugins/_chat_branching/extensions/webui/set_messages_after_loop/inject-branch-buttons.js +32 -3
  22. plugins/_chat_branching/webui/thumbnail.jpg +0 -0
  23. plugins/_chat_compaction/api/compact_chat.py +68 -3
  24. plugins/_chat_compaction/extensions/webui/chat-input-bottom-actions-start/compact-button.html +373 -3
  25. plugins/_chat_compaction/helpers/compactor.py +279 -3
  26. plugins/_chat_compaction/prompts/compact.msg.md +5 -3
  27. plugins/_chat_compaction/prompts/compact.sys.md +17 -3
  28. plugins/_chat_compaction/webui/compact-modal.html +390 -3
  29. plugins/_chat_compaction/webui/compact-store.js +102 -3
  30. plugins/_chat_compaction/webui/thumbnail.png +0 -0
  31. plugins/_code_execution/README.md +52 -3
  32. plugins/_code_execution/extensions/webui/get_message_handler/code-exe-handler.js +83 -3
  33. plugins/_code_execution/helpers/shell_local.py +50 -3
  34. plugins/_code_execution/helpers/shell_ssh.py +245 -3
  35. plugins/_code_execution/helpers/tty_session.py +327 -3
  36. plugins/_code_execution/prompts/agent.system.tool.code_exe.md +90 -3
  37. plugins/_code_execution/prompts/agent.system.tool.input.md +19 -3
  38. plugins/_code_execution/prompts/fw.code.info.md +1 -3
  39. plugins/_code_execution/prompts/fw.code.max_time.md +1 -3
  40. plugins/_code_execution/prompts/fw.code.no_out_time.md +1 -3
  41. plugins/_code_execution/prompts/fw.code.no_output.md +1 -3
  42. plugins/_code_execution/prompts/fw.code.pause_dialog.md +1 -3
  43. plugins/_code_execution/prompts/fw.code.pause_time.md +1 -3
  44. plugins/_code_execution/prompts/fw.code.reset.md +1 -3
  45. plugins/_code_execution/prompts/fw.code.running.md +1 -3
  46. plugins/_code_execution/prompts/fw.code.runtime_wrong.md +5 -3
  47. plugins/_code_execution/tools/code_execution_tool.py +529 -3
  48. plugins/_code_execution/tools/input.py +26 -3
  49. plugins/_code_execution/webui/config.html +204 -3
  50. plugins/_code_execution/webui/thumbnail.jpg +0 -0
plugins/README.md CHANGED
@@ -1,3 +1,100 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:5599dcd901336087fc051149679b2c3fb06597e772f5499d5ffa2bb33aeb7cb6
3
- size 5518
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:5c4568b75cc9d3c0c68288561ec86449f3f7e6e6a1fbed62e84aecc55091a2c1
3
- size 1505
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:da9eb5f18422450feecc7c09d9b939a181200f26bdbeaf89c04f1b40354c7e8f
3
- size 8188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:42a4626301eaca52005cb53e87a23169ca42d5d4f213d81c0a864720d9a0d94d
3
- size 309
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:c22405e621a86ed6eb85c5ddbe9d71c22b8924be1a585b0022a40ff06c0cef0e
3
- size 1405
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:3bb4dd47a0699cc349b0e34fb359a9778029debacdfcc931cbf37e6c746675b4
3
- size 426
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:393f441aa6440c49ab3921b30da6e292d66f9f9aed6e8a2f32c67136c6621cc0
3
- size 5784
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:cc02396489ff037c197c5c1e41ce5d20ff2d4fbb6cd3de0f610a9da0ad33423b
3
- size 129
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:83a19ee087245c183741f66aaeb2707b07668ac908a0392da7d0dfa8c782fd9e
3
- size 7104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:1c65868269ab351da34f84e55950d57646573c41b12cfc6384eb02e215e4cf48
3
- size 3188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:64fad384c412068a0686b7f3f2edebf22abf50d22c285a449a00ce67a3f5d021
3
- size 2463
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:9033100aa4946beeaa7639a0c748e4112111e7f76fbad9b82422f9a6100e2093
3
- size 1114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:8ff07181170e42a9b3000d1e7c1db59541c63e3fefceece2345a5772033720da
3
- size 960
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:c2c7c6cca53369be2c55c044d56e95d4368f28e8cb14656076c0115324e4d05b
3
- size 1368
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:39588c30a192976b3d109d24a658c6d3f02274f730cbbd156e9ef874cec31881
3
- size 17422
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:bb93c8c7a5f6da7d4e6f6e3d8905b17fe819381ec1cb0049b318d1575724a61a
3
- size 6527
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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

  • SHA256: 86625df2bc0d47fb3feeaaf0d4c2ae35735b133cdaebbf36065cb3e1cd29404d
  • Pointer size: 129 Bytes
  • Size of remote file: 9.98 kB
plugins/_chat_branching/README.md CHANGED
@@ -1,3 +1,34 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:286f79718ec1ef677d03637b30b0026bd6259c3750ff33b53cd49b1243eeda36
3
- size 1339
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:13e2e7056e13a6ece481ec03977d6f49dc6ce99213bc7523a62255fb92096773
3
- size 4949
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:fddb9e71e3fd42ddd846a0e58eb89c50fdffc55b5179dc1a8af4371497672e07
3
- size 1310
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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

  • SHA256: 21b3650bf394e68ab4d4ce6a04b22c55c9a103703ed3d66a4c994afd10ce9b90
  • Pointer size: 129 Bytes
  • Size of remote file: 6.82 kB
plugins/_chat_compaction/api/compact_chat.py CHANGED
@@ -1,3 +1,68 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:c574e9d5b24c884a6c70e3dd85aa9410e91d2fba3233abde7717b3df4eb88d44
3
- size 2372
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:2fb0c6475f412d4c090fb67d91310daa55661cf9416fa181a487a190a2c06ac4
3
- size 10762
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:728fed13fe891abdba7993dfa3d9ed4ba9fc3bf0011aba5ce6ed401cbc33c030
3
- size 10002
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:575fd7aaef3fc532c2fd8fd5d2eae4b2b43bbe759e4ca733ba7d3fbd3b030c1f
3
- size 98
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:35ae816202ae6f060afd545782baf375eea270260c4695b1bf3b59fa3daa320e
3
- size 775
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:c5eb81d99f422416e3fec848569fbae1ef4ad7544d724a936eb8e3e58f186aa5
3
- size 11136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:a2031a79da17f12e36a7eaac46d6e37e37b51c12d26fd56bccbb8ec6baaf52a8
3
- size 2643
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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

  • SHA256: 28cddbf7931915f0d3e971382c3588e914f840b112cbc0037b0551c682a0e790
  • Pointer size: 129 Bytes
  • Size of remote file: 3.4 kB
plugins/_code_execution/README.md CHANGED
@@ -1,3 +1,52 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:faca9815e268bc1a4c654621462862b684b65af665791767f4cf4a022f54deab
3
- size 2062
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:13d11b422fac7d6a16b18e9e251197a9414213edaeb70ccacec1b3dc138a0f5a
3
- size 2071
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:7234c968b0ca1e3548b4d475fc9087e28cd5f6b2605ee399680a893c2fa7c65a
3
- size 1717
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:0eb7cefe138a50cd6779a47588b9c0850e3547c79b39b4553e9bfa8760219cf7
3
- size 9253
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:5a35ea20df84f5bbd39b865887bd82d355b0e2dafdc48d11e8434cf8610f78db
3
- size 11152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:597f009da6348e27526ea5d3f5b6971ae6586ccb0e32cc71e9bedb46123ecbb0
3
- size 2353
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:6ed84ad8a01177d24b4cdcaee7d5033a1c4598ee0c159af642de1f0dfa28ed8d
3
- size 393
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:86cb4510f6e254650e620035019d818c1683a62ab7be829f478c9719f41f270a
3
- size 19
 
1
+ [SYSTEM: {{info}}]
 
 
plugins/_code_execution/prompts/fw.code.max_time.md CHANGED
@@ -1,3 +1 @@
1
- version https://git-lfs.github.com/spec/v1
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
- version https://git-lfs.github.com/spec/v1
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
- version https://git-lfs.github.com/spec/v1
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
- version https://git-lfs.github.com/spec/v1
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
- version https://git-lfs.github.com/spec/v1
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
- version https://git-lfs.github.com/spec/v1
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
- version https://git-lfs.github.com/spec/v1
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:6342fa28a06e91c09968d0f5ddc28b9ab590d77a40ad61c5ca203c4f0411bfde
3
- size 150
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:1f1377b42af094abe31558cb4675f5fa1aae52a8a9efc3c1873b2439fe9c7982
3
- size 21057
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:9df378bf0ab4be63b9561310f7c3d12d39092a602ed069154a96a729fc5fe204
3
- size 1176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:f54f4dcc7da0133df7ad98297d492a5ae20a8ee34fa7f7baa2746631bcfd1f88
3
- size 10012
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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

  • SHA256: c81c5c2acf5f2f66b59d7129eeaf022f7bedc96448f491e9257a4af17eb614bd
  • Pointer size: 129 Bytes
  • Size of remote file: 7.36 kB