Context window management, work in progress

frdel committed Nov 25, 2024 at 17:39 UTC a0ff118ad1c9e0aa325e714e3ecdf716ff38ee46
39 files changed +1194 -296
agent.py
+147 -119
@@ -3,11 +3,10 @@ from dataclasses import dataclass, field
3 import time, importlib, inspect, os, json
4 from typing import Any, Optional, Dict, TypedDict
5 import uuid
6 -from python.helpers import extract_tools, rate_limiter, files, errors
6 +from python.helpers import extract_tools, rate_limiter, files, errors, history, tokens
7 from python.helpers.print_style import PrintStyle
8 -from langchain.schema import AIMessage
8 from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
10 -from langchain_core.messages import HumanMessage, SystemMessage
9 +from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
10 from langchain_core.language_models.chat_models import BaseChatModel
11 from langchain_core.language_models.llms import BaseLLM
12 from langchain_core.embeddings import Embeddings
@@ -88,7 +87,7 @@ class AgentContext:
87 while intervention_agent and broadcast_level != 0:
88 intervention_agent.intervention_message = msg
89 broadcast_level -= 1
91 - intervention_agent = intervention_agent.data.get("superior", None)
90 + intervention_agent = intervention_agent.data.get(Agent.DATA_NAME_SUPERIOR, None)
91 else:
92
93 # self.process = DeferredTask(current_agent.monologue, msg)
@@ -97,19 +96,17 @@ class AgentContext:
96 return self.process
97
98 # this wrapper ensures that superior agents are called back if the chat was loaded from file and original callstack is gone
100 - async def _process_chain(self, agent: 'Agent', msg: str, user=True):
99 + async def _process_chain(self, agent: "Agent", msg: str, user=True):
100 try:
101 msg_template = (
103 - agent.read_prompt("fw.user_message.md", message=msg)
102 + await agent.hist_add_user_message(msg)
103 if user
105 - else agent.read_prompt(
106 - "fw.tool_response.md",
107 - tool_name="call_subordinate",
108 - tool_response=msg,
104 + else await agent.hist_add_tool_result(
105 + tool_name="call_subordinate", tool_result=msg
106 )
107 )
111 - response = await agent.monologue(msg_template)
112 - superior = agent.data.get("superior", None)
108 + response = await agent.monologue()
109 + superior = agent.data.get(Agent.DATA_NAME_SUPERIOR, None)
110 if superior:
111 response = await self._process_chain(superior, response, False)
112 return response
@@ -154,15 +151,18 @@ class AgentConfig:
151 additional: Dict[str, Any] = field(default_factory=dict)
152
153
157 -
154 class LoopData:
159 - def __init__(self):
155 + def __init__(self, **kwargs):
156 self.iteration = -1
157 self.system = []
162 - self.message = ""
163 - self.history_from = 0
164 - self.history = []
165 - self.attachments = [] # Add attachments field
158 + self.user_message: history.Message | None = None
159 + self.history_output: list[history.OutputMessage] = []
160 + self.last_response = ""
161 + self.attachments = [] # Add attachments field
162 +
163 + # override values with kwargs
164 + for key, value in kwargs.items():
165 + setattr(self, key, value)
166
167
168 # intervention exception class - skips rest of message loop iteration
@@ -181,6 +181,10 @@ class HandledException(Exception):
181
182 class Agent:
183
184 + DATA_NAME_SUPERIOR = "_superior"
185 + DATA_NAME_SUBORDINATE = "_subordinate"
186 + DATA_NAME_CTX_WINDOW = "ctx_window"
187 +
188 def __init__(
189 self, number: int, config: AgentConfig, context: AgentContext | None = None
190 ):
@@ -195,8 +199,8 @@ class Agent:
199 self.number = number
200 self.agent_name = f"Agent {self.number}"
201
198 - self.history = []
199 - self.last_message = ""
202 + self.history = history.History(self)
203 + self.last_user_message: history.Message | None = None
204 self.intervention_message = ""
205 self.rate_limiter = rate_limiter.RateLimiter(
206 self.context.log,
@@ -207,59 +211,67 @@ class Agent:
211 )
212 self.data = {} # free data object all the tools can use
213
210 - async def monologue(self, msg: str):
214 + async def monologue(self):
215 while True:
216 try:
217 # loop data dictionary to pass to extensions
214 - loop_data = LoopData()
215 - loop_data.message = msg
216 - loop_data.history_from = len(self.history)
217 -
218 + self.loop_data = LoopData(user_message=self.last_user_message)
219 # call monologue_start extensions
219 - await self.call_extensions("monologue_start", loop_data=loop_data)
220 + await self.call_extensions("monologue_start", loop_data=self.loop_data)
221
222 printer = PrintStyle(italic=True, font_color="#b3ffd9", padding=False)
222 - user_message = loop_data.message
223 -
224 - # Include attachments in user message if available
225 - if loop_data.attachments:
226 - user_message += "\n" + "\n".join(loop_data.attachments) # Add attachments to message
227 - loop_data.attachments = [] # Clear attachments after adding to message
223
224 + # # Include attachments in user message if available
225 + # if loop_data.attachments:
226 + # user_message += "\n" + "\n".join(
227 + # loop_data.attachments
228 + # ) # Add attachments to message
229 + # loop_data.attachments = (
230 + # []
231 + # ) # Clear attachments after adding to message
232 + # TODO attachments to extension
233
230 - await self.append_message(user_message, human=True)
234 + # await self.hist_add_user_message(message=self.loop_data.message)
235
236 # let the agent run message loop until he stops it with a response tool
237 while True:
238
239 self.context.streaming_agent = self # mark self as current streamer
240 agent_response = ""
237 - loop_data.iteration += 1
241 + self.loop_data.iteration += 1
242
243 try:
244
245 # set system prompt and message history
242 - loop_data.system = []
243 - loop_data.history = self.history
246 + self.loop_data.system = []
247 + self.loop_data.history_output = self.history.output()
248
249 # and allow extensions to edit them
250 await self.call_extensions(
247 - "message_loop_prompts", loop_data=loop_data
251 + "message_loop_prompts", loop_data=self.loop_data
252 )
253
254 # build chain from system prompt, message history and model
255 prompt = ChatPromptTemplate.from_messages(
256 [
253 - SystemMessage(content="\n\n".join(loop_data.system)),
257 + SystemMessage(
258 + content="\n\n".join(self.loop_data.system)
259 + ),
260 MessagesPlaceholder(variable_name="messages"),
261 ]
262 )
263 chain = prompt | self.config.chat_model
264
265 + # convert history to LLM format
266 + history_langchain = history.output_langchain(
267 + self.loop_data.history_output
268 + )
269 +
270 # rate limiter TODO - move to extension, make per-model
260 - formatted_inputs = prompt.format(messages=self.history)
261 - tokens = int(len(formatted_inputs) / 4)
262 - self.rate_limiter.limit_call_and_input(tokens)
271 + formatted_inputs = prompt.format(messages=history_langchain)
272 + self.set_data(self.DATA_NAME_CTX_WINDOW, formatted_inputs)
273 + token_count = tokens.approximate_tokens(formatted_inputs)
274 + self.rate_limiter.limit_call_and_input(token_count)
275
276 # output that the agent is starting
277 PrintStyle(
@@ -273,11 +285,10 @@ class Agent:
285 )
286
287 async for chunk in chain.astream(
276 - {"messages": loop_data.history}
288 + {"messages": history_langchain}
289 ):
278 - await self.handle_intervention(
279 - agent_response
280 - ) # wait for intervention and handle it, if paused
290 + # wait for intervention and handle it, if paused
291 + await self.handle_intervention(agent_response)
292
293 if isinstance(chunk, str):
294 content = chunk
@@ -287,12 +298,10 @@ class Agent:
298 content = str(chunk)
299
300 if content:
290 - printer.stream(
291 - content
292 - ) # output the agent response stream
293 - agent_response += (
294 - content # concatenate stream into the response
295 - )
301 + # output the agent response stream
302 + printer.stream(content)
303 + # concatenate stream into the response
304 + agent_response += content
305 self.log_from_stream(agent_response, log)
306
307 self.rate_limiter.set_output_tokens(
@@ -302,27 +311,23 @@ class Agent:
311 await self.handle_intervention(agent_response)
312
313 if (
305 - self.last_message == agent_response
314 + self.loop_data.last_response == agent_response
315 ): # if assistant_response is the same as last message in history, let him know
307 - await self.append_message(
308 - agent_response
309 - ) # Append the assistant's response to the history
316 + # Append the assistant's response to the history
317 + await self.hist_add_ai_response(agent_response)
318 + # Append warning message to the history
319 warning_msg = self.read_prompt("fw.msg_repeat.md")
311 - await self.append_message(
312 - warning_msg, human=True
313 - ) # Append warning message to the history
320 + await self.hist_add_warning(message=warning_msg)
321 PrintStyle(font_color="orange", padding=True).print(
322 warning_msg
323 )
324 self.context.log.log(type="warning", content=warning_msg)
325
326 else: # otherwise proceed with tool
320 - await self.append_message(
321 - agent_response
322 - ) # Append the assistant's response to the history
323 - tools_result = await self.process_tools(
324 - agent_response
325 - ) # process tools requested in agent message
327 + # Append the assistant's response to the history
328 + await self.hist_add_ai_response(agent_response)
329 + # process tools requested in agent message
330 + tools_result = await self.process_tools(agent_response)
331 if tools_result: # final response of message loop available
332 return tools_result # break the execution if the task is done
333
@@ -333,19 +338,16 @@ class Agent:
338 RepairableException
339 ) as e: # Forward repairable errors to the LLM, maybe it can fix them
340 error_message = errors.format_error(e)
336 - msg_response = self.read_prompt(
337 - "fw.error.md", error=error_message
338 - ) # error message template
339 - await self.append_message(msg_response, human=True)
340 - PrintStyle(font_color="red", padding=True).print(msg_response)
341 - self.context.log.log(type="error", content=msg_response)
341 + await self.hist_add_warning(error_message)
342 + PrintStyle(font_color="red", padding=True).print(error_message)
343 + self.context.log.log(type="error", content=error_message)
344 except Exception as e: # Other exception kill the loop
345 self.handle_critical_exception(e)
346
347 finally:
348 # call message_loop_end extensions
349 await self.call_extensions(
348 - "message_loop_end", loop_data=loop_data
350 + "message_loop_end", loop_data=self.loop_data
351 )
352
353 # exceptions outside message loop:
@@ -356,7 +358,7 @@ class Agent:
358 finally:
359 self.context.streaming_agent = None # unset current streamer
360 # call monologue_end extensions
359 - await self.call_extensions("monologue_end", loop_data=loop_data) # type: ignore
361 + await self.call_extensions("monologue_end", loop_data=self.loop_data) # type: ignore
362
363 def handle_critical_exception(self, exception: Exception):
364 if isinstance(exception, HandledException):
@@ -376,6 +378,24 @@ class Agent:
378 self.context.log.log(type="error", content=error_message)
379 raise HandledException(exception) # Re-raise the exception to kill the loop
380
381 + def parse_prompt(self, file: str, **kwargs) -> tuple[list, dict]:
382 + prompt_dir = files.get_abs_path("prompts/default")
383 + backup_dir = []
384 + if (
385 + self.config.prompts_subdir
386 + ): # if agent has custom folder, use it and use default as backup
387 + prompt_dir = files.get_abs_path("prompts", self.config.prompts_subdir)
388 + backup_dir.append(files.get_abs_path("prompts/default"))
389 + prompt = files.parse_file(
390 + files.get_abs_path(prompt_dir, file), _backup_dirs=backup_dir, **kwargs
391 + )
392 + if isinstance(prompt, dict):
393 + return [], prompt
394 + elif isinstance(prompt, list):
395 + return prompt, {}
396 + else:
397 + return [prompt], {}
398 +
399 def read_prompt(self, file: str, **kwargs) -> str:
400 prompt_dir = files.get_abs_path("prompts/default")
401 backup_dir = []
@@ -385,7 +405,7 @@ class Agent:
405 prompt_dir = files.get_abs_path("prompts", self.config.prompts_subdir)
406 backup_dir.append(files.get_abs_path("prompts/default"))
407 return files.read_file(
388 - files.get_abs_path(prompt_dir, file), backup_dirs=backup_dir, **kwargs
408 + files.get_abs_path(prompt_dir, file), _backup_dirs=backup_dir, **kwargs
409 )
410
411 def get_data(self, field: str):
@@ -394,23 +414,36 @@ class Agent:
414 def set_data(self, field: str, value):
415 self.data[field] = value
416
397 - async def append_message(self, msg: str, human: bool = False):
398 - message_type = "human" if human else "ai"
399 - if self.history and self.history[-1].type == message_type:
400 - self.history[-1].content += "\n\n" + msg
417 + async def hist_add_user_message(self, message: str, intervention: bool = False):
418 + self.history.new_topic() # user message starts a new topic in history
419 + if intervention:
420 + args, kwargs = self.parse_prompt("fw.intervention.md", message=message)
421 + msg = self.history.add_message(False, *args, **kwargs)
422 else:
402 - new_message = HumanMessage(content=msg) if human else AIMessage(content=msg)
403 - self.history.append(new_message)
404 - await self.cleanup_history(
405 - self.config.msgs_keep_max,
406 - self.config.msgs_keep_start,
407 - self.config.msgs_keep_end,
408 - )
409 - if message_type == "ai":
410 - self.last_message = msg
423 + args, kwargs = self.parse_prompt("fw.user_message.md", message=message)
424 + msg = self.history.add_message(False, *args, **kwargs)
425 + self.last_user_message = msg
426 + return msg
427 +
428 + async def hist_add_ai_response(self, message: str):
429 + self.loop_data.last_response = message
430 + args, kwargs = self.parse_prompt("fw.ai_response.md", message=message)
431 + return self.history.add_message(True, *args, **kwargs)
432 +
433 + async def hist_add_warning(self, message: str):
434 + args, kwargs = self.parse_prompt("fw.warning.md", message=message)
435 + return self.history.add_message(False, *args, **kwargs)
436 +
437 + async def hist_add_tool_result(self, tool_name: str, tool_result: str):
438 + args, kwargs = self.parse_prompt(
439 + "fw.tool_result.md", tool_name=tool_name, tool_result=tool_result
440 + )
441 + return self.history.add_message(False, *args, **kwargs)
442
412 - def concat_messages(self, messages):
413 - return "\n".join([f"{msg.type}: {msg.content}" for msg in messages])
443 + def concat_messages(
444 + self, messages
445 + ): # TODO add param for message range, topic, history
446 + return self.history.output_text(human_label="user", ai_label="assistant")
447
448 async def call_utility_llm(
449 self, system: str, msg: str, callback: Callable[[str], None] | None = None
@@ -423,8 +456,8 @@ class Agent:
456 response = ""
457
458 formatted_inputs = prompt.format()
426 - tokens = int(len(formatted_inputs) / 4)
427 - self.rate_limiter.limit_call_and_input(tokens)
459 + token_count = tokens.approximate_tokens(formatted_inputs)
460 + self.rate_limiter.limit_call_and_input(token_count)
461
462 async for chunk in chain.astream({}):
463 await self.handle_intervention() # wait for intervention and handle it, if paused
@@ -469,28 +502,28 @@ class Agent:
502 return [new_human_message]
503
504 async def cleanup_history(self, max: int, keep_start: int, keep_end: int):
472 - if len(self.history) <= max:
473 - return self.history
505 + # if len(self.history) <= max:
506 + # return self.history
507
475 - first_x = self.history[:keep_start]
476 - last_y = self.history[-keep_end:]
508 + # first_x = self.history[:keep_start]
509 + # last_y = self.history[-keep_end:]
510
478 - # Identify the middle part
479 - middle_part = self.history[keep_start:-keep_end]
511 + # # Identify the middle part
512 + # middle_part = self.history[keep_start:-keep_end]
513
481 - # Ensure the first message in the middle is "human", if not, move one message back
482 - if middle_part and middle_part[0].type != "human":
483 - if len(first_x) > 0:
484 - middle_part.insert(0, first_x.pop())
514 + # # Ensure the first message in the middle is "human", if not, move one message back
515 + # if middle_part and middle_part[0].type != "human":
516 + # if len(first_x) > 0:
517 + # middle_part.insert(0, first_x.pop())
518
486 - # Ensure the middle part has an odd number of messages
487 - if len(middle_part) % 2 == 0:
488 - middle_part = middle_part[:-1]
519 + # # Ensure the middle part has an odd number of messages
520 + # if len(middle_part) % 2 == 0:
521 + # middle_part = middle_part[:-1]
522
490 - # Replace the middle part using the replacement function
491 - new_middle_part = await self.replace_middle_messages(middle_part)
523 + # # Replace the middle part using the replacement function
524 + # new_middle_part = await self.replace_middle_messages(middle_part)
525
493 - self.history = first_x + new_middle_part + last_y
526 + # self.history = first_x + new_middle_part + last_y
527
528 return self.history
529
@@ -503,15 +536,11 @@ class Agent:
536 msg = self.intervention_message
537 self.intervention_message = "" # reset the intervention message
538 if progress.strip():
506 - await self.append_message(
507 - progress
508 - ) # append the response generated so far
509 - user_msg = self.read_prompt(
510 - "fw.intervention.md", user_message=msg
511 - ) # format the user intervention template
512 - await self.append_message(
513 - user_msg, human=True
514 - ) # append the intervention message
539 + await self.hist_add_ai_response(progress)
540 + # format the user intervention template
541 + user_msg = self.read_prompt("fw.intervention.md", user_message=msg)
542 + # append the intervention message
543 + await self.hist_add_user_message(user_msg, intervention=True)
544 raise InterventionException(msg)
545
546 async def process_tools(self, msg: str):
@@ -534,7 +563,7 @@ class Agent:
563 return response.message
564 else:
565 msg = self.read_prompt("fw.msg_misformat.md")
537 - await self.append_message(msg, human=True)
566 + await self.hist_add_warning(msg)
567 PrintStyle(font_color="red", padding=True).print(msg)
568 self.context.log.log(
569 type="error", content=f"{self.agent_name}: Message misformat"
@@ -546,9 +575,8 @@ class Agent:
575 return # no reason to try
576 response = DirtyJson.parse_string(stream)
577 if isinstance(response, dict):
549 - logItem.update(
550 - content=stream, kvps=response
551 - ) # log if result is a dictionary already
578 + # log if result is a dictionary already
579 + logItem.update(content=stream, kvps=response)
580 except Exception as e:
581 pass
582
prompts/default/fw.ai_response.md new
+1
@@ -0,0 +1 @@
1 +{{message}}
\ No newline at end of file
prompts/default/fw.bulk_summary.msg.md new
+2
@@ -0,0 +1,2 @@
1 +# Message history to summarize:
2 +{{content}}
\ No newline at end of file
prompts/default/fw.bulk_summary.sys.md new
+13
@@ -0,0 +1,13 @@
1 +# AI role
2 +You are AI summarization assistant
3 +You are provided with a conversation history and your goal is to provide a short summary of the conversation
4 +Records in the conversation may already be summarized
5 +You must return a single summary of all records
6 +
7 +# Expected output
8 +Your output will be a text of the summary
9 +Length of the text should be one paragraph, approximately 100 words
10 +No intro
11 +No conclusion
12 +No formatting
13 +Only the summary text is returned
\ No newline at end of file
prompts/default/fw.call_subordinate.md new
+1
@@ -0,0 +1 @@
1 +{{message}}
\ No newline at end of file
prompts/default/fw.intervention.md
+1 -5
@@ -1,5 +1 @@
1 -~~~json
2 -{
3 - "user_intervention": "{{user_message}}"
4 -}
5 -~~~
\ No newline at end of file
1 +{{message}}
\ No newline at end of file
prompts/default/fw.msg_misformat.md
+1 -5
@@ -1,5 +1 @@
1 -~~~json
2 -{
3 - "system_warning": "You have misformatted your message. Follow system prompt instructions on JSON message formatting precisely."
4 -}
5 -~~~
\ No newline at end of file
1 +You have misformatted your message. Follow system prompt instructions on JSON message formatting precisely.
\ No newline at end of file
prompts/default/fw.msg_repeat.md
+1 -5
@@ -1,5 +1 @@
1 -~~~json
2 -{
3 - "system_warning": "You have sent the same message again. You have to do something else!"
4 -}
5 -~~~
\ No newline at end of file
1 +You have sent the same message again. You have to do something else!
\ No newline at end of file
prompts/default/fw.msg_summary.md new
+5
@@ -0,0 +1,5 @@
1 +```json
2 +{
3 + "messages_summary": {{summary}}
4 +}
5 +```
prompts/default/fw.tool_not_found.md
+1 -5
@@ -1,5 +1 @@
1 -~~~json
2 -{
3 - "system_warning": "Tool {{tool_name}} not found. Available tools: \n{{tools_prompt}}"
4 -}
5 -~~~
\ No newline at end of file
1 +Tool {{tool_name}} not found. Available tools: \n{{tools_prompt}}
\ No newline at end of file
prompts/default/fw.tool_response.md deleted
-6
@@ -1,6 +0,0 @@
1 -~~~json
2 -{
3 - "response_from_tool": "{{tool_name}}",
4 - "data": {{tool_response}}
5 -}
6 -~~~
\ No newline at end of file
prompts/default/fw.tool_result.md new
+6
@@ -0,0 +1,6 @@
1 +~~~json
2 +{
3 + "tool_name": {{tool_name}},
4 + "tool_result": {{tool_result}}
5 +}
6 +~~~
\ No newline at end of file
prompts/default/fw.topic_summary.msg.md new
+2
@@ -0,0 +1,2 @@
1 +# Message history to summarize:
2 +{{content}}
\ No newline at end of file
prompts/default/fw.topic_summary.sys.md new
+13
@@ -0,0 +1,13 @@
1 +# AI role
2 +You are AI summarization assistant
3 +You are provided with a conversation history and your goal is to provide a short summary of the conversation
4 +Records in the conversation may already be summarized
5 +You must return a single summary of all records
6 +
7 +# Expected output
8 +Your output will be a text of the summary
9 +Length of the text should be one paragraph, approximately 100 words
10 +No intro
11 +No conclusion
12 +No formatting
13 +Only the summary text is returned
\ No newline at end of file
prompts/default/fw.user_message.md
+3 -3
@@ -1,5 +1,5 @@
1 -~~~json
1 +```json
2 {
3 - "user": "{{message}}"
3 + "user_message": {{message}}
4 }
5 -~~~
\ No newline at end of file
5 +```
prompts/default/fw.warning.md new
+5
@@ -0,0 +1,5 @@
1 +~~~json
2 +{
3 + "system_warning": {{message}}
4 +}
5 +~~~
python/api/chat_export.py
-1
@@ -11,7 +11,6 @@ class ExportChat(ApiHandler):
11
12 context = self.get_context(ctxid)
13 content = persist_chat.export_json_chat(context)
14 -
14 return {
15 "message": "Chats exported.",
16 "ctxid": context.id,
python/api/ctx_window_get.py new
+14
@@ -0,0 +1,14 @@
1 +from python.helpers import tokens
2 +from python.helpers.api import ApiHandler
3 +from flask import Request, Response
4 +
5 +
6 +class GetCtxWindow(ApiHandler):
7 + async def process(self, input: dict, request: Request) -> dict | Response:
8 + ctxid = input.get("context", [])
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)
13 +
14 + return {"content": window, "tokens": size}
python/api/history_get.py new
+17
@@ -0,0 +1,17 @@
1 +from python.helpers import tokens
2 +from python.helpers.api import ApiHandler
3 +from flask import Request, Response
4 +
5 +
6 +class GetHistory(ApiHandler):
7 + async def process(self, input: dict, request: Request) -> dict | Response:
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())
13 +
14 + return {
15 + "history": history,
16 + "tokens": size
17 + }
\ No newline at end of file
python/extensions/message_loop_end/_10_organize_history.py new
+18
@@ -0,0 +1,18 @@
1 +import asyncio
2 +from python.helpers.extension import Extension
3 +from agent import LoopData
4 +
5 +DATA_NAME_TASK = "_organize_history_task"
6 +
7 +
8 +class OrganizeHistory(Extension):
9 + async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
10 + # is there a running task? if yes, skip this round, the wait extension will double check the context size
11 + task = self.agent.get_data(DATA_NAME_TASK)
12 + if task and not task.done():
13 + return
14 +
15 + # start task
16 + task = asyncio.create_task(self.agent.history.compress())
17 + # set to agent to be able to wait for it
18 + self.agent.set_data(DATA_NAME_TASK, task)
python/extensions/message_loop_prompts/_50_recall_memories.py
+6 -5
@@ -6,7 +6,7 @@ from agent import LoopData
6 class RecallMemories(Extension):
7
8 INTERVAL = 3
9 - HISTORY = 5
9 + HISTORY = 5 # TODO cleanup
10 RESULTS = 3
11 THRESHOLD = 0.6
12
@@ -31,9 +31,10 @@ class RecallMemories(Extension):
31 )
32
33 # get system message and chat history for util llm
34 - msgs_text = self.agent.concat_messages(
35 - self.agent.history[-RecallMemories.HISTORY :]
36 - ) # only last X messages
34 + # msgs_text = self.agent.concat_messages(
35 + # self.agent.history[-RecallMemories.HISTORY :]
36 + # ) # only last X messages
37 + msgs_text = self.agent.history.current.output_text()
38 system = self.agent.read_prompt(
39 "memory.memories_query.sys.md", history=msgs_text
40 )
@@ -44,7 +45,7 @@ class RecallMemories(Extension):
45
46 # call util llm to summarize conversation
47 query = await self.agent.call_utility_llm(
47 - system=system, msg=loop_data.message, callback=log_callback
48 + system=system, msg=loop_data.user_message.output_text() if loop_data.user_message else "", callback=log_callback
49 )
50
51 # get solutions database
python/extensions/message_loop_prompts/_51_recall_solutions.py
+6 -5
@@ -6,7 +6,7 @@ from agent import LoopData
6 class RecallSolutions(Extension):
7
8 INTERVAL = 3
9 - HISTORY = 5
9 + HISTORY = 5 # TODO cleanup
10 SOLUTIONS_COUNT = 2
11 INSTRUMENTS_COUNT = 2
12 THRESHOLD = 0.6
@@ -32,9 +32,10 @@ class RecallSolutions(Extension):
32 )
33
34 # get system message and chat history for util llm
35 - msgs_text = self.agent.concat_messages(
36 - self.agent.history[-RecallSolutions.HISTORY :]
37 - ) # only last X messages
35 + # msgs_text = self.agent.concat_messages(
36 + # self.agent.history[-RecallSolutions.HISTORY :]
37 + # ) # only last X messages
38 + msgs_text = self.agent.history.current.output_text()
39 system = self.agent.read_prompt(
40 "memory.solutions_query.sys.md", history=msgs_text
41 )
@@ -45,7 +46,7 @@ class RecallSolutions(Extension):
46
47 # call util llm to summarize conversation
48 query = await self.agent.call_utility_llm(
48 - system=system, msg=loop_data.message, callback=log_callback
49 + system=system, msg=loop_data.user_message.output_text() if loop_data.user_message else "", callback=log_callback
50 )
51
52 # get solutions database
python/extensions/message_loop_prompts/_90_organize_history_wait.py new
+34
@@ -0,0 +1,34 @@
1 +from python.helpers.extension import Extension
2 +from agent import LoopData
3 +from python.extensions.message_loop_end._10_organize_history import DATA_NAME_TASK
4 +import asyncio
5 +
6 +
7 +class OrganizeHistoryWait(Extension):
8 + async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
9 +
10 + # sync action only required if the history is too large, otherwise leave it in background
11 + while self.agent.history.is_over_limit():
12 + # get task
13 + task = self.agent.get_data(DATA_NAME_TASK)
14 +
15 + # Check if the task is already done
16 + if task:
17 + if not task.done():
18 + self.log()
19 +
20 + # Wait for the task to complete
21 + await task
22 +
23 + # Clear the coroutine data after it's done
24 + self.agent.set_data(DATA_NAME_TASK, None)
25 + else:
26 + # no task running, start and wait
27 + self.log()
28 + await self.agent.history.compress()
29 +
30 + def log(self):
31 + if not hasattr(self, 'log_item') or not self.log_item:
32 + self.log_item = self.agent.context.log.log(
33 + type="util", heading="Waiting for history to be compressed..."
34 + )
python/helpers/errors.py
+1 -1
@@ -7,7 +7,7 @@ def handle_error(e: Exception):
7 if isinstance(e, asyncio.CancelledError):
8 raise e
9
10 -def format_error(e: Exception, max_entries=2):
10 +def format_error(e: Exception, max_entries=4):
11 traceback_text = traceback.format_exc()
12 # Split the traceback into lines
13 lines = traceback_text.split('\n')
python/helpers/files.py
+130 -26
@@ -1,45 +1,110 @@
1 from fnmatch import fnmatch
2 +import json
3 import os, re
4
5 import re
6
6 -def read_file(relative_path, backup_dirs=None, encoding="utf-8", **kwargs):
7 - if backup_dirs is None:
8 - backup_dirs = []
7 +
8 +def parse_file(_relative_path, _backup_dirs=None, _encoding="utf-8", **kwargs):
9 + content = read_file(_relative_path, _backup_dirs, _encoding)
10 + is_json = is_full_json_template(content)
11 + content = remove_code_fences(content)
12 + if is_json:
13 + content = replace_placeholders_json(content, **kwargs)
14 + obj = json.loads(content)
15 + # obj = replace_placeholders_dict(obj, **kwargs)
16 + return obj
17 + else:
18 + content = replace_placeholders_text(content, **kwargs)
19 + return content
20 +
21 +
22 +def read_file(_relative_path, _backup_dirs=None, _encoding="utf-8", **kwargs):
23 + if _backup_dirs is None:
24 + _backup_dirs = []
25
26 # Try to get the absolute path for the file from the original directory or backup directories
11 - absolute_path = find_file_in_dirs(relative_path, backup_dirs)
27 + absolute_path = find_file_in_dirs(_relative_path, _backup_dirs)
28
29 # Read the file content
14 - with open(absolute_path, 'r', encoding=encoding) as f:
15 - content = remove_code_fences(f.read())
30 + with open(absolute_path, "r", encoding=_encoding) as f:
31 + # content = remove_code_fences(f.read())
32 + content = f.read()
33
34 # Replace placeholders with values from kwargs
18 - for key, value in kwargs.items():
19 - placeholder = "{{" + key + "}}"
20 - strval = str(value)
21 - content = content.replace(placeholder, strval)
35 + content = replace_placeholders_text(content, **kwargs)
36
37 # Process include statements
24 - content = process_includes(content, os.path.dirname(relative_path), backup_dirs, **kwargs)
38 + content = process_includes(
39 + content, os.path.dirname(_relative_path), _backup_dirs, **kwargs
40 + )
41
42 return content
43
28 -def process_includes(content, base_path, backup_dirs, **kwargs):
44 +
45 +def replace_placeholders_text(_content: str, **kwargs):
46 + # Replace placeholders with values from kwargs
47 + for key, value in kwargs.items():
48 + placeholder = "{{" + key + "}}"
49 + strval = str(value)
50 + _content = _content.replace(placeholder, strval)
51 + return _content
52 +
53 +def replace_placeholders_json(_content: str, **kwargs):
54 + # Replace placeholders with values from kwargs
55 + for key, value in kwargs.items():
56 + placeholder = "{{" + key + "}}"
57 + strval = json.dumps(value)
58 + _content = _content.replace(placeholder, strval)
59 + return _content
60 +
61 +def replace_placeholders_dict(_content: dict, **kwargs):
62 + def replace_value(value):
63 + if isinstance(value, str):
64 + placeholders = re.findall(r"{{(\w+)}}", value)
65 + if placeholders:
66 + for placeholder in placeholders:
67 + if placeholder in kwargs:
68 + replacement = kwargs[placeholder]
69 + if value == f"{{{{{placeholder}}}}}":
70 + return replacement
71 + elif isinstance(replacement, (dict, list)):
72 + value = value.replace(
73 + f"{{{{{placeholder}}}}}", json.dumps(replacement)
74 + )
75 + else:
76 + value = value.replace(
77 + f"{{{{{placeholder}}}}}", str(replacement)
78 + )
79 + return value
80 + elif isinstance(value, dict):
81 + return {k: replace_value(v) for k, v in value.items()}
82 + elif isinstance(value, list):
83 + return [replace_value(item) for item in value]
84 + else:
85 + return value
86 +
87 + return replace_value(_content)
88 +
89 +
90 +def process_includes(_content, _base_path, _backup_dirs, **kwargs):
91 # Regex to find {{ include 'path' }} or {{include'path'}}
92 include_pattern = re.compile(r"{{\s*include\s*['\"](.*?)['\"]\s*}}")
93
94 def replace_include(match):
95 include_path = match.group(1)
96 # First attempt to resolve the include relative to the base path
35 - full_include_path = find_file_in_dirs(os.path.join(base_path, include_path), backup_dirs)
36 -
97 + full_include_path = find_file_in_dirs(
98 + os.path.join(_base_path, include_path), _backup_dirs
99 + )
100 +
101 # Recursively read the included file content, keeping the original base path
38 - included_content = read_file(full_include_path, backup_dirs, **kwargs)
102 + included_content = read_file(full_include_path, _backup_dirs, **kwargs)
103 return included_content
104
105 # Replace all includes with the file content
42 - return re.sub(include_pattern, replace_include, content)
106 + return re.sub(include_pattern, replace_include, _content)
107 +
108
109 def find_file_in_dirs(file_path, backup_dirs):
110 """
@@ -58,43 +123,82 @@ def find_file_in_dirs(file_path, backup_dirs):
123 return get_abs_path(backup_path)
124
125 # If the file is not found, let it raise the FileNotFoundError
61 - raise FileNotFoundError(f"File '{file_path}' not found in the original path or backup directories.")
126 + raise FileNotFoundError(
127 + f"File '{file_path}' not found in the original path or backup directories."
128 + )
129 +
130 +
131 +import re
132 +
133
134 def remove_code_fences(text):
64 - return re.sub(r'~~~\w*\n|~~~', '', text)
135 + # Pattern to match code fences with optional language specifier
136 + pattern = r"(```|~~~)(.*?\n)(.*?)(\1)"
137 +
138 + # Function to replace the code fences
139 + def replacer(match):
140 + return match.group(3) # Return the code without fences
141 +
142 + # Use re.DOTALL to make '.' match newlines
143 + result = re.sub(pattern, replacer, text, flags=re.DOTALL)
144 +
145 + return result
146 +
147 +
148 +import re
149 +
150
66 -def write_file(relative_path:str, content:str, encoding:str="utf-8"):
151 +def is_full_json_template(text):
152 + # Pattern to match the entire text enclosed in ```json or ~~~json fences
153 + pattern = r"^\s*(```|~~~)\s*json\s*\n(.*?)\n\1\s*$"
154 + # Use re.DOTALL to make '.' match newlines
155 + match = re.fullmatch(pattern, text.strip(), flags=re.DOTALL)
156 + return bool(match)
157 +
158 +
159 +def write_file(relative_path: str, content: str, encoding: str = "utf-8"):
160 abs_path = get_abs_path(relative_path)
161 os.makedirs(os.path.dirname(abs_path), exist_ok=True)
69 - with open(abs_path, 'w', encoding=encoding) as f:
162 + with open(abs_path, "w", encoding=encoding) as f:
163 f.write(content)
164
72 -def delete_file(relative_path:str):
165 +
166 +def delete_file(relative_path: str):
167 abs_path = get_abs_path(relative_path)
168 if os.path.exists(abs_path):
169 os.remove(abs_path)
170
77 -def list_files(relative_path:str, filter:str="*"):
171 +
172 +def list_files(relative_path: str, filter: str = "*"):
173 abs_path = get_abs_path(relative_path)
174 if not os.path.exists(abs_path):
175 return []
176 return [file for file in os.listdir(abs_path) if fnmatch(file, filter)]
177
178 +
179 def get_abs_path(*relative_paths):
180 return os.path.join(get_base_dir(), *relative_paths)
181
182 +
183 def exists(*relative_paths):
184 path = get_abs_path(*relative_paths)
185 return os.path.exists(path)
186
187 +
188 def get_base_dir():
189 # Get the base directory from the current file path
92 - base_dir = os.path.dirname(os.path.abspath(os.path.join(__file__,"../../")))
190 + base_dir = os.path.dirname(os.path.abspath(os.path.join(__file__, "../../")))
191 return base_dir
192
95 -def get_subdirectories(relative_path:str, include:str="*", exclude=None):
193 +
194 +def get_subdirectories(relative_path: str, include: str = "*", exclude=None):
195 abs_path = get_abs_path(relative_path)
196 if not os.path.exists(abs_path):
197 return []
99 - return [subdir for subdir in os.listdir(abs_path) if os.path.isdir(os.path.join(abs_path, subdir)) and fnmatch(subdir, include) and (exclude is None or not fnmatch(subdir, exclude))]
100 -
198 + return [
199 + subdir
200 + for subdir in os.listdir(abs_path)
201 + if os.path.isdir(os.path.join(abs_path, subdir))
202 + and fnmatch(subdir, include)
203 + and (exclude is None or not fnmatch(subdir, exclude))
204 + ]
python/helpers/history.py
+451 -23
@@ -1,40 +1,468 @@
1 from abc import abstractmethod
2 -from python.helpers import tokens
2 +import asyncio
3 +from collections import OrderedDict
4 +import json
5 +import math
6 +from typing import Coroutine, Literal, TypedDict, cast
7 +from python.helpers import messages, tokens, settings, call_llm
8 +from enum import Enum
9 +from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
10
4 -class Record():
5 - def __init__(self):
11 +BULK_MERGE_COUNT = 3
12 +TOPICS_KEEP_COUNT = 3
13 +CURRENT_TOPIC_RATIO = 0.5
14 +HISTORY_TOPIC_RATIO = 0.3
15 +HISTORY_BULK_RATIO = 0.2
16 +TOPIC_COMPRESS_RATIO = 0.65
17 +LARGE_MESSAGE_TO_TOPIC_RATIO = 0.25
18 +
19 +OutputType = (
20 + list["OutputType"]
21 + | OrderedDict[str, "OutputType"]
22 + | list[OrderedDict[str, "OutputType"]]
23 + | str
24 + | list[str]
25 +)
26 +
27 +
28 +class OutputMessage(TypedDict):
29 + ai: bool
30 + content: OutputType
31 +
32 +
33 +class Record:
34 + def __init__(self):
35 pass
36
8 - @abstractmethod
37 def get_tokens(self) -> int:
38 + out = self.output_text()
39 + return tokens.approximate_tokens(out)
40 +
41 + @abstractmethod
42 + async def compress(self) -> bool:
43 + pass
44 +
45 + @abstractmethod
46 + def output(self) -> list[OutputMessage]:
47 pass
48
49 + @abstractmethod
50 + async def summarize(self) -> str:
51 + pass
52 +
53 + @abstractmethod
54 + def to_dict(self) -> dict:
55 + pass
56 +
57 + @staticmethod
58 + def from_dict(data: dict, history: "History"):
59 + cls = data["_cls"]
60 + return globals()[cls].from_dict(data, history=history)
61 +
62 + def output_langchain(self):
63 + return output_langchain(self.output())
64 +
65 + def output_text(self, human_label="user", ai_label="ai"):
66 + return output_text(self.output(), ai_label, human_label)
67 +
68 +
69 class Message(Record):
13 - def __init__(self):
14 - self.segments: list[str]
15 - self.human: bool
70 + def __init__(self, ai: bool, text: str | None = None, **kwargs: OutputType):
71 + self.ai = ai
72 + self.text = text
73 + self.kwargs: OrderedDict[str, OutputType] = OrderedDict(**kwargs)
74 + self.summary: OutputType = ""
75
76 + async def compress(self):
77 + return False
78
18 -class Monologue:
19 - def __init__(self):
79 + def output(self):
80 + return [
81 + OutputMessage(ai=self.ai, content=self.summary or self.text or self.kwargs)
82 + ]
83 +
84 + def output_langchain(self):
85 + return output_langchain(self.output())
86 +
87 + def output_text(self, human_label="user", ai_label="ai"):
88 + return output_text(self.output(), ai_label, human_label)
89 +
90 + def to_dict(self):
91 + return {
92 + "_cls": "Message",
93 + "ai": self.ai,
94 + "text": self.text,
95 + "kwargs": self.kwargs,
96 + "summary": self.summary,
97 + }
98 +
99 + @staticmethod
100 + def from_dict(data: dict, history: "History"):
101 + msg = Message(ai=data["ai"], text=data["text"], **data["kwargs"])
102 + msg.summary = data["summary"]
103 + return msg
104 +
105 +
106 +class Topic(Record):
107 + def __init__(self, history: "History"):
108 + self.history = history
109 self.summary: str = ""
110 self.messages: list[Message] = []
111
23 - def finish(self):
24 - pass
112 + def add_message(self, ai: bool, text: str | None = None, **kwargs):
113 + msg = Message(ai=ai, text=text, **kwargs)
114 + self.messages.append(msg)
115 + return msg
116
117 + def output(self) -> list[OutputMessage]:
118 + if self.summary:
119 + return [OutputMessage(ai=False, content=self.summary)]
120 + else:
121 + msgs = [m for r in self.messages for m in r.output()]
122 + return group_outputs_abab(msgs)
123
27 -class History:
28 - def __init__(self):
29 - self.monologues: list[Monologue] = []
30 - self.messages: list[Message] = []
31 - self.start_monologue()
124 + async def summarize(self):
125 + self.summary = await self.summarize_messages(self.messages)
126 + return self.summary
127 +
128 + async def compress_large_messages(self) -> bool:
129 + set = settings.get_settings()
130 + msg_max_size = (
131 + set["chat_model_ctx_length"]
132 + * set["chat_model_ctx_history"]
133 + * HISTORY_TOPIC_RATIO
134 + * LARGE_MESSAGE_TO_TOPIC_RATIO
135 + )
136 + large_msgs = []
137 + for m in self.messages:
138 + out = m.output()
139 + text = output_text(out)
140 + tok = tokens.approximate_tokens(text)
141 + leng = len(text)
142 + if leng > msg_max_size:
143 + large_msgs.append((m, tok, leng, out))
144 + large_msgs.sort(key=lambda x: x[1], reverse=True)
145 + for msg, tok, leng, out in large_msgs:
146 + trim_to_chars = leng * (msg_max_size / tok)
147 + trunc = messages.truncate_dict_by_ratio(
148 + self.history.agent, out, trim_to_chars * 1.15, trim_to_chars * 0.85
149 + )
150 + msg.summary = trunc
151 +
152 + return True
153 + return False
154 +
155 + async def compress(self) -> bool:
156 + compress = await self.compress_large_messages()
157 + if not compress:
158 + compress = await self.compress_attention()
159 + return compress
160 +
161 + async def compress_attention(self) -> bool:
162 +
163 + if len(self.messages) > 2:
164 + cnt_to_sum = math.ceil((len(self.messages) - 2) * TOPIC_COMPRESS_RATIO)
165 + msg_to_sum = self.messages[1 : cnt_to_sum + 1]
166 + summary = await self.summarize_messages(msg_to_sum)
167 + sum_msg_args, sum_msg_kwargs = self.history.agent.parse_prompt(
168 + "fw.msg_summary.md", summary=summary
169 + )
170 + sum_msg = Message(False, *sum_msg_args, **sum_msg_kwargs)
171 + self.messages[1 : cnt_to_sum + 1] = [sum_msg]
172 + return True
173 + return False
174 +
175 + async def summarize_messages(self, messages: list[Message]):
176 + msg_txt = [m.output_text() for m in messages]
177 + summary = await call_llm.call_llm(
178 + system=self.history.agent.read_prompt("fw.topic_summary.sys.md"),
179 + message=self.history.agent.read_prompt(
180 + "fw.topic_summary.msg.md", content=msg_txt
181 + ),
182 + model=settings.get_utility_model(),
183 + )
184 + return summary
185 +
186 + def to_dict(self):
187 + return {
188 + "_cls": "Topic",
189 + "summary": self.summary,
190 + "messages": [m.to_dict() for m in self.messages],
191 + }
192 +
193 + @staticmethod
194 + def from_dict(data: dict, history: "History"):
195 + topic = Topic(history=history)
196 + topic.summary = data["summary"]
197 + topic.messages = [
198 + Message.from_dict(m, history=history) for m in data["messages"]
199 + ]
200 + return topic
201 +
202 +
203 +class Bulk(Record):
204 + def __init__(self, history: "History"):
205 + self.history = history
206 + self.summary: str = ""
207 + self.records: list[Record] = []
208 +
209 + def output(
210 + self, human_label: str = "user", ai_label: str = "ai"
211 + ) -> list[OutputMessage]:
212 + if self.summary:
213 + return [OutputMessage(ai=False, content=self.summary)]
214 + else:
215 + msgs = [m for r in self.records for m in r.output()]
216 + return group_outputs_abab(msgs)
217 +
218 + async def compress(self):
219 + return False
220 +
221 + async def summarize(self):
222 + self.summary = await call_llm.call_llm(
223 + system=self.history.agent.read_prompt("fw.topic_summary.sys.md"),
224 + message=self.history.agent.read_prompt(
225 + "fw.topic_summary.msg.md", content=self.output_text()
226 + ),
227 + model=settings.get_utility_model(),
228 + )
229 + return self.summary
230 +
231 + def to_dict(self):
232 + return {
233 + "_cls": "Bulk",
234 + "summary": self.summary,
235 + "records": [r.to_dict() for r in self.records],
236 + }
237 +
238 + @staticmethod
239 + def from_dict(data: dict, history: "History"):
240 + bulk = Bulk(history=history)
241 + bulk.summary = data["summary"]
242 + cls = data["_cls"]
243 + bulk.records = [Record.from_dict(r, history=history) for r in data["records"]]
244 + return bulk
245 +
246 +
247 +class History(Record):
248 + def __init__(self, agent):
249 + from agent import Agent
250 +
251 + self.bulks: list[Bulk] = []
252 + self.topics: list[Topic] = []
253 + self.current = Topic(history=self)
254 + self.agent: Agent = agent
255 +
256 + def is_over_limit(self):
257 + limit = get_ctx_size_for_history()
258 + total = self.get_tokens()
259 + return total > limit
260 +
261 + def get_bulks_tokens(self) -> int:
262 + return sum(record.get_tokens() for record in self.bulks)
263 +
264 + def get_topics_tokens(self) -> int:
265 + return sum(record.get_tokens() for record in self.topics)
266 +
267 + def get_current_topic_tokens(self) -> int:
268 + return self.current.get_tokens()
269 +
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, text: str | None = None, **kwargs: OutputType):
278 + return self.current.add_message(ai, text=text, **kwargs)
279 +
280 + def new_topic(self):
281 + if self.current.messages:
282 + self.topics.append(self.current)
283 + self.current = Topic(history=self)
284 +
285 + def output(self) -> list[OutputMessage]:
286 + result: list[OutputMessage] = []
287 + result += [m for b in self.bulks for m in b.output()]
288 + result += [m for t in self.topics for m in t.output()]
289 + result += self.current.output()
290 + result = group_outputs_abab(result)
291 + return result
292 +
293 + @staticmethod
294 + def from_dict(data: dict, history: "History"):
295 + history.bulks = [Bulk.from_dict(b, history=history) for b in data["bulks"]]
296 + history.topics = [Topic.from_dict(t, history=history) for t in data["topics"]]
297 + history.current = Topic.from_dict(data["current"], history=history)
298 + return history
299 +
300 + def to_dict(self):
301 + return {
302 + "_cls": "History",
303 + "bulks": [b.to_dict() for b in self.bulks],
304 + "topics": [t.to_dict() for t in self.topics],
305 + "current": self.current.to_dict(),
306 + }
307 +
308 + def serialize(self):
309 + data = self.to_dict()
310 + return json.dumps(data)
311 +
312 + async def compress(self):
313 + curr, hist, bulk = (
314 + self.get_current_topic_tokens(),
315 + self.get_topics_tokens(),
316 + self.get_bulks_tokens(),
317 + )
318 + total = get_ctx_size_for_history()
319 + compressed = False
320 +
321 + # calculate ratios of individual parts
322 + ratios = [
323 + (curr, CURRENT_TOPIC_RATIO, "current_topic"),
324 + (hist, HISTORY_TOPIC_RATIO, "history_topic"),
325 + (bulk, HISTORY_BULK_RATIO, "history_bulk"),
326 + ]
327 + # start from the most oversized part and compress it
328 + ratios = sorted(ratios, key=lambda x: (x[0] / total) / x[1], reverse=True)
329 + for ratio in ratios:
330 + if ratio[0] > ratio[1] * total:
331 + over_part = ratio[2]
332 + if over_part == "current_topic":
333 + compressed = await self.current.compress()
334 + elif over_part == "history_topic":
335 + compressed = await self.compress_topics()
336 + else:
337 + compressed = await self.compress_bulks()
338 + # if part was compressed, stop the loop and try the whole function again, maybe no more compression is necessary
339 + if compressed:
340 + break
341 + else:
342 + break
343 +
344 + # try the whole function again to see if there is still a need for compression
345 + if compressed:
346 + await self.compress()
347 +
348 + return compressed
349 +
350 + async def compress_topics(self) -> bool:
351 + # summarize topics one by one
352 + for topic in self.topics:
353 + if not topic.summary:
354 + await topic.summarize()
355 + return True
356 +
357 + # move oldest topic to bulks and summarize
358 + for topic in self.topics:
359 + bulk = Bulk(history=self)
360 + bulk.records.append(topic)
361 + if topic.summary:
362 + bulk.summary = topic.summary
363 + else:
364 + await bulk.summarize()
365 + self.bulks.append(bulk)
366 + self.topics.remove(topic)
367 + return True
368 +
369 + async def compress_bulks(self):
370 + compressed = await self.merge_bulks_by(BULK_MERGE_COUNT)
371 + return compressed
372 +
373 + async def merge_bulks_by(self, count: int):
374 + if len(self.bulks) < count:
375 + return False
376 + bulks = await asyncio.gather(
377 + *[
378 + self.merge_bulks(self.bulks[i : i + count])
379 + for i in range(0, len(self.bulks), count)
380 + ]
381 + )
382 + self.bulks = bulks
383 + return True
384 +
385 + async def merge_bulks(self, bulks: list[Bulk]) -> Bulk:
386 + bulk = Bulk(history=self)
387 + bulk.records = cast(list[Record], bulks)
388 + await bulk.summarize()
389 + return bulk
390 +
391 +
392 +def deserialize_history(json_data: str, agent) -> History:
393 + history = History(agent=agent)
394 + if json_data:
395 + data = json.loads(json_data)
396 + history = History.from_dict(data, history=history)
397 + return history
398 +
399 +
400 +def get_ctx_size_for_history() -> int:
401 + set = settings.get_settings()
402 + return int(set["chat_model_ctx_length"] * set["chat_model_ctx_history"])
403 +
404 +
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"])}'
407 +
408 +
409 +def serialize_content(content: OutputType) -> str:
410 + if isinstance(content, str):
411 + return content
412 + return json.dumps(content)
413 +
414 +
415 +def group_outputs_abab(outputs: list[OutputMessage]) -> list[OutputMessage]:
416 + result = []
417 + for out in outputs:
418 + if result and result[-1]["ai"] == out["ai"]:
419 + result[-1] = OutputMessage(
420 + ai=result[-1]["ai"],
421 + content=merge_outputs(result[-1]["content"], out["content"]),
422 + )
423 + else:
424 + result.append(out)
425 + return result
426 +
427 +
428 +def output_langchain(messages: list[OutputMessage]):
429 + result = []
430 + for m in messages:
431 + if m["ai"]:
432 + result.append(AIMessage(content=serialize_content(m["content"])))
433 + else:
434 + result.append(HumanMessage(content=serialize_content(m["content"])))
435 + return result
436 +
437 +
438 +def output_text(messages: list[OutputMessage], ai_label="ai", human_label="human"):
439 + return "\n".join(serialize_output(o, ai_label, human_label) for o in messages)
440 +
441 +
442 +def merge_outputs(a: OutputType, b: OutputType) -> OutputType:
443 + if not isinstance(a, list):
444 + a = [a]
445 + if not isinstance(b, list):
446 + b = [b]
447 + return a + b # type: ignore
448 + # return merge_properties(a, b)
449
33 - def current_monologue(self):
34 - return self.monologues[-1]
450
36 - def start_monologue(self):
37 - if self.monologues:
38 - self.current_monologue().finish()
39 - self.monologues.append(Monologue())
40 - return self.current_monologue()
451 +def merge_properties(a: OutputType, b: OutputType) -> OutputType:
452 + if isinstance(a, list):
453 + if isinstance(b, list):
454 + return a + b # type: ignore
455 + else:
456 + return a + [b]
457 + elif isinstance(b, list):
458 + return [a] + b # type: ignore
459 + elif isinstance(a, dict) and isinstance(b, dict):
460 + for key, value in b.items():
461 + if key in a:
462 + a[key] = merge_properties(a[key], value)
463 + else:
464 + a[key] = value
465 + return a
466 + elif isinstance(a, str) and isinstance(b, str):
467 + return a + b
468 + raise ValueError(f"Cannot merge {a} and {b}")
python/helpers/messages.py
+62 -2
@@ -1,15 +1,75 @@
1 # from . import files
2
3 +import json
4 +
5 +
6 def truncate_text(agent, output, threshold=1000):
7 + threshold = int(threshold)
8 if len(output) <= threshold:
9 return output
10
11 # Adjust the file path as needed
8 - placeholder = agent.read_prompt("fw.msg_truncated.md", length=(len(output) - threshold))
12 + placeholder = agent.read_prompt(
13 + "fw.msg_truncated.md", length=(len(output) - threshold)
14 + )
15 # placeholder = files.read_file("./prompts/default/fw.msg_truncated.md", length=(len(output) - threshold))
16
17 start_len = (threshold - len(placeholder)) // 2
18 end_len = threshold - len(placeholder) - start_len
19
20 truncated_output = output[:start_len] + placeholder + output[-end_len:]
15 - return truncated_output
\ No newline at end of file
21 + return truncated_output
22 +
23 +
24 +def truncate_dict_by_ratio(agent, data: dict|list|str, threshold_chars: int, truncate_to: int):
25 + threshold_chars = int(threshold_chars)
26 + truncate_to = int(truncate_to)
27 +
28 + def process_item(item):
29 + if isinstance(item, dict):
30 + truncated_dict = {}
31 + cumulative_size = 0
32 +
33 + for key, value in item.items():
34 + processed_value = process_item(value)
35 + serialized_value = json.dumps(processed_value, ensure_ascii=False)
36 + size = len(serialized_value)
37 +
38 + if cumulative_size + size > threshold_chars:
39 + truncated_dict[key] = truncate_text(
40 + agent, serialized_value, truncate_to
41 + )
42 + else:
43 + cumulative_size += size
44 + truncated_dict[key] = processed_value
45 +
46 + return truncated_dict
47 +
48 + elif isinstance(item, list):
49 + truncated_list = []
50 + cumulative_size = 0
51 +
52 + for value in item:
53 + processed_value = process_item(value)
54 + serialized_value = json.dumps(processed_value, ensure_ascii=False)
55 + size = len(serialized_value)
56 +
57 + if cumulative_size + size > threshold_chars:
58 + truncated_list.append(
59 + truncate_text(agent, serialized_value, truncate_to)
60 + )
61 + else:
62 + cumulative_size += size
63 + truncated_list.append(processed_value)
64 +
65 + return truncated_list
66 +
67 + elif isinstance(item, str):
68 + if len(item) > threshold_chars:
69 + return truncate_text(agent, item, truncate_to)
70 + return item
71 +
72 + else:
73 + return item
74 +
75 + return process_item(data)
python/helpers/persist_chat.py
+45 -42
@@ -2,7 +2,7 @@ from collections import OrderedDict
2 from typing import Any
3 import uuid
4 from agent import Agent, AgentConfig, AgentContext, HumanMessage, AIMessage
5 -from python.helpers import files
5 +from python.helpers import files, history
6 import json
7 from initialize import initialize
8
@@ -18,6 +18,7 @@ def save_tmp_chat(context: AgentContext):
18 js = _safe_json_serialize(data, ensure_ascii=False)
19 files.write_file(relative_path, js)
20
21 +
22 def load_tmp_chats():
23 json_files = files.list_files("tmp/chats", "*.json")
24 ctxids = []
@@ -29,20 +30,24 @@ def load_tmp_chats():
30 ctxids.append(ctx.id)
31 return ctxids
32
33 +
34 def load_json_chats(jsons: list[str]):
35 ctxids = []
36 for js in jsons:
37 data = json.loads(js)
36 - if "id" in data: del data["id"] # remove id to get new
38 + if "id" in data:
39 + del data["id"] # remove id to get new
40 ctx = _deserialize_context(data)
41 ctxids.append(ctx.id)
42 return ctxids
43
44 +
45 def export_json_chat(context: AgentContext):
46 data = _serialize_context(context)
47 js = _safe_json_serialize(data, ensure_ascii=False)
48 return js
49
50 +
51 def remove_chat(ctxid):
52 files.delete_file(_get_file_path(ctxid))
53
@@ -57,7 +62,7 @@ def _serialize_context(context: AgentContext):
62 agent = context.agent0
63 while agent:
64 agents.append(_serialize_agent(agent))
60 - agent = agent.data.get("subordinate", None)
65 + agent = agent.data.get(Agent.DATA_NAME_SUBORDINATE, None)
66
67 return {
68 "id": context.id,
@@ -70,15 +75,9 @@ def _serialize_context(context: AgentContext):
75
76
77 def _serialize_agent(agent: Agent):
73 - data = {**agent.data}
74 - if "superior" in data:
75 - del data["superior"]
76 - if "subordinate" in data:
77 - del data["subordinate"]
78 + data = {k: v for k, v in agent.data.items() if not k.startswith("_")}
79
79 - history = []
80 - for msg in agent.history:
81 - history.append({"type": msg.type, "content": msg.content})
80 + history = agent.history.serialize()
81
82 return {
83 "number": agent.number,
@@ -90,8 +89,9 @@ def _serialize_agent(agent: Agent):
89 def _serialize_log(log: Log):
90 return {
91 "guid": log.guid,
93 - "logs": [item.output() for item in log.logs[-LOG_SIZE:]]
94 -, # serialize LogItem objects
92 + "logs": [
93 + item.output() for item in log.logs[-LOG_SIZE:]
94 + ], # serialize LogItem objects
95 "progress": log.progress,
96 "progress_no": log.progress_no,
97 }
@@ -103,7 +103,7 @@ def _deserialize_context(data):
103
104 context = AgentContext(
105 config=config,
106 - id=data.get("id", None), #get new id
106 + id=data.get("id", None), # get new id
107 name=data.get("name", None),
108 log=log,
109 paused=False,
@@ -115,8 +115,8 @@ def _deserialize_context(data):
115 agent0 = _deserialize_agents(agents, config, context)
116 streaming_agent = agent0
117 while streaming_agent.number != data.get("streaming_agent", 0):
118 - streaming_agent = streaming_agent.data.get("subordinate", None)
119 -
118 + streaming_agent = streaming_agent.data.get(Agent.DATA_NAME_SUBORDINATE, None)
119 +
120 context.agent0 = agent0
121 context.streaming_agent = streaming_agent
122
@@ -136,53 +136,56 @@ def _deserialize_agents(
136 context=context,
137 )
138 current.data = ag.get("data", {})
139 - current.history = _deserialize_history(ag.get("history", []))
140 -
139 + current.history = history.deserialize_history(
140 + ag.get("history", ""), agent=current
141 + )
142 if not zero:
143 zero = current
144
145 if prev:
145 - prev.set_data("subordinate", current)
146 - current.set_data("superior", prev)
146 + prev.set_data(Agent.DATA_NAME_SUBORDINATE, current)
147 + current.set_data(Agent.DATA_NAME_SUPERIOR, prev)
148 prev = current
149
150 return zero or Agent(0, config, context)
151
152
152 -def _deserialize_history(history: list[dict[str, Any]]):
153 - result = []
154 - for hist in history:
155 - content = hist.get("content", "")
156 - msg = (
157 - HumanMessage(content=content)
158 - if hist.get("type") == "human"
159 - else AIMessage(content=content)
160 - )
161 - result.append(msg)
162 - return result
153 +# def _deserialize_history(history: list[dict[str, Any]]):
154 +# result = []
155 +# for hist in history:
156 +# content = hist.get("content", "")
157 +# msg = (
158 +# HumanMessage(content=content)
159 +# if hist.get("type") == "human"
160 +# else AIMessage(content=content)
161 +# )
162 +# result.append(msg)
163 +# return result
164
165
166 def _deserialize_log(data: dict[str, Any]) -> "Log":
167 log = Log()
168 log.guid = data.get("guid", str(uuid.uuid4()))
168 - log.progress = "" #data.get("progress", "")
169 + log.progress = "" # data.get("progress", "")
170 log.progress_no = data.get("progress_no", 0)
171
172 # Deserialize the list of LogItem objects
173 i = 0
174 for item_data in data.get("logs", []):
174 - log.logs.append(LogItem(
175 - log=log, # restore the log reference
176 - no=item_data["no"],
177 - type=item_data["type"],
178 - heading=item_data.get("heading", ""),
179 - content=item_data.get("content", ""),
180 - kvps=OrderedDict(item_data["kvps"]) if item_data["kvps"] else None,
181 - temp=item_data.get("temp", False),
182 - ))
175 + log.logs.append(
176 + LogItem(
177 + log=log, # restore the log reference
178 + no=item_data["no"],
179 + type=item_data["type"],
180 + heading=item_data.get("heading", ""),
181 + content=item_data.get("content", ""),
182 + kvps=OrderedDict(item_data["kvps"]) if item_data["kvps"] else None,
183 + temp=item_data.get("temp", False),
184 + )
185 + )
186 log.updates.append(i)
187 i += 1
185 -
188 +
189 return log
190
191
python/helpers/rate_limiter.py
+1
@@ -51,6 +51,7 @@ class RateLimiter:
51 if wait_time > 0:
52 PrintStyle(font_color="yellow", padding=True).print(f"Rate limit exceeded. Waiting for {wait_time:.2f} seconds due to: {', '.join(wait_reasons)}")
53 self.logger.log("rate_limit","Rate limit exceeded",f"Rate limit exceeded. Waiting for {wait_time:.2f} seconds due to: {', '.join(wait_reasons)}")
54 + # TODO rate limit log type
55 time.sleep(wait_time)
56 current_time = time.time()
57
python/helpers/settings.py
+35 -2
@@ -15,6 +15,8 @@ class Settings(TypedDict):
15 chat_model_name: str
16 chat_model_temperature: float
17 chat_model_kwargs: dict[str, str]
18 + chat_model_ctx_length: int
19 + chat_model_ctx_history: float
20
21 util_model_provider: str
22 util_model_name: str
@@ -120,6 +122,28 @@ def convert_out(settings: Settings) -> SettingsOutput:
122 }
123 )
124
125 + chat_model_fields.append(
126 + {
127 + "id": "chat_model_ctx_length",
128 + "title": "Chat model context length",
129 + "description": "Maximum number of tokens in the context window for LLM. System prompt, chat history, RAG and response all count towards this limit.",
130 + "type": "input",
131 + "value": settings["chat_model_ctx_length"],
132 + })
133 +
134 + chat_model_fields.append(
135 + {
136 + "id": "chat_model_ctx_history",
137 + "title": "Context window space for chat history.",
138 + "description": "Portion of context window dedicated to chat history visible to the agent. Chat history will automatically be optimized to fit. Smaller size will result in shorter and more summarized history. The remaining space will be used for system prompt, RAG and response.",
139 + "type": "range",
140 + "min": 0.01,
141 + "max": 1,
142 + "step": 0.01,
143 + "value": settings["chat_model_ctx_history"],
144 + })
145 +
146 +
147 chat_model_section: SettingsSection = {
148 "title": "Chat Model",
149 "description": "Selection and settings for main chat model used by Agent Zero",
@@ -434,7 +458,8 @@ def get_settings() -> Settings:
458 _settings = _read_settings_file()
459 if not _settings:
460 _settings = _get_default_settings()
437 - return _settings.copy()
461 + norm = normalize_settings(_settings)
462 + return norm
463
464
465 def set_settings(settings: Settings):
@@ -450,9 +475,15 @@ def normalize_settings(settings: Settings) -> Settings:
475 for key, value in default.items():
476 if key not in copy:
477 copy[key] = value
478 + else:
479 + try:
480 + copy[key] = type(value)(copy[key]) # type: ignore
481 + except (ValueError, TypeError):
482 + pass
483 return copy
484
485
486 +
487 def get_chat_model(settings: Settings | None = None) -> BaseChatModel:
488 if not settings:
489 settings = get_settings()
@@ -525,6 +556,8 @@ def _get_default_settings() -> Settings:
556 chat_model_name="gpt-4o-mini",
557 chat_model_temperature=0,
558 chat_model_kwargs={},
559 + chat_model_ctx_length=8192,
560 + chat_model_ctx_history=0.65,
561 util_model_provider=ModelProvider.OPENAI.name,
562 util_model_name="gpt-4o-mini",
563 util_model_temperature=0,
@@ -555,7 +588,7 @@ def _apply_settings():
588 agent = ctx.agent0
589 while agent:
590 agent.config = ctx.config
558 - agent = agent.get_data("subordinate")
591 + agent = agent.get_data(agent.DATA_NAME_SUBORDINATE)
592
593
594 def _env_to_dict(data: str):
python/helpers/tool.py
+1 -2
@@ -32,8 +32,7 @@ class Tool:
32
33 async def after_execution(self, response: Response, **kwargs):
34 text = messages.truncate_text(self.agent, response.message.strip(), self.agent.config.max_tool_response_length)
35 - msg_response = self.agent.read_prompt("fw.tool_response.md", tool_name=self.name, tool_response=text)
36 - await self.agent.append_message(msg_response, human=True)
35 + await self.agent.hist_add_tool_result(self.name, text)
36 PrintStyle(font_color="#1B4F72", background_color="white", padding=True, bold=True).print(f"{self.agent.agent_name}: Response from tool '{self.name}'")
37 PrintStyle(font_color="#85C1E9").print(response.message)
38 self.log.update(content=response.message)
python/tools/call_subordinate.py
+19 -7
@@ -1,14 +1,26 @@
1 from agent import Agent
2 from python.helpers.tool import Tool, Response
3
4 +
5 class Delegation(Tool):
6
7 async def execute(self, message="", reset="", **kwargs):
8 # create subordinate agent using the data object on this agent and set superior agent to his data object
8 - if self.agent.get_data("subordinate") is None or str(reset).lower().strip() == "true":
9 - subordinate = Agent(self.agent.number+1, self.agent.config, self.agent.context)
10 - subordinate.set_data("superior", self.agent)
11 - self.agent.set_data("subordinate", subordinate)
12 - # run subordinate agent message loop
13 - subordinate: Agent = self.agent.get_data("subordinate")
14 - return Response( message= await subordinate.monologue(message), break_loop=False)
\ No newline at end of file
9 + if (
10 + self.agent.get_data(Agent.DATA_NAME_SUBORDINATE) is None
11 + or str(reset).lower().strip() == "true"
12 + ):
13 + sub = Agent(
14 + self.agent.number + 1, self.agent.config, self.agent.context
15 + )
16 + sub.set_data(Agent.DATA_NAME_SUPERIOR, self.agent)
17 + self.agent.set_data(Agent.DATA_NAME_SUBORDINATE, sub)
18 +
19 + # add user message to subordinate agent
20 + subordinate: Agent = self.agent.get_data(Agent.DATA_NAME_SUBORDINATE)
21 + template = self.agent.read_prompt("fw.call_subordinate.md", message=message)
22 + await subordinate.hist_add_user_message(template)
23 + # run subordinate monologue
24 + result = await subordinate.monologue()
25 + # result
26 + return Response(message=result, break_loop=False)
python/tools/code_execution_tool.py
+1 -4
@@ -72,10 +72,7 @@ class CodeExecution(Tool):
72 PrintStyle().print()
73
74 async def after_execution(self, response, **kwargs):
75 - msg_response = self.agent.read_prompt(
76 - "fw.tool_response.md", tool_name=self.name, tool_response=response.message
77 - )
78 - await self.agent.append_message(msg_response, human=True)
75 + await self.agent.hist_add_tool_result(self.name, response.message)
76
77 async def prepare_state(self, reset=False):
78 self.state = self.agent.get_data("cot_state")
run_ui.py
+2 -12
@@ -1,24 +1,14 @@
1 -import json
1 from functools import wraps
2 import os
4 -from pathlib import Path
3 import threading
6 -import uuid
7 -from flask import Flask, request, jsonify, Response, send_file
4 +from flask import Flask, request, Response
5 from flask_basicauth import BasicAuth
9 -from agent import AgentContext
10 -from initialize import initialize
6 from python.helpers import files, git
7 from python.helpers.files import get_abs_path
13 -from python.helpers.print_style import PrintStyle
14 -from python.helpers.dotenv import load_dotenv
15 -from python.helpers import persist_chat, settings, whisper, rfc, runtime, dotenv
16 -import base64
17 -from werkzeug.utils import secure_filename
8 +from python.helpers import persist_chat, runtime, dotenv
9 from python.helpers.cloudflare_tunnel import CloudflareTunnel
10 from python.helpers.extract_tools import load_classes_from_folder
11 from python.helpers.api import ApiHandler
21 -from python.helpers.file_browser import FileBrowser
12
13
14 # initialize the internal Flask server
webui/history.css new
+5
@@ -0,0 +1,5 @@
1 +#json-viewer-container{
2 + width: 100%;
3 + height: 60vh;
4 + overflow: auto;
5 +}
\ No newline at end of file
webui/history.js new
+41
@@ -0,0 +1,41 @@
1 +import { getContext } from "./index.js";
2 +
3 +export async function openHistoryModal() {
4 + const hist = await window.sendJsonData("/history_get", { context: getContext() });
5 + const data = JSON.stringify(hist.history, null, 4);
6 + const size = hist.tokens
7 + await showEditorModal(data, "json", `History ~${size} tokens`,"Conversation history how the agent can see it. History is compressed to fit into the context window.");
8 +}
9 +
10 +export async function openCtxWindowModal() {
11 + const win = await window.sendJsonData("/ctx_window_get", { context: getContext() });
12 + const data = win.content
13 + const size = win.tokens
14 + await showEditorModal(data, "text", `Context window ~${size} tokens`,"Data passed to the LLM during last interaction. Contains system message, conversation history and RAG.");
15 +}
16 +
17 +async function showEditorModal(data, type = "json", title, description="") {
18 + // Generate the HTML with JSON Viewer container
19 + const html = `<div id="json-viewer-container"></div>`;
20 +
21 + // Open the modal with the generated HTML
22 + await window.genericModalProxy.openModal(title, description, html);
23 +
24 + // Initialize the JSON Viewer after the modal is rendered
25 + const container = document.getElementById("json-viewer-container");
26 + if (container) {
27 + const editor = ace.edit("json-viewer-container");
28 +
29 + const dark = localStorage.getItem('darkMode')
30 + if (dark != "false") {
31 + editor.setTheme("ace/theme/monokai");
32 + }
33 +
34 + editor.session.setMode("ace/mode/" + type);
35 + editor.setValue(data);
36 + // editor.session.$toggleFoldWidget(5, {})
37 + }
38 +}
39 +
40 +window.openHistoryModal = openHistoryModal;
41 +window.openCtxWindowModal = openCtxWindowModal;
webui/index.html
+64 -15
@@ -10,6 +10,7 @@
10 <link rel="stylesheet" href="settings.css">
11 <link rel="stylesheet" href="file_browser.css">
12 <link rel="stylesheet" href="speech.css">
13 + <link rel="stylesheet" href="history.css">
14
15 <script>
16 window.safeCall = function (name, ...args) {
@@ -19,6 +20,7 @@
20
21 <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
22 <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
23 + <script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.12/ace.js" type="text/javascript" charset="utf-8"></script>
24
25 <!-- KaTeX CSS -->
26 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.css" crossorigin="anonymous">
@@ -31,7 +33,9 @@
33 <script type="module" src="index.js"></script>
34 <script type="text/javascript" src="settings.js"></script>
35 <script type="text/javascript" src="file_browser.js"></script>
36 + <script type="text/javascript" src="modal.js"></script>
37 <script type="module" src="speech.js"></script>
38 + <script type="module" src="history.js"></script>
39
40 </head>
41
@@ -305,21 +309,6 @@
309
310 <!-- Bottom row with text buttons -->
311 <div class="text-buttons-row">
308 - <button class="text-button" @click="loadKnowledge()"><svg xmlns="http://www.w3.org/2000/svg"
309 - fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
310 - <path stroke-linecap="round" stroke-linejoin="round"
311 - d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5">
312 - </path>
313 - </svg>Import knowledge</button>
314 - <button class="text-button" id="work_dir_browser" @click="fileBrowserModalProxy.openModal()">
315 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 123.37 92.59">
316 - <path
317 - d="m5.72,11.5l-3.93,8.73h119.77s-3.96-8.73-3.96-8.73h-60.03c-1.59,0-2.88-1.29-2.88-2.88V1.75H13.72v6.87c0,1.59-1.29,2.88-2.88,2.88h-5.12Z"
318 - fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="5"></path>
319 - <path
320 - d="m6.38,20.23H1.75l7.03,67.03c.11,1.07.55,2.02,1.2,2.69.55.55,1.28.89,2.11.89h97.1c.82,0,1.51-.33,2.05-.87.68-.68,1.13-1.67,1.28-2.79l9.1-66.94H6.38Z"
321 - fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="5"></path>
322 - </svg>work_dir Browser</button>
312
313 <button class="text-button" @click="pauseAgent(!paused)">
314 <!-- Dynamic path that switches between pause and play icons -->
@@ -338,6 +327,43 @@
327 </svg>
328 <span x-text="paused ? 'Resume Agent' : 'Pause Agent'"></span>
329 </button>
330 +
331 + <button class="text-button" @click="loadKnowledge()"><svg xmlns="http://www.w3.org/2000/svg"
332 + fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
333 + <path stroke-linecap="round" stroke-linejoin="round"
334 + d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5">
335 + </path>
336 + </svg>Import knowledge</button>
337 + <button class="text-button" id="work_dir_browser" @click="fileBrowserModalProxy.openModal()">
338 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 123.37 92.59">
339 + <path
340 + d="m5.72,11.5l-3.93,8.73h119.77s-3.96-8.73-3.96-8.73h-60.03c-1.59,0-2.88-1.29-2.88-2.88V1.75H13.72v6.87c0,1.59-1.29,2.88-2.88,2.88h-5.12Z"
341 + fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="5"></path>
342 + <path
343 + d="m6.38,20.23H1.75l7.03,67.03c.11,1.07.55,2.02,1.2,2.69.55.55,1.28.89,2.11.89h97.1c.82,0,1.51-.33,2.05-.87.68-.68,1.13-1.67,1.28-2.79l9.1-66.94H6.38Z"
344 + fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="5"></path>
345 + </svg>work_dir Browser</button>
346 +
347 + <button class="text-button" id="history_inspect" @click="window.openHistoryModal()">
348 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 123.37 92.59">
349 + <path
350 + d="m5.72,11.5l-3.93,8.73h119.77s-3.96-8.73-3.96-8.73h-60.03c-1.59,0-2.88-1.29-2.88-2.88V1.75H13.72v6.87c0,1.59-1.29,2.88-2.88,2.88h-5.12Z"
351 + fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="5"></path>
352 + <path
353 + d="m6.38,20.23H1.75l7.03,67.03c.11,1.07.55,2.02,1.2,2.69.55.55,1.28.89,2.11.89h97.1c.82,0,1.51-.33,2.05-.87.68-.68,1.13-1.67,1.28-2.79l9.1-66.94H6.38Z"
354 + fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="5"></path>
355 + </svg>History</button>
356 +
357 + <button class="text-button" id="ctx_window" @click="window.openCtxWindowModal()">
358 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 123.37 92.59">
359 + <path
360 + d="m5.72,11.5l-3.93,8.73h119.77s-3.96-8.73-3.96-8.73h-60.03c-1.59,0-2.88-1.29-2.88-2.88V1.75H13.72v6.87c0,1.59-1.29,2.88-2.88,2.88h-5.12Z"
361 + fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="5"></path>
362 + <path
363 + d="m6.38,20.23H1.75l7.03,67.03c.11,1.07.55,2.02,1.2,2.69.55.55,1.28.89,2.11.89h97.1c.82,0,1.51-.33,2.05-.87.68-.68,1.13-1.67,1.28-2.79l9.1-66.94H6.38Z"
364 + fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="5"></path>
365 + </svg>Context</button>
366 +
367 </div>
368 </div>
369 </div>
@@ -567,6 +593,29 @@
593 </template>
594 </div>
595
596 +<!-- generic modal -->
597 +
598 +<div id="genericModal" x-data="genericModalProxy">
599 + <template x-teleport="body">
600 + <div x-show="isOpen" class="modal-overlay" @click.self="handleClose()" @keydown.escape.window="handleClose()" x-transition>
601 + <div class="modal-container">
602 + <div class="modal-header">
603 + <h2 class="modal-title" x-text="title"></h2>
604 + <button class="modal-close" @click="handleClose()">&times;</button>
605 + </div>
606 + <div class="modal-description" x-text="description"></div>
607 + <div class="modal-content">
608 + <div class="html-pre" x-html="html"></div>
609 + </div>
610 + <!-- <div class="modal-footer">
611 + <div id="buttons-container">
612 + <button class="btn btn-cancel" @click="handleClose()">Close</button>
613 + </div>
614 + </div> -->
615 + </div>
616 + </template>
617 +</div>
618 +
619 </body>
620
621 </html>
\ No newline at end of file
webui/index.js
+5 -1
@@ -443,7 +443,7 @@ window.selectChat = async function (id) {
443 updateAfterScroll()
444 }
445
446 -const setContext = function (id) {
446 +export const setContext = function (id) {
447 if (id == context) return
448 context = id
449 lastLogGuid = ""
@@ -453,6 +453,10 @@ const setContext = function (id) {
453 chatsAD.selected = id
454 }
455
456 +export const getContext = function () {
457 + return context
458 +}
459 +
460 window.toggleAutoScroll = async function (_autoScroll) {
461 autoScroll = _autoScroll;
462 }
webui/modal.js new
+34
@@ -0,0 +1,34 @@
1 +const genericModalProxy = {
2 + isOpen: false,
3 + isLoading: false,
4 +
5 + async openModal(title, description, html) {
6 + const modalEl = document.getElementById('genericModal');
7 + const modalAD = Alpine.$data(modalEl);
8 +
9 + modalAD.isOpen = true;
10 + modalAD.title = title
11 + modalAD.description = description
12 + modalAD.html = html
13 + },
14 +
15 + handleClose() {
16 + this.isOpen = false;
17 + }
18 +}
19 +
20 +// Wait for Alpine to be ready
21 +document.addEventListener('alpine:init', () => {
22 + Alpine.data('genericModalProxy', () => ({
23 + init() {
24 + Object.assign(this, genericModalProxy);
25 + // Ensure immediate file fetch when modal opens
26 + this.$watch('isOpen', async (value) => {
27 + // what now?
28 + });
29 + }
30 + }));
31 +});
32 +
33 +// Keep the global assignment for backward compatibility
34 +window.genericModalProxy = genericModalProxy;
\ No newline at end of file