Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1012,8 +1012,11 @@ def get_tool(
loop_data: LoopData | None,
**kwargs,
):
from tools.unknown import Unknown
from helpers.tool import Tool
from helpers.tool import Tool, Response as _ToolResp

class Unknown(Tool):
async def execute(self, **kwargs):
return _ToolResp(message=self.agent.read_prompt("fw.tool_not_found.md", tool_name=self.name), break_loop=False)

classes = []

Expand Down
21 changes: 17 additions & 4 deletions api/chat_remove.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
from agent import AgentContext
from helpers import persist_chat
from helpers.task_scheduler import TaskScheduler
# P020: Archive-on-close patch — archives chat instead of permanent delete
from usr.plugins._core_patches.patches.p020_chat_archive_on_close import (
archive_chat_instead_of_delete,
)


class RemoveChat(ApiHandler):
Expand All @@ -16,8 +20,12 @@ async def process(self, input: Input, request: Request) -> Output:
# stop processing any tasks
context.reset()

# P020: Archive instead of delete — chat is moved to /a0/usr/chats-archive/
# Pinned chats are refused (must be unpinned first)
archive_result = archive_chat_instead_of_delete(ctxid)

# Only remove from AgentContext (in-memory), chat files are preserved in archive
AgentContext.remove(ctxid)
persist_chat.remove_chat(ctxid)

await scheduler.reload()

Expand All @@ -29,6 +37,11 @@ async def process(self, input: Input, request: Request) -> Output:
from helpers.state_monitor_integration import mark_dirty_all
mark_dirty_all(reason="api.chat_remove.RemoveChat")

return {
"message": "Context removed.",
}
if archive_result.get("archived"):
return {
"message": f"Chat archived: {archive_result.get('chat_name', ctxid)}",
}
else:
return {
"message": archive_result.get("message", "Context removed."),
}
54 changes: 51 additions & 3 deletions api/message_async.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,59 @@
from agent import AgentContext
from agent import AgentContext, UserMessage
from helpers.api import ApiHandler, Request, Response
from helpers import files, extension, message_queue as mq
import os
from helpers.security import safe_filename
from helpers.defer import DeferredTask
from api.message import Message


class MessageAsync(Message):
class MessageAsync(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
task, context = await self.communicate(input=input, request=request)
return await self.respond(task, context)

async def respond(self, task: DeferredTask, context: AgentContext):
return {
"message": "Message received.",
"context": context.id,
}

async def communicate(self, input: dict, request: Request):
if request.content_type.startswith("multipart/form-data"):
text = request.form.get("text", "")
ctxid = request.form.get("context", "")
message_id = request.form.get("message_id", None)
attachments = request.files.getlist("attachments")
attachment_paths = []

upload_folder_int = "/a0/usr/uploads"
upload_folder_ext = files.get_abs_path("usr/uploads")

if attachments:
os.makedirs(upload_folder_ext, exist_ok=True)
for attachment in attachments:
if attachment.filename is None:
continue
filename = safe_filename(attachment.filename)
if not filename:
continue
save_path = files.get_abs_path(upload_folder_ext, filename)
attachment.save(save_path)
attachment_paths.append(os.path.join(upload_folder_int, filename))
else:
input_data = request.get_json()
text = input_data.get("text", "")
ctxid = input_data.get("context", "")
message_id = input_data.get("message_id", None)
attachment_paths = []

message = text
context = self.use_context(ctxid)

data = {"message": message, "attachment_paths": attachment_paths}
await extension.call_extensions_async("user_message_ui", agent=context.get_agent(), data=data)
message = data.get("message", "")
attachment_paths = data.get("attachment_paths", [])

mq.log_user_message(context, message, attachment_paths, message_id)

return context.communicate(UserMessage(message=message, attachments=attachment_paths, id=message_id or "")), context
Empty file modified docker/run/fs/exe/trigger_self_update.sh
100755 → 100644
Empty file.
Empty file modified docs/res/a0-vector-graphics/a0LogoVector.ai
100755 → 100644
Empty file.
Empty file modified docs/res/a0-vector-graphics/dark.svg
100755 → 100644
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file modified docs/res/a0-vector-graphics/darkSymbol.svg
100755 → 100644
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file modified docs/res/a0-vector-graphics/light.svg
100755 → 100644
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file modified docs/res/a0-vector-graphics/lightSymbol.svg
100755 → 100644
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
193 changes: 182 additions & 11 deletions helpers/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,15 +227,98 @@ async def compress_attention(self, ratio: float = CURRENT_TOPIC_ATTENTION_COMPRE
self.messages[1 : cnt_to_sum + 1] = [sum_msg]
return True

async def summarize_messages(self, messages: list[Message]):
msg_txt = [m.output_text() for m in messages]
summary = await self.history.agent.call_utility_model(
async def summarize_messages(self, messages: list[Message]) -> str:
"""Summarize messages with token-aware chunking for small context models.

Splits messages into chunks of ~3500 tokens, summarizes each chunk,
then combines chunk summaries into a final summary. Handles edge cases
for empty lists, single messages, and oversized messages.
"""
CHUNK_TARGET_TOKENS = 3500
COMBINE_THRESHOLD = 3000

if not messages:
return ""

if len(messages) == 1:
return await self._summarize_chunk([messages[0].output_text()])

chunks = self._group_into_chunks(messages, CHUNK_TARGET_TOKENS)

if len(chunks) == 1:
return await self._summarize_chunk(chunks[0])

# Summarize chunks sequentially to avoid overloading the utility model
chunk_summaries: list[str] = []
for chunk in chunks:
try:
summary = await self._summarize_chunk(chunk)
chunk_summaries.append(summary if summary and summary.strip() else "...")
except Exception:
# Fallback: truncate the chunk text
combined = "\n".join(chunk)
words = combined.split()
chunk_summaries.append(" ".join(words[:100]) + ("..." if len(words) > 100 else ""))

return await self._combine_summaries(chunk_summaries, COMBINE_THRESHOLD)

def _group_into_chunks(
self, messages: list[Message], target_tokens: int
) -> list[list[str]]:
"""Group messages into chunks that stay within target_tokens."""
chunks: list[list[str]] = []
current_chunk: list[str] = []
current_tokens = 0

for msg in messages:
text = msg.output_text()
msg_tokens = msg.get_tokens()

if msg_tokens > target_tokens:
# Oversized message gets its own chunk
if current_chunk:
chunks.append(current_chunk)
chunks.append([text])
current_chunk = []
current_tokens = 0
elif current_tokens + msg_tokens > target_tokens and current_chunk:
chunks.append(current_chunk)
current_chunk = [text]
current_tokens = msg_tokens
else:
current_chunk.append(text)
current_tokens += msg_tokens

if current_chunk:
chunks.append(current_chunk)

return chunks

async def _summarize_chunk(self, chunk: list[str]) -> str:
"""Summarize a single chunk of message texts."""
return await self.history.agent.call_utility_model(
system=self.history.agent.read_prompt("fw.topic_summary.sys.md"),
message=self.history.agent.read_prompt(
"fw.topic_summary.msg.md", content=msg_txt
"fw.topic_summary.msg.md", content=chunk
),
)
return summary

async def _combine_summaries(
self, summaries: list[str], threshold: int
) -> str:
"""Combine chunk summaries into a final summary."""
valid = [s for s in summaries if s and s.strip()]
if not valid:
return "Summary unavailable."
if len(valid) == 1:
return valid[0]

combined = "\n".join([f"Part {i+1}: {s}" for i, s in enumerate(valid)])
combined_tokens = tokens.approximate_tokens(combined)

if combined_tokens > threshold:
return await self._summarize_chunk([combined])
return " ".join(valid)

def to_dict(self):
return {
Expand Down Expand Up @@ -279,12 +362,100 @@ async def compress(self):
return False

async def summarize(self):
self.summary = await self.history.agent.call_utility_model(
system=self.history.agent.read_prompt("fw.topic_summary.sys.md"),
message=self.history.agent.read_prompt(
"fw.topic_summary.msg.md", content=self.output_text()
),
)
"""Summarize records with token-aware chunking for small context models.

Splits record text into chunks of ~3500 tokens, summarizes each chunk,
then combines chunk summaries into a final summary.
"""
CHUNK_TARGET_TOKENS = 3500
COMBINE_THRESHOLD = 3000

# Extract text from all records
texts = []
total_tokens = 0
for record in self.records:
text = record.output_text()
record_tokens = tokens.approximate_tokens(text)
texts.append((text, record_tokens))
total_tokens += record_tokens

if not texts:
self.summary = ""
return self.summary

# Single chunk case - summarize directly
if total_tokens <= CHUNK_TARGET_TOKENS:
self.summary = await self.history.agent.call_utility_model(
system=self.history.agent.read_prompt("fw.topic_summary.sys.md"),
message=self.history.agent.read_prompt(
"fw.topic_summary.msg.md", content=self.output_text()
),
)
return self.summary

# Multi-chunk case - chunk and summarize
chunks: list[list[str]] = []
current_chunk: list[str] = []
current_tokens = 0

for text, record_tokens in texts:
if record_tokens > CHUNK_TARGET_TOKENS:
# Oversized record gets its own chunk
if current_chunk:
chunks.append(current_chunk)
chunks.append([text])
current_chunk = []
current_tokens = 0
elif current_tokens + record_tokens > CHUNK_TARGET_TOKENS and current_chunk:
chunks.append(current_chunk)
current_chunk = [text]
current_tokens = record_tokens
else:
current_chunk.append(text)
current_tokens += record_tokens

if current_chunk:
chunks.append(current_chunk)

# Summarize chunks sequentially
chunk_summaries: list[str] = []
for chunk in chunks:
try:
combined = "\n".join(chunk)
summary = await self.history.agent.call_utility_model(
system=self.history.agent.read_prompt("fw.topic_summary.sys.md"),
message=self.history.agent.read_prompt(
"fw.topic_summary.msg.md", content=combined
),
)
chunk_summaries.append(summary if summary and summary.strip() else "...")
except Exception:
# Fallback: truncate the chunk
combined = "\n".join(chunk)
words = combined.split()
chunk_summaries.append(" ".join(words[:100]) + ("..." if len(words) > 100 else ""))

# Combine summaries
valid = [s for s in chunk_summaries if s and s.strip()]
if not valid:
self.summary = "Summary unavailable."
return self.summary
if len(valid) == 1:
self.summary = valid[0]
return self.summary

combined = "\n".join([f"Part {i+1}: {s}" for i, s in enumerate(valid)])
combined_tokens = tokens.approximate_tokens(combined)

if combined_tokens > COMBINE_THRESHOLD:
self.summary = await self.history.agent.call_utility_model(
system=self.history.agent.read_prompt("fw.topic_summary.sys.md"),
message=self.history.agent.read_prompt(
"fw.topic_summary.msg.md", content=combined
),
)
return self.summary
self.summary = " ".join(valid)
return self.summary

def to_dict(self):
Expand Down
Loading