vision prototype, terminal sessions
Vision functionality, vision_load tool History.py re-work to support and optimize attachments token usage Code execution tool support for multiple sessions in parallel
frdel committed
Apr 1, 2025 at 13:55 UTC
8a9de4a2ee334fc34d6a71673273c7272308480b
20 files changed
+493
-210
agent.py
+42
-33
@@ -10,7 +10,12 @@ import models
10
from langchain_core.prompt_values import ChatPromptValue
11
from python.helpers import extract_tools, rate_limiter, files, errors, history, tokens
12
from python.helpers.print_style import PrintStyle
13
-from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder, HumanMessagePromptTemplate, StringPromptTemplate
13
+from langchain_core.prompts import (
14
+ ChatPromptTemplate,
15
+ MessagesPlaceholder,
16
+ HumanMessagePromptTemplate,
17
+ StringPromptTemplate,
18
+)
19
from langchain_core.prompts.image import ImagePromptTemplate
20
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage, BaseMessage
21
from langchain_core.language_models.chat_models import BaseChatModel
@@ -91,7 +96,7 @@ class AgentContext:
96
else:
97
current_agent = self.agent0
98
94
- self.task =self.run_task(current_agent.monologue)
99
+ self.task = self.run_task(current_agent.monologue)
100
return self.task
101
102
def communicate(self, msg: "UserMessage", broadcast_level: int = 1):
@@ -130,9 +135,9 @@ class AgentContext:
135
async def _process_chain(self, agent: "Agent", msg: "UserMessage|str", user=True):
136
try:
137
msg_template = (
133
- await agent.hist_add_user_message(msg) # type: ignore
138
+ agent.hist_add_user_message(msg) # type: ignore
139
if user
135
- else await agent.hist_add_tool_result(
140
+ else agent.hist_add_tool_result(
141
tool_name="call_subordinate", tool_result=msg # type: ignore
142
)
143
)
@@ -283,9 +288,6 @@ class Agent:
288
printer.stream(chunk)
289
self.log_from_stream(full, log)
290
286
- # store as last context window content
287
- self.set_data(Agent.DATA_NAME_CTX_WINDOW, prompt.format())
288
-
291
agent_response = await self.call_chat_model(
292
prompt, callback=stream_callback
293
)
@@ -296,10 +298,10 @@ class Agent:
298
self.loop_data.last_response == agent_response
299
): # if assistant_response is the same as last message in history, let him know
300
# Append the assistant's response to the history
299
- await self.hist_add_ai_response(agent_response)
301
+ self.hist_add_ai_response(agent_response)
302
# Append warning message to the history
303
warning_msg = self.read_prompt("fw.msg_repeat.md")
302
- await self.hist_add_warning(message=warning_msg)
304
+ self.hist_add_warning(message=warning_msg)
305
PrintStyle(font_color="orange", padding=True).print(
306
warning_msg
307
)
@@ -307,7 +309,7 @@ class Agent:
309
310
else: # otherwise proceed with tool
311
# Append the assistant's response to the history
310
- await self.hist_add_ai_response(agent_response)
312
+ self.hist_add_ai_response(agent_response)
313
# process tools requested in agent message
314
tools_result = await self.process_tools(agent_response)
315
if tools_result: # final response of message loop available
@@ -319,7 +321,7 @@ class Agent:
321
except RepairableException as e:
322
# Forward repairable errors to the LLM, maybe it can fix them
323
error_message = errors.format_error(e)
322
- await self.hist_add_warning(error_message)
324
+ self.hist_add_warning(error_message)
325
PrintStyle(font_color="red", padding=True).print(error_message)
326
self.context.log.log(type="error", content=error_message)
327
except Exception as e:
@@ -358,23 +360,31 @@ class Agent:
360
extras += history.Message(False, content=extra).output()
361
loop_data.extras_temporary.clear()
362
361
- # combine history and extras
362
- history_combined: list[OutputMessage] = history.group_outputs_abab(loop_data.history_output + extras)
363
-
364
- # convert history to LLM format
365
- history_langchain: list[BaseMessage] = history.output_langchain(history_combined)
366
-
367
- PrintStyle(font_color="grey", background_color="black", bold=True, padding=True).print(
368
- f"History Langchain: {history_langchain}"
363
+ # convert history + extras to LLM format
364
+ history_langchain: list[BaseMessage] = history.output_langchain(
365
+ loop_data.history_output + extras
366
)
367
368
# build chain from system prompt, message history and model
369
+ system_text = "\n\n".join(loop_data.system)
370
prompt = ChatPromptTemplate.from_messages(
371
[
374
- SystemMessage(content="\n\n".join(loop_data.system)),
372
+ SystemMessage(content=system_text),
373
*history_langchain,
374
]
375
)
376
+
377
+ # store as last context window content
378
+ self.set_data(
379
+ Agent.DATA_NAME_CTX_WINDOW,
380
+ {
381
+ "text": prompt.format(),
382
+ "tokens": self.history.get_tokens()
383
+ + tokens.approximate_tokens(system_text)
384
+ + tokens.approximate_tokens(history.output_text(extras)),
385
+ },
386
+ )
387
+
388
return prompt
389
390
def handle_critical_exception(self, exception: Exception):
@@ -441,12 +451,12 @@ class Agent:
451
def set_data(self, field: str, value):
452
self.data[field] = value
453
444
- def hist_add_message(self, ai: bool, content: history.MessageContent):
445
- return self.history.add_message(ai=ai, content=content)
446
-
447
- async def hist_add_user_message(
448
- self, message: UserMessage, intervention: bool = False
454
+ def hist_add_message(
455
+ self, ai: bool, content: history.MessageContent, tokens: int = 0
456
):
457
+ return self.history.add_message(ai=ai, content=content, tokens=tokens)
458
+
459
+ def hist_add_user_message(self, message: UserMessage, intervention: bool = False):
460
self.history.new_topic() # user message starts a new topic in history
461
462
# load message template based on intervention
@@ -476,19 +486,18 @@ class Agent:
486
self.last_user_message = msg
487
return msg
488
479
- async def hist_add_ai_response(self, message: str):
489
+ def hist_add_ai_response(self, message: str):
490
self.loop_data.last_response = message
491
content = self.parse_prompt("fw.ai_response.md", message=message)
492
return self.hist_add_message(True, content=content)
493
484
- async def hist_add_warning(self, message: history.MessageContent):
494
+ def hist_add_warning(self, message: history.MessageContent):
495
content = self.parse_prompt("fw.warning.md", message=message)
496
return self.hist_add_message(False, content=content)
497
488
- async def hist_add_tool_result(self, tool_name: str, tool_result: str, attachments: list[str] = []):
489
- attachments_str = json.dumps(attachments).replace("\n", "")
498
+ def hist_add_tool_result(self, tool_name: str, tool_result: str):
499
content = self.parse_prompt(
491
- "fw.tool_result.md", tool_name=tool_name, tool_result=tool_result, attachments=attachments_str
500
+ "fw.tool_result.md", tool_name=tool_name, tool_result=tool_result
501
)
502
return self.hist_add_message(False, content=content)
503
@@ -620,9 +629,9 @@ class Agent:
629
msg = self.intervention
630
self.intervention = None # reset the intervention message
631
if progress.strip():
623
- await self.hist_add_ai_response(progress)
632
+ self.hist_add_ai_response(progress)
633
# append the intervention message
625
- await self.hist_add_user_message(msg, intervention=True)
634
+ self.hist_add_user_message(msg, intervention=True)
635
raise InterventionException(msg)
636
637
async def wait_if_paused(self):
@@ -649,7 +658,7 @@ class Agent:
658
return response.message
659
else:
660
msg = self.read_prompt("fw.msg_misformat.md")
652
- await self.hist_add_warning(msg)
661
+ self.hist_add_warning(msg)
662
PrintStyle(font_color="red", padding=True).print(msg)
663
self.context.log.log(
664
type="error", content=f"{self.agent_name}: Message misformat"
prompts/default/agent.system.instruments.md
+3
-2
@@ -1,4 +1,5 @@
1
# Instruments
2
-- following are instruments at disposal:
2
+- following are instruments at disposal
3
+- do not overly rely on them they might not be relevant
4
4
-{{instruments}}
\ No newline at end of file
5
+{{instruments}}
prompts/default/agent.system.memories.md
+2
-1
@@ -1,4 +1,5 @@
1
# Memories on the topic
2
-- following are your memories about current topic:
2
+- following are memories about current topic
3
+- do not overly rely on them they might not be relevant
4
5
{{memories}}
\ No newline at end of file
prompts/default/agent.system.solutions.md
+2
-1
@@ -1,4 +1,5 @@
1
# Solutions from the past
2
-- following are your memories about successful solutions of related problems:
2
+- following are memories about successful solutions of related problems
3
+- do not overly rely on them they might not be relevant
4
5
{{solutions}}
\ No newline at end of file
prompts/default/agent.system.tool.code_exe.md
+5
-2
@@ -3,10 +3,9 @@
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" "reset"
6
-for dialogues (Y/N etc.), use "terminal" runtime next step, send answer
6
+select "session" number, 0 default, others for multitasking
7
if code runs long, use "output" to wait, "reset" to kill process
8
use "pip" "npm" "apt-get" in "terminal" to install packages
9
-important: never use implicit print/output—it doesn't work!
9
to output, use print() or console.log()
10
if tool outputs error, adjust code before retrying; knowledge_tool can help
11
important: check code for placeholders or demo data; replace with real variables; don't reuse snippets
@@ -26,6 +25,7 @@ usage:
25
"tool_name": "code_execution_tool",
26
"tool_args": {
27
"runtime": "python",
28
+ "session": 0,
29
"code": "import os\nprint(os.getcwd())",
30
}
31
}
@@ -41,6 +41,7 @@ usage:
41
"tool_name": "code_execution_tool",
42
"tool_args": {
43
"runtime": "terminal",
44
+ "session": 0,
45
"code": "apt-get install zip",
46
}
47
}
@@ -55,6 +56,7 @@ usage:
56
"tool_name": "code_execution_tool",
57
"tool_args": {
58
"runtime": "output",
59
+ "session": 0,
60
}
61
}
62
~~~
@@ -68,6 +70,7 @@ usage:
70
"tool_name": "code_execution_tool",
71
"tool_args": {
72
"runtime": "reset",
73
+ "session": 0,
74
}
75
}
76
~~~
\ No newline at end of file
prompts/default/agent.system.tools_vision.md
+16
-1
@@ -1,3 +1,18 @@
1
## "Multimodal (Vision) Agent Tools" available:
2
3
-None yet. In future, this section will contain vision-only tools
3
+### vision_load:
4
+load image data to LLM
5
+use paths arg for attachments
6
+
7
+**Example usage**:
8
+```json
9
+{
10
+ "thoughts": [
11
+ "I need to see the image...",
12
+ ],
13
+ "tool_name": "vision_load",
14
+ "tool_args": {
15
+ "paths": ["/path/to/image.png"],
16
+ }
17
+}
18
+```
\ No newline at end of file
prompts/default/fw.tool_result.md
+3
-4
@@ -1,7 +1,6 @@
1
-~~~json
1
+```json
2
{
3
"tool_name": {{tool_name}},
4
- "tool_result": {{tool_result}},
5
- "attachments": {{attachments}}
4
+ "tool_result": {{tool_result}}
5
}
7
-~~~
6
+```
python/api/ctx_window_get.py
+6
-2
@@ -9,6 +9,10 @@ class GetCtxWindow(ApiHandler):
9
context = self.get_context(ctxid)
10
agent = context.streaming_agent or context.agent0
11
window = agent.get_data(agent.DATA_NAME_CTX_WINDOW)
12
- size = tokens.approximate_tokens(window)
12
+ if not window or not isinstance(window, dict):
13
+ return {"content": "", "tokens": 0}
14
14
- return {"content": window, "tokens": size}
15
+ text = window["text"]
16
+ tokens = window["tokens"]
17
+
18
+ return {"content": text, "tokens": tokens}
python/api/history_get.py
+2
-2
@@ -8,8 +8,8 @@ class GetHistory(ApiHandler):
8
ctxid = input.get("context", [])
9
context = self.get_context(ctxid)
10
agent = context.streaming_agent or context.agent0
11
- history = agent.history.output()
12
- size = tokens.approximate_tokens(agent.history.output_text())
11
+ history = agent.history.output_text()
12
+ size = agent.history.get_tokens()
13
14
return {
15
"history": history,
python/helpers/files.py
+36
@@ -1,6 +1,7 @@
1
from fnmatch import fnmatch
2
import json
3
import os, re
4
+import base64
5
6
import re
7
import shutil
@@ -45,6 +46,32 @@ def read_file(_relative_path, _backup_dirs=None, _encoding="utf-8", **kwargs):
46
return content
47
48
49
+def read_file_bin(_relative_path, _backup_dirs=None):
50
+ # init backup dirs
51
+ if _backup_dirs is None:
52
+ _backup_dirs = []
53
+
54
+ # get absolute path
55
+ absolute_path = find_file_in_dirs(_relative_path, _backup_dirs)
56
+
57
+ # read binary content
58
+ with open(absolute_path, "rb") as f:
59
+ return f.read()
60
+
61
+
62
+def read_file_base64(_relative_path, _backup_dirs=None):
63
+ # init backup dirs
64
+ if _backup_dirs is None:
65
+ _backup_dirs = []
66
+
67
+ # get absolute path
68
+ absolute_path = find_file_in_dirs(_relative_path, _backup_dirs)
69
+
70
+ # read binary content and encode to base64
71
+ with open(absolute_path, "rb") as f:
72
+ return base64.b64encode(f.read()).decode('utf-8')
73
+
74
+
75
def replace_placeholders_text(_content: str, **kwargs):
76
# Replace placeholders with values from kwargs
77
for key, value in kwargs.items():
@@ -175,6 +202,15 @@ def write_file_bin(relative_path: str, content: bytes):
202
f.write(content)
203
204
205
+def write_file_base64(relative_path: str, content: str):
206
+ # decode base64 string to bytes
207
+ data = base64.b64decode(content)
208
+ abs_path = get_abs_path(relative_path)
209
+ os.makedirs(os.path.dirname(abs_path), exist_ok=True)
210
+ with open(abs_path, "wb") as f:
211
+ f.write(data)
212
+
213
+
214
def delete_file(relative_path: str):
215
abs_path = get_abs_path(relative_path)
216
if os.path.exists(abs_path):
python/helpers/history.py
+162
-123
@@ -1,16 +1,14 @@
1
from abc import abstractmethod
2
import asyncio
3
from collections import OrderedDict
4
+from collections.abc import Mapping
5
import json
6
import math
6
-import os
7
-from typing import Coroutine, Literal, TypedDict, cast
7
+from typing import Coroutine, Literal, TypedDict, cast, Union, Dict, List, Any, override
8
from python.helpers import messages, tokens, settings, call_llm
9
from enum import Enum
10
-from langchain_core.messages import HumanMessage, SystemMessage, AIMessage, BaseMessage
11
-from python.helpers.print_style import PrintStyle
12
-from langchain_core.prompts import HumanMessagePromptTemplate
13
-from typing import Any
10
+from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage, AIMessage
11
+
12
BULK_MERGE_COUNT = 3
13
TOPICS_KEEP_COUNT = 3
14
CURRENT_TOPIC_RATIO = 0.5
@@ -18,14 +16,22 @@ HISTORY_TOPIC_RATIO = 0.3
16
HISTORY_BULK_RATIO = 0.2
17
TOPIC_COMPRESS_RATIO = 0.65
18
LARGE_MESSAGE_TO_TOPIC_RATIO = 0.25
19
+RAW_MESSAGE_OUTPUT_TEXT_TRIM = 100
20
+
21
+
22
+class RawMessage(TypedDict):
23
+ raw_content: "MessageContent"
24
+ preview: str | None
25
+
26
22
-MessageContent = (
23
- list["MessageContent"]
24
- | OrderedDict[str, "MessageContent"]
25
- | list[OrderedDict[str, "MessageContent"]]
26
- | str
27
- | list[str]
28
-)
27
+MessageContent = Union[
28
+ List["MessageContent"],
29
+ Dict[str, "MessageContent"],
30
+ List[Dict[str, "MessageContent"]],
31
+ str,
32
+ List[str],
33
+ RawMessage,
34
+]
35
36
37
class OutputMessage(TypedDict):
@@ -37,9 +43,9 @@ class Record:
43
def __init__(self):
44
pass
45
46
+ @abstractmethod
47
def get_tokens(self) -> int:
41
- out = self.output_text()
42
- return tokens.approximate_tokens(out)
48
+ pass
49
50
@abstractmethod
51
async def compress(self) -> bool:
@@ -70,10 +76,25 @@ class Record:
76
77
78
class Message(Record):
73
- def __init__(self, ai: bool, content: MessageContent):
79
+ def __init__(self, ai: bool, content: MessageContent, tokens: int = 0):
80
self.ai = ai
81
self.content = content
76
- self.summary: MessageContent = ""
82
+ self.summary: str = ""
83
+ self.tokens: int = tokens or self.calculate_tokens()
84
+
85
+ @override
86
+ def get_tokens(self) -> int:
87
+ if not self.tokens:
88
+ self.tokens = self.calculate_tokens()
89
+ return self.tokens
90
+
91
+ def calculate_tokens(self):
92
+ text = self.output_text()
93
+ return tokens.approximate_tokens(text)
94
+
95
+ def set_summary(self, summary: str):
96
+ self.summary = summary
97
+ self.tokens = self.calculate_tokens()
98
99
async def compress(self):
100
return False
@@ -93,12 +114,15 @@ class Message(Record):
114
"ai": self.ai,
115
"content": self.content,
116
"summary": self.summary,
117
+ "tokens": self.tokens,
118
}
119
120
@staticmethod
121
def from_dict(data: dict, history: "History"):
100
- msg = Message(ai=data["ai"], content=data.get("content", "Content lost"))
122
+ content = data.get("content", "Content lost")
123
+ msg = Message(ai=data["ai"], content=content)
124
msg.summary = data.get("summary", "")
125
+ msg.tokens = data.get("tokens", 0)
126
return msg
127
128
@@ -108,8 +132,16 @@ class Topic(Record):
132
self.summary: str = ""
133
self.messages: list[Message] = []
134
111
- def add_message(self, ai: bool, content: MessageContent):
112
- msg = Message(ai=ai, content=content)
135
+ def get_tokens(self):
136
+ if self.summary:
137
+ return tokens.approximate_tokens(self.summary)
138
+ else:
139
+ return sum(msg.get_tokens() for msg in self.messages)
140
+
141
+ def add_message(
142
+ self, ai: bool, content: MessageContent, tokens: int = 0
143
+ ) -> Message:
144
+ msg = Message(ai=ai, content=content, tokens=tokens)
145
self.messages.append(msg)
146
return msg
147
@@ -118,7 +150,7 @@ class Topic(Record):
150
return [OutputMessage(ai=False, content=self.summary)]
151
else:
152
msgs = [m for r in self.messages for m in r.output()]
121
- return group_outputs_abab(msgs)
153
+ return msgs
154
155
async def summarize(self):
156
self.summary = await self.summarize_messages(self.messages)
@@ -134,22 +166,31 @@ class Topic(Record):
166
)
167
large_msgs = []
168
for m in (m for m in self.messages if not m.summary):
169
+ # TODO refactor this
170
out = m.output()
171
text = output_text(out)
139
- tok = tokens.approximate_tokens(text)
172
+ tok = m.get_tokens()
173
leng = len(text)
174
if tok > msg_max_size:
175
large_msgs.append((m, tok, leng, out))
176
large_msgs.sort(key=lambda x: x[1], reverse=True)
177
for msg, tok, leng, out in large_msgs:
178
trim_to_chars = leng * (msg_max_size / tok)
146
- trunc = messages.truncate_dict_by_ratio(
147
- self.history.agent,
148
- out[0]["content"],
149
- trim_to_chars * 1.15,
150
- trim_to_chars * 0.85,
151
- )
152
- msg.summary = trunc
179
+ # raw messages will be replaced as a whole, they would become invalid when truncated
180
+ if _is_raw_message(out[0]["content"]):
181
+ msg.set_summary(
182
+ "Message content replaced to save space in context window"
183
+ )
184
+
185
+ # regular messages will be truncated
186
+ else:
187
+ trunc = messages.truncate_dict_by_ratio(
188
+ self.history.agent,
189
+ out[0]["content"],
190
+ trim_to_chars * 1.15,
191
+ trim_to_chars * 0.85,
192
+ )
193
+ msg.set_summary(_json_dumps(trunc))
194
195
return True
196
return False
@@ -175,6 +216,7 @@ class Topic(Record):
216
return False
217
218
async def summarize_messages(self, messages: list[Message]):
219
+ # FIXME: vision bytes are sent to utility LLM, send summary instead
220
msg_txt = [m.output_text() for m in messages]
221
summary = await self.history.agent.call_utility_model(
222
system=self.history.agent.read_prompt("fw.topic_summary.sys.md"),
@@ -194,9 +236,9 @@ class Topic(Record):
236
@staticmethod
237
def from_dict(data: dict, history: "History"):
238
topic = Topic(history=history)
197
- topic.summary = data["summary"]
239
+ topic.summary = data.get("summary", "")
240
topic.messages = [
199
- Message.from_dict(m, history=history) for m in data["messages"]
241
+ Message.from_dict(m, history=history) for m in data.get("messages", [])
242
]
243
return topic
244
@@ -214,7 +256,7 @@ class Bulk(Record):
256
return [OutputMessage(ai=False, content=self.summary)]
257
else:
258
msgs = [m for r in self.records for m in r.output()]
217
- return group_outputs_abab(msgs)
259
+ return msgs
260
261
async def compress(self):
262
return False
@@ -253,8 +295,15 @@ class History(Record):
295
self.current = Topic(history=self)
296
self.agent: Agent = agent
297
298
+ def get_tokens(self) -> int:
299
+ return (
300
+ self.get_bulks_tokens()
301
+ + self.get_topics_tokens()
302
+ + self.get_current_topic_tokens()
303
+ )
304
+
305
def is_over_limit(self):
257
- limit = get_ctx_size_for_history()
306
+ limit = _get_ctx_size_for_history()
307
total = self.get_tokens()
308
return total > limit
309
@@ -267,15 +316,10 @@ class History(Record):
316
def get_current_topic_tokens(self) -> int:
317
return self.current.get_tokens()
318
270
- def get_tokens(self) -> int:
271
- return (
272
- self.get_bulks_tokens()
273
- + self.get_topics_tokens()
274
- + self.get_current_topic_tokens()
275
- )
276
-
277
- def add_message(self, ai: bool, content: MessageContent):
278
- return self.current.add_message(ai, content=content)
319
+ def add_message(
320
+ self, ai: bool, content: MessageContent, tokens: int = 0
321
+ ) -> Message:
322
+ return self.current.add_message(ai, content=content, tokens=tokens)
323
324
def new_topic(self):
325
if self.current.messages:
@@ -287,7 +331,6 @@ class History(Record):
331
result += [m for b in self.bulks for m in b.output()]
332
result += [m for t in self.topics for m in t.output()]
333
result += self.current.output()
290
- result = group_outputs_abab(result)
334
return result
335
336
@staticmethod
@@ -307,7 +350,7 @@ class History(Record):
350
351
def serialize(self):
352
data = self.to_dict()
310
- return json.dumps(data)
353
+ return _json_dumps(data)
354
355
async def compress(self):
356
compressed = False
@@ -317,7 +360,7 @@ class History(Record):
360
self.get_topics_tokens(),
361
self.get_bulks_tokens(),
362
)
320
- total = get_ctx_size_for_history()
363
+ total = _get_ctx_size_for_history()
364
ratios = [
365
(curr, CURRENT_TOPIC_RATIO, "current_topic"),
366
(hist, HISTORY_TOPIC_RATIO, "history_topic"),
@@ -392,25 +435,46 @@ class History(Record):
435
def deserialize_history(json_data: str, agent) -> History:
436
history = History(agent=agent)
437
if json_data:
395
- data = json.loads(json_data)
438
+ data = _json_loads(json_data)
439
history = History.from_dict(data, history=history)
440
return history
441
442
400
-def get_ctx_size_for_history() -> int:
443
+def _get_ctx_size_for_history() -> int:
444
set = settings.get_settings()
445
return int(set["chat_model_ctx_length"] * set["chat_model_ctx_history"])
446
447
405
-def serialize_output(output: OutputMessage, ai_label="ai", human_label="human"):
406
- return f'{ai_label if output["ai"] else human_label}: {serialize_content(output["content"])}'
448
+def _stringify_output(output: OutputMessage, ai_label="ai", human_label="human"):
449
+ return f'{ai_label if output["ai"] else human_label}: {_stringify_content(output["content"])}'
450
451
409
-def serialize_content(content: MessageContent) -> str:
452
+def _stringify_content(content: MessageContent) -> str:
453
+ # already a string
454
if isinstance(content, str):
455
return content
456
+
457
+ # raw messages return preview or trimmed json
458
+ if _is_raw_message(content):
459
+ preview: str = content.get("preview", "") # type: ignore
460
+ if preview:
461
+ return preview
462
+ text = _json_dumps(content)
463
+ if len(text) > RAW_MESSAGE_OUTPUT_TEXT_TRIM:
464
+ return text[:RAW_MESSAGE_OUTPUT_TEXT_TRIM] + "... TRIMMED"
465
+ return text
466
+
467
+ # regular messages of non-string are dumped as json
468
+ return _json_dumps(content)
469
+
470
+
471
+def _output_content_langchain(content: MessageContent):
472
+ if isinstance(content, str):
473
+ return content
474
+ if _is_raw_message(content):
475
+ return content["raw_content"] # type: ignore
476
try:
413
- return json.dumps(content)
477
+ return _json_dumps(content)
478
except Exception as e:
479
raise e
480
@@ -421,98 +485,73 @@ def group_outputs_abab(outputs: list[OutputMessage]) -> list[OutputMessage]:
485
if result and result[-1]["ai"] == out["ai"]:
486
result[-1] = OutputMessage(
487
ai=result[-1]["ai"],
424
- content=merge_outputs(result[-1]["content"], out["content"]),
488
+ content=_merge_outputs(result[-1]["content"], out["content"]),
489
)
490
else:
491
result.append(out)
492
return result
493
494
431
-def output_langchain(messages: list[OutputMessage]) -> list[BaseMessage]:
495
+def group_messages_abab(messages: list[BaseMessage]) -> list[BaseMessage]:
496
result = []
433
- for m in messages:
434
- if m["ai"]:
435
- result.append(AIMessage(content=serialize_content(m["content"])))
497
+ for msg in messages:
498
+ if result and isinstance(result[-1], type(msg)):
499
+ # create new instance of the same type with merged content
500
+ result[-1] = type(result[-1])(content=_merge_outputs(result[-1].content, msg.content)) # type: ignore
501
else:
437
- contents = m["content"]
438
-
439
- # sometimes content is a list sometimes not
440
- if not isinstance(contents, list):
441
- contents = [contents]
502
+ result.append(msg)
503
+ return result
504
443
- PrintStyle(font_color="grey", background_color="black", bold=True, padding=True).print(
444
- f"Contents: {json.dumps(contents, indent=2)}"
445
- )
505
447
- template: list[dict[str, str]] = [] # type: ignore
448
- message = ""
449
- images = {}
450
- for _, content in enumerate(contents):
451
- if message:
452
- # the first message is the user message, then the memory and solutions
453
- message += "\n\n--- Memory & Solutions Section: ---\n\n"
454
-
455
- message += serialize_content(content)
456
-
457
- if isinstance(content, dict) and "attachments" in content:
458
- attachments: list[str] = cast(list[str], content["attachments"])
459
- for attachment in attachments:
460
- if not os.path.exists(str(attachment)):
461
- continue
462
- if attachment not in images:
463
- import base64
464
- from mimetypes import guess_type
465
- mime_type, _ = guess_type(str(attachment))
466
- if mime_type.startswith("image/"):
467
- # Read and encode the image file
468
- with open(str(attachment), "rb") as image_file:
469
- base64_encoded_data = base64.b64encode(image_file.read()).decode('utf-8')
470
- # Construct the data URL
471
- images[attachment] = f"data:{mime_type};base64,{base64_encoded_data}"
472
-
473
- if message:
474
- template.append({"type": "text", "text": message})
475
- if images:
476
- for _, image in images.items():
477
- template.append({"type": "image_url", "image_url": image})
478
- if template:
479
- # only jinja2 is safe for json, both mustache({{...}}) and f-string({...}) are not
480
- result.append(HumanMessagePromptTemplate.from_template(template=template, partial_variables={}, template_format="jinja2")) # type: ignore
481
-
482
- PrintStyle(font_color="grey", background_color="black", bold=True, padding=True).print(
483
- f"Result: {result}"
484
- )
506
+def output_langchain(messages: list[OutputMessage]):
507
+ result = []
508
+ for m in messages:
509
+ if m["ai"]:
510
+ # result.append(AIMessage(content=serialize_content(m["content"])))
511
+ result.append(AIMessage(_output_content_langchain(content=m["content"]))) # type: ignore
512
+ else:
513
+ # result.append(HumanMessage(content=serialize_content(m["content"])))
514
+ result.append(HumanMessage(_output_content_langchain(content=m["content"]))) # type: ignore
515
+ # ensure message type alternation
516
+ result = group_messages_abab(result)
517
return result
518
519
520
def output_text(messages: list[OutputMessage], ai_label="ai", human_label="human"):
489
- return "\n".join(serialize_output(o, ai_label, human_label) for o in messages)
521
+ return "\n".join(_stringify_output(o, ai_label, human_label) for o in messages)
522
+
523
524
+def _merge_outputs(a: MessageContent, b: MessageContent) -> MessageContent:
525
+ if isinstance(a, str) and isinstance(b, str):
526
+ return a + b
527
492
-def merge_outputs(a: MessageContent, b: MessageContent) -> MessageContent:
528
if not isinstance(a, list):
529
a = [a]
530
if not isinstance(b, list):
531
b = [b]
497
- return a + b # type: ignore
498
- # return merge_properties(a, b)
532
+
533
+ return cast(MessageContent, a + b)
534
535
501
-def merge_properties(a: MessageContent, b: MessageContent) -> MessageContent:
502
- if isinstance(a, list):
503
- if isinstance(b, list):
504
- return a + b # type: ignore
536
+def _merge_properties(
537
+ a: Dict[str, MessageContent], b: Dict[str, MessageContent]
538
+) -> Dict[str, MessageContent]:
539
+ result = a.copy()
540
+ for k, v in b.items():
541
+ if k in result:
542
+ result[k] = _merge_outputs(result[k], v)
543
else:
506
- return a + [b]
507
- elif isinstance(b, list):
508
- return [a] + b # type: ignore
509
- elif isinstance(a, dict) and isinstance(b, dict):
510
- for key, value in b.items():
511
- if key in a:
512
- a[key] = merge_properties(a[key], value)
513
- else:
514
- a[key] = value
515
- return a
516
- elif isinstance(a, str) and isinstance(b, str):
517
- return a + b
518
- raise ValueError(f"Cannot merge {a} and {b}")
544
+ result[k] = v
545
+ return result
546
+
547
+
548
+def _is_raw_message(obj: object) -> bool:
549
+ return isinstance(obj, Mapping) and "raw_content" in obj
550
+
551
+
552
+def _json_dumps(obj):
553
+ return json.dumps(obj, ensure_ascii=False)
554
+
555
+
556
+def _json_loads(obj):
557
+ return json.loads(obj)
python/helpers/images.py
new
+35
@@ -0,0 +1,35 @@
1
+from PIL import Image
2
+import io
3
+import math
4
+
5
+
6
+def compress_image(image_data: bytes, *, max_pixels: int = 256_000, quality: int = 50) -> bytes:
7
+ """Compress an image by scaling it down and converting to JPEG with quality settings.
8
+
9
+ Args:
10
+ image_data: Raw image bytes
11
+ max_pixels: Maximum number of pixels in the output image (width * height)
12
+ quality: JPEG quality setting (1-100)
13
+
14
+ Returns:
15
+ Compressed image as bytes
16
+ """
17
+ # load image from bytes
18
+ img = Image.open(io.BytesIO(image_data))
19
+
20
+ # calculate scaling factor to get to max_pixels
21
+ current_pixels = img.width * img.height
22
+ if current_pixels > max_pixels:
23
+ scale = math.sqrt(max_pixels / current_pixels)
24
+ new_width = int(img.width * scale)
25
+ new_height = int(img.height * scale)
26
+ img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
27
+
28
+ # convert to RGB if needed (for JPEG)
29
+ if img.mode in ('RGBA', 'P'):
30
+ img = img.convert('RGB')
31
+
32
+ # save as JPEG with compression
33
+ output = io.BytesIO()
34
+ img.save(output, format='JPEG', quality=quality, optimize=True)
35
+ return output.getvalue()
python/helpers/runtime.py
+22
@@ -2,6 +2,9 @@ import argparse
2
import inspect
3
from typing import TypeVar, Callable, Awaitable, Union, overload, cast
4
from python.helpers import dotenv, rfc, settings
5
+import asyncio
6
+import threading
7
+import queue
8
9
T = TypeVar('T')
10
R = TypeVar('R')
@@ -102,3 +105,22 @@ def _get_rfc_url() -> str:
105
url = url+":"+str(set["rfc_port_http"])
106
url += "/rfc"
107
return url
108
+
109
+
110
+def call_development_function_sync(func: Union[Callable[..., T], Callable[..., Awaitable[T]]], *args, **kwargs) -> T:
111
+ # run async function in sync manner
112
+ result_queue = queue.Queue()
113
+
114
+ def run_in_thread():
115
+ result = asyncio.run(call_development_function(func, *args, **kwargs))
116
+ result_queue.put(result)
117
+
118
+ thread = threading.Thread(target=run_in_thread)
119
+ thread.start()
120
+ thread.join(timeout=30) # wait for thread with timeout
121
+
122
+ if thread.is_alive():
123
+ raise TimeoutError("Function call timed out after 30 seconds")
124
+
125
+ result = result_queue.get_nowait()
126
+ return cast(T, result)
python/helpers/tool.py
+3
-4
@@ -1,5 +1,6 @@
1
from abc import abstractmethod
2
-from dataclasses import dataclass, field
2
+from dataclasses import dataclass
3
+
4
from agent import Agent
5
from python.helpers.print_style import PrintStyle
6
@@ -8,8 +9,6 @@ from python.helpers.print_style import PrintStyle
9
class Response:
10
message:str
11
break_loop: bool
11
- attachments: list[str] = field(default_factory=list[str])
12
-
12
13
class Tool:
14
@@ -34,7 +33,7 @@ class Tool:
33
34
async def after_execution(self, response: Response, **kwargs):
35
text = response.message.strip()
37
- await self.agent.hist_add_tool_result(self.name, text, response.attachments)
36
+ self.agent.hist_add_tool_result(self.name, text)
37
PrintStyle(font_color="#1B4F72", background_color="white", padding=True, bold=True).print(f"{self.agent.agent_name}: Response from tool '{self.name}'")
38
PrintStyle(font_color="#85C1E9").print(response.message)
39
self.log.update(content=response.message)
python/helpers/whisper.py
+1
-1
@@ -30,7 +30,7 @@ async def _preload(model_name:str):
30
is_updating_model = True
31
if not _model or _model_name != model_name:
32
PrintStyle.standard(f"Loading Whisper model: {model_name}")
33
- _model = whisper.load_model(model_name)
33
+ _model = whisper.load_model(name=model_name) # type: ignore
34
_model_name = model_name
35
finally:
36
is_updating_model = False
python/tools/call_subordinate.py
+1
-1
@@ -18,7 +18,7 @@ class Delegation(Tool):
18
19
# add user message to subordinate agent
20
subordinate: Agent = self.agent.get_data(Agent.DATA_NAME_SUBORDINATE)
21
- await subordinate.hist_add_user_message(UserMessage(message=message, attachments=[]))
21
+ subordinate.hist_add_user_message(UserMessage(message=message, attachments=[]))
22
# run subordinate monologue
23
result = await subordinate.monologue()
24
# result
python/tools/code_execution_tool.py
+69
-30
@@ -12,7 +12,7 @@ from python.helpers.docker import DockerContainerManager
12
13
@dataclass
14
class State:
15
- shell: LocalInteractiveSession | SSHInteractiveSession
15
+ shells: dict[int, LocalInteractiveSession | SSHInteractiveSession]
16
docker: DockerContainerManager | None
17
18
@@ -27,19 +27,26 @@ class CodeExecution(Tool):
27
# os.chdir(files.get_abs_path("./work_dir")) #change CWD to work_dir
28
29
runtime = self.args.get("runtime", "").lower().strip()
30
+ session = int(self.args.get("session", 0))
31
32
if runtime == "python":
32
- response = await self.execute_python_code(self.args["code"])
33
+ response = await self.execute_python_code(
34
+ code=self.args["code"], session=session
35
+ )
36
elif runtime == "nodejs":
34
- response = await self.execute_nodejs_code(self.args["code"])
37
+ response = await self.execute_nodejs_code(
38
+ code=self.args["code"], session=session
39
+ )
40
elif runtime == "terminal":
36
- response = await self.execute_terminal_command(self.args["code"])
41
+ response = await self.execute_terminal_command(
42
+ command=self.args["code"], session=session
43
+ )
44
elif runtime == "output":
45
response = await self.get_terminal_output(
39
- wait_with_output=5, wait_without_output=60
46
+ session=session, wait_with_output=5, wait_without_output=60
47
)
48
elif runtime == "reset":
42
- response = await self.reset_terminal()
49
+ response = await self.reset_terminal(session=session)
50
else:
51
response = self.agent.read_prompt(
52
"fw.code_runtime_wrong.md", runtime=runtime
@@ -72,11 +79,15 @@ class CodeExecution(Tool):
79
# PrintStyle().print()
80
81
def get_log_object(self):
75
- return self.agent.context.log.log(type="code_exe", heading=f"{self.agent.agent_name}: Using tool '{self.name}'", content="", kvps=self.args)
76
-
82
+ return self.agent.context.log.log(
83
+ type="code_exe",
84
+ heading=f"{self.agent.agent_name}: Using tool '{self.name}'",
85
+ content="",
86
+ kvps=self.args,
87
+ )
88
89
async def after_execution(self, response, **kwargs):
79
- await self.agent.hist_add_tool_result(self.name, response.message)
90
+ self.agent.hist_add_tool_result(self.name, response.message)
91
92
async def prepare_state(self, reset=False):
93
self.state = self.agent.get_data("_cot_state")
@@ -97,7 +108,11 @@ class CodeExecution(Tool):
108
109
# initialize local or remote interactive shell insterface
110
if self.agent.config.code_exec_ssh_enabled:
100
- pswd = self.agent.config.code_exec_ssh_pass if self.agent.config.code_exec_ssh_pass else await rfc_exchange.get_root_password()
111
+ pswd = (
112
+ self.agent.config.code_exec_ssh_pass
113
+ if self.agent.config.code_exec_ssh_pass
114
+ else await rfc_exchange.get_root_password()
115
+ )
116
shell = SSHInteractiveSession(
117
self.agent.context.log,
118
self.agent.config.code_exec_ssh_addr,
@@ -108,42 +123,63 @@ class CodeExecution(Tool):
123
else:
124
shell = LocalInteractiveSession()
125
111
- self.state = State(shell=shell, docker=docker)
126
+ self.state = State(shells={0: shell}, docker=docker)
127
await shell.connect()
128
self.agent.set_data("_cot_state", self.state)
129
115
- async def execute_python_code(self, code: str, reset: bool = False):
130
+ async def execute_python_code(self, session: int, code: str, reset: bool = False):
131
escaped_code = shlex.quote(code)
132
command = f"ipython -c {escaped_code}"
118
- return await self.terminal_session(command, reset)
133
+ return await self.terminal_session(session, command, reset)
134
120
- async def execute_nodejs_code(self, code: str, reset: bool = False):
135
+ async def execute_nodejs_code(self, session: int, code: str, reset: bool = False):
136
escaped_code = shlex.quote(code)
137
command = f"node /exe/node_eval.js {escaped_code}"
123
- return await self.terminal_session(command, reset)
138
+ return await self.terminal_session(session, command, reset)
139
125
- async def execute_terminal_command(self, command: str, reset: bool = False):
126
- return await self.terminal_session(command, reset)
140
+ async def execute_terminal_command(
141
+ self, session: int, command: str, reset: bool = False
142
+ ):
143
+ return await self.terminal_session(session, command, reset)
144
128
- async def terminal_session(self, command: str, reset: bool = False):
145
+ async def terminal_session(self, session: int, command: str, reset: bool = False):
146
147
await self.agent.handle_intervention() # wait for intervention and handle it, if paused
148
# try again on lost connection
149
for i in range(2):
150
try:
134
-
151
+
152
if reset:
153
await self.reset_terminal()
154
138
- self.state.shell.send_command(command)
139
-
140
- PrintStyle(background_color="white", font_color="#1B4F72", bold=True).print(
141
- f"{self.agent.agent_name} code execution output"
142
- )
143
- return await self.get_terminal_output()
155
+ if session not in self.state.shells:
156
+ if self.agent.config.code_exec_ssh_enabled:
157
+ pswd = (
158
+ self.agent.config.code_exec_ssh_pass
159
+ if self.agent.config.code_exec_ssh_pass
160
+ else await rfc_exchange.get_root_password()
161
+ )
162
+ shell = SSHInteractiveSession(
163
+ self.agent.context.log,
164
+ self.agent.config.code_exec_ssh_addr,
165
+ self.agent.config.code_exec_ssh_port,
166
+ self.agent.config.code_exec_ssh_user,
167
+ pswd,
168
+ )
169
+ else:
170
+ shell = LocalInteractiveSession()
171
+ self.state.shells[session] = shell
172
+ await shell.connect()
173
+
174
+ self.state.shells[session].send_command(command)
175
+
176
+ PrintStyle(
177
+ background_color="white", font_color="#1B4F72", bold=True
178
+ ).print(f"{self.agent.agent_name} code execution output")
179
+ return await self.get_terminal_output(session)
180
181
except Exception as e:
146
- if i==1:
182
+ if i == 1:
183
# try again on lost connection
184
PrintStyle.error(str(e))
185
await self.prepare_state(reset=True)
@@ -153,6 +189,7 @@ class CodeExecution(Tool):
189
190
async def get_terminal_output(
191
self,
192
+ session=0,
193
reset_full_output=True,
194
wait_with_output=3,
195
wait_without_output=10,
@@ -165,10 +202,10 @@ class CodeExecution(Tool):
202
203
while max_exec_time <= 0 or time.time() - start_time < max_exec_time:
204
await asyncio.sleep(SLEEP_TIME) # Wait for some output to be generated
168
- full_output, partial_output = await self.state.shell.read_output(
205
+ full_output, partial_output = await self.state.shells[session].read_output(
206
timeout=max_exec_time, reset_full_output=reset_full_output
207
)
171
- reset_full_output = False # only reset once
208
+ reset_full_output = False # only reset once
209
210
await self.agent.handle_intervention() # wait for intervention and handle it, if paused
211
@@ -184,8 +221,10 @@ class CodeExecution(Tool):
221
break
222
return full_output
223
187
- async def reset_terminal(self):
188
- self.state.shell.close()
224
+ async def reset_terminal(self, session=0):
225
+ if session in self.state.shells:
226
+ self.state.shells[session].close()
227
+ del self.state.shells[session]
228
await self.prepare_state(reset=True)
229
response = self.agent.read_prompt("fw.code_reset.md")
230
self.log.update(content=response)
python/tools/input.py
+1
-1
@@ -20,4 +20,4 @@ class Input(Tool):
20
return self.agent.context.log.log(type="code_exe", heading=f"{self.agent.agent_name}: Using tool '{self.name}'", content="", kvps=self.args)
21
22
async def after_execution(self, response, **kwargs):
23
- await self.agent.hist_add_tool_result(self.name, response.message)
\ No newline at end of file
23
+ self.agent.hist_add_tool_result(self.name, response.message)
\ No newline at end of file
python/tools/vision_load.py
new
+79
@@ -0,0 +1,79 @@
1
+import base64
2
+from python.helpers.print_style import PrintStyle
3
+from python.helpers.tool import Tool, Response
4
+from python.helpers import runtime, files, images
5
+from mimetypes import guess_type
6
+from python.helpers import history
7
+
8
+# image optimization and token estimation for context window
9
+MAX_PIXELS = 768_000
10
+QUALITY = 75
11
+TOKENS_ESTIMATE = 1500
12
+
13
+
14
+class VisionLoad(Tool):
15
+ async def execute(self, paths: list[str] = [], **kwargs) -> Response:
16
+
17
+ self.images_dict = {}
18
+ template: list[dict[str, str]] = [] # type: ignore
19
+
20
+ for path in paths:
21
+ if not await runtime.call_development_function(files.exists, str(path)):
22
+ continue
23
+
24
+ if path not in self.images_dict:
25
+ mime_type, _ = guess_type(str(path))
26
+ if mime_type and mime_type.startswith("image/"):
27
+ # Read binary file
28
+ file_content = await runtime.call_development_function(
29
+ files.read_file_base64, str(path)
30
+ )
31
+ file_content = base64.b64decode(file_content)
32
+ # Compress and convert to JPEG
33
+ compressed = images.compress_image(
34
+ file_content, max_pixels=MAX_PIXELS, quality=QUALITY
35
+ )
36
+ # Encode as base64
37
+ file_content_b64 = base64.b64encode(compressed).decode("utf-8")
38
+
39
+ # DEBUG: Save compressed image
40
+ # await runtime.call_development_function(
41
+ # files.write_file_base64, str(path), file_content_b64
42
+ # )
43
+
44
+ # Construct the data URL (always JPEG after compression)
45
+ self.images_dict[path] = file_content_b64
46
+
47
+ return Response(message="dummy", break_loop=False)
48
+
49
+ async def after_execution(self, response: Response, **kwargs):
50
+
51
+ # build image data messages for LLMs, or error message
52
+ content = []
53
+ if self.images_dict:
54
+ for _, image in self.images_dict.items():
55
+ content.append(
56
+ {
57
+ "type": "image_url",
58
+ "image_url": {"url": f"data:image/jpeg;base64,{image}"},
59
+ }
60
+ )
61
+ # append as raw message content for LLMs with vision tokens estimate
62
+ msg = history.RawMessage(raw_content=content, preview="<Base64 encoded image data>")
63
+ self.agent.hist_add_message(
64
+ False, content=msg, tokens=TOKENS_ESTIMATE * len(content)
65
+ )
66
+ else:
67
+ self.agent.hist_add_tool_result(self.name, "No images processed")
68
+
69
+ # print and log short version
70
+ message = (
71
+ "No images processed"
72
+ if not self.images_dict
73
+ else f"{len(self.images_dict)} images processed"
74
+ )
75
+ PrintStyle(
76
+ font_color="#1B4F72", background_color="white", padding=True, bold=True
77
+ ).print(f"{self.agent.agent_name}: Response from tool '{self.name}'")
78
+ PrintStyle(font_color="#85C1E9").print(message)
79
+ self.log.update(result=message)
webui/js/history.js
+3
-2
@@ -3,9 +3,10 @@ import { getContext } from "../index.js";
3
export async function openHistoryModal() {
4
try {
5
const hist = await window.sendJsonData("/history_get", { context: getContext() });
6
- const data = JSON.stringify(hist.history, null, 4);
6
+ // const data = JSON.stringify(hist.history, null, 4);
7
+ const data = hist.history
8
const size = hist.tokens
8
- await showEditorModal(data, "json", `History ~${size} tokens`, "Conversation history visible to the LLM. History is compressed to fit into the context window over time.");
9
+ await showEditorModal(data, "markdown", `History ~${size} tokens`, "Conversation history visible to the LLM. History is compressed to fit into the context window over time.");
10
} catch (e) {
11
window.toastFetchError("Error fetching history", e)
12
return